50488a1885
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>
178 lines
8.2 KiB
Svelte
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>
|