Files
upbeatBytes/frontend/src/routes/quote/+page.svelte
T
thejayman77 50488a1885 /quote redesign: CD's "letter" QOTD (deckle frame, wax-seal date, watermark)
Rebuilt /quote to CD's QOTD v3 — a warm letter card with a dashed deckle frame,
an overlapping wax-seal date stamp, a giant faded quote-mark watermark, the quote
in Playfair Display italic, attribution, a "What it means" note, and Copy/Share.
Wired to live /api/quote/today (date → seal, text, author/work, meaning).

Uses our HubShell (HubBar + Back + footer + account icon) per the brief. Self-
hosted Playfair Display (roman+italic, OFL) for the signature quote/watermark/
seal; Newsreader for the body serif + Hanken for sans labels (kept to our stack
to avoid font sprawl). Rose accent matches CD (#a4607a family).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:46:28 -04:00

178 lines
8.2 KiB
Svelte

<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import HubShell from '$lib/components/HubShell.svelte';
let q = $state(null);
let state = $state('loading');
const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
let seal = $derived.by(() => {
if (!q?.date) return null;
const [, m, d] = q.date.split('-').map(Number);
return MONTHS[m - 1] ? { mon: MONTHS[m - 1], day: d } : null;
});
let copied = $state(false);
async function copyQuote() {
const attr = q?.author ? ` — ${q.author}${q.work ? ', ' + q.work : ''}` : '';
try {
await navigator.clipboard.writeText(`“${q.text}${attr}`);
copied = true; setTimeout(() => (copied = false), 1800);
} catch { /* no clipboard — silent */ }
}
let shared = $state(false);
async function share() {
const url = location.href;
try {
if (navigator.share) { await navigator.share({ title: 'Quote of the Day · upbeatBytes', text: `“${q.text}”`, url }); return; }
await navigator.clipboard.writeText(url);
shared = true; setTimeout(() => (shared = false), 1800);
} catch { /* cancelled / unsupported */ }
}
onMount(async () => {
try {
q = await getJSON('/api/quote/today');
state = q ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
</script>
<svelte:head>
<title>Quote of the Day · upbeatBytes</title>
<meta name="description" content="A hopeful quote every day — with its source and a plain-language take on what it means." />
</svelte:head>
<HubShell active="">
<article class="quote-page">
{#if state === 'ready'}
<!-- CD's "letter" treatment: warm card, dashed deckle frame, wax-seal date, faded quote-mark watermark -->
<div class="letter">
{#if seal}
<div class="seal" aria-hidden="true">
<span class="seal-mon">{seal.mon}</span>
<span class="seal-day">{seal.day}</span>
</div>
{/if}
<div class="deckle">
<span class="wm" aria-hidden="true"></span>
<div class="eyebrow">
<span class="eye-rule"></span>
<span class="eye-label">Quote of the Day</span>
</div>
<p class="quote">{q.text}</p>
{#if q.author}
<div class="attrib">
<span class="at-rule"></span>
<span class="at-by">{q.author}{#if q.work}, <span class="work">{q.work}</span>{/if}</span>
</div>
{/if}
{#if q.meaning}
<div class="hr"></div>
<div class="means-head">
<span class="mh-rule"></span>
<span class="mh-label">What it means</span>
</div>
<p class="meaning">{q.meaning}</p>
{/if}
<div class="actions">
<button class="act act-copy" onclick={copyQuote}>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
{copied ? 'Copied' : 'Copy'}
</button>
<button class="act act-share" onclick={share}>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
{shared ? 'Link copied' : 'Share'}
</button>
</div>
</div>
</div>
{:else if state === 'empty'}
<p class="note">Today's quote is on its way. Check back soon.</p>
{:else}
<p class="note">Finding today's quote…</p>
{/if}
</article>
</HubShell>
<style>
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-italic-var.woff2') format('woff2'); font-weight: 500 700; font-style: italic; font-display: swap; }
.quote-page { max-width: 880px; margin: 0 auto; }
/* the card + its inner deckle frame */
.letter {
position: relative; margin-top: clamp(24px, 4vw, 40px); background: #f6ead0;
border: 1px solid rgba(120, 90, 40, 0.10); border-radius: 16px;
box-shadow: 0 26px 50px -32px rgba(90, 60, 30, 0.6); padding: clamp(12px, 1.6vw, 18px);
}
.deckle {
position: relative; border: 1.5px dashed rgba(170, 110, 135, 0.4); border-radius: 11px;
padding: clamp(28px, 5vw, 40px) clamp(24px, 6vw, 56px) clamp(36px, 6vw, 54px); overflow: hidden;
}
/* giant faded closing-quote watermark, bleeding off the bottom-right */
.wm {
position: absolute; right: clamp(-20px, -2vw, -30px); bottom: clamp(-70px, -12vw, -120px);
font-family: 'Playfair Display', Georgia, serif; font-size: clamp(220px, 46vw, 400px); line-height: 1;
color: rgba(170, 110, 135, 0.08); pointer-events: none; user-select: none;
}
/* wax-seal date stamp, overlapping the top-right corner */
.seal {
position: absolute; top: -26px; right: clamp(20px, 6vw, 52px); z-index: 2;
width: clamp(62px, 9vw, 74px); height: clamp(62px, 9vw, 74px); border-radius: 50%;
background: #fbf3e3; border: 1.5px solid rgba(170, 110, 135, 0.55);
box-shadow: 0 8px 20px -8px rgba(90, 60, 30, 0.5); transform: rotate(-8deg);
display: flex; flex-direction: column; align-items: center; justify-content: center; color: #a4607a;
}
.seal-mon { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.18em; }
.seal-day { font-family: 'Playfair Display', Georgia, serif; font-size: clamp(28px, 4vw, 33px); font-weight: 700; line-height: 0.82; }
.eyebrow { position: relative; display: flex; align-items: center; gap: 14px; }
.eye-rule { width: 34px; height: 3px; background: #8f5e72; border-radius: 2px; flex: none; }
.eye-label { font-family: 'Hanken Grotesk', sans-serif; font-size: clamp(13px, 1.7vw, 16px); font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #7e4f63; }
.quote {
position: relative; font-family: 'Playfair Display', Georgia, serif; font-style: italic; font-weight: 600;
font-size: clamp(1.7rem, 5vw, 2.85rem); line-height: 1.18; letter-spacing: -0.01em; color: #3c2530;
margin: clamp(18px, 3vw, 24px) 0 0; max-width: 34ch;
}
.attrib { position: relative; display: flex; align-items: center; gap: 14px; margin-top: clamp(20px, 3vw, 26px); }
.at-rule { width: 28px; height: 1.5px; background: #c79bac; flex: none; }
.at-by { font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.6vw, 1.13rem); color: #8f5e72; }
.work { font-style: italic; }
.hr { position: relative; height: 1px; background: rgba(120, 90, 40, 0.16); margin: clamp(28px, 4vw, 38px) 0 24px; }
.means-head { position: relative; display: flex; align-items: center; gap: 12px; }
.mh-rule { width: 26px; height: 2px; background: #bcae93; border-radius: 2px; flex: none; }
.mh-label { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #a89880; }
.meaning {
position: relative; font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.8vw, 1.13rem);
line-height: 1.65; color: #5c5249; margin: 12px 0 0; max-width: 60ch;
}
.actions { position: relative; display: flex; gap: 12px; margin-top: clamp(26px, 4vw, 32px); flex-wrap: wrap; }
.act {
display: inline-flex; align-items: center; gap: 7px; font-family: 'Hanken Grotesk', sans-serif;
font-size: 14px; font-weight: 600; padding: 10px 18px; border-radius: 999px; cursor: pointer;
-webkit-tap-highlight-color: transparent; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.act-copy { background: #a4607a; color: #fff; border: none; }
.act-copy:hover { background: #8f5165; }
.act-share { background: transparent; color: #8f5e72; border: 1px solid #d3b3c0; }
.act-share:hover { border-color: #a4607a; color: #a4607a; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
</style>