init elas atelier #1

Merged
nvrl merged 82 commits from ela into main 2026-05-18 13:55:42 +02:00
8 changed files with 143 additions and 69 deletions
Showing only changes of commit ab6f6088f2 - Show all commits
@@ -22,7 +22,7 @@ export default function LogoutButton() {
disabled={busy} disabled={busy}
title="Sign out" title="Sign out"
aria-label="Sign out" aria-label="Sign out"
className="topbar-control topbar-control--danger" className="topbar-control topbar-control--danger tc-collapse-sm"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -38,7 +38,7 @@ export default function LogoutButton() {
<polyline points="16 17 21 12 16 7" /> <polyline points="16 17 21 12 16 7" />
<line x1="21" x2="9" y1="12" y2="12" /> <line x1="21" x2="9" y1="12" y2="12" />
</svg> </svg>
<span>{busy ? 'Signing out…' : 'Sign out'}</span> <span className="tc-label">{busy ? 'Signing out…' : 'Sign out'}</span>
</button> </button>
); );
} }
+3 -3
View File
@@ -130,7 +130,7 @@ export default function Search() {
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label={`Search the catalogue (${isMac ? '⌘' : 'Ctrl'}+K)`} aria-label={`Search the catalogue (${isMac ? '⌘' : 'Ctrl'}+K)`}
title={`Search (${isMac ? '⌘' : 'Ctrl'}+K)`} title={`Search (${isMac ? '⌘' : 'Ctrl'}+K)`}
className="topbar-control" className="topbar-control tc-collapse-md"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -145,8 +145,8 @@ export default function Search() {
<circle cx="11" cy="11" r="8" /> <circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" /> <path d="m21 21-4.3-4.3" />
</svg> </svg>
<span>Search</span> <span className="tc-label">Search</span>
<kbd className="hidden md:inline-flex"> <kbd className="tc-kbd">
<span>{isMac ? '⌘' : 'Ctrl'}</span><span className="opacity-50">+</span><span>K</span> <span>{isMac ? '⌘' : 'Ctrl'}</span><span className="opacity-50">+</span><span>K</span>
</kbd> </kbd>
</button> </button>
@@ -43,7 +43,7 @@ export default function ThemeSwitcher({ defaultTheme = 'salon' }: Props) {
value={theme} value={theme}
onChange={(e) => setTheme(e.target.value)} onChange={(e) => setTheme(e.target.value)}
aria-label="Theme" aria-label="Theme"
className="topbar-control" className="topbar-control theme-select"
> >
{THEMES.map(t => ( {THEMES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option> <option key={t.value} value={t.value}>{t.label}</option>
+20 -5
View File
@@ -29,8 +29,8 @@ export default function Login() {
} }
return ( return (
<div className="max-w-md mx-auto mt-16"> <div className="max-w-md mx-auto mt-10 md:mt-20 px-1">
<div className="glass p-10"> <div className="glass p-7 sm:p-10">
<div className="font-display italic text-[var(--subtext0)] text-xs tracking-[0.3em] uppercase mb-3 text-center"> <div className="font-display italic text-[var(--subtext0)] text-xs tracking-[0.3em] uppercase mb-3 text-center">
Artist's entrance Artist's entrance
</div> </div>
@@ -43,7 +43,7 @@ export default function Login() {
<p className="text-[var(--subtext1)] mb-8 text-center font-display italic"> <p className="text-[var(--subtext1)] mb-8 text-center font-display italic">
Present your token to enter the back room. Present your token to enter the back room.
</p> </p>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6" noValidate>
<div> <div>
<label htmlFor="token" className="field-label">Admin token</label> <label htmlFor="token" className="field-label">Admin token</label>
<input <input
@@ -53,24 +53,39 @@ export default function Login() {
value={value} value={value}
onChange={e => setValue(e.target.value)} onChange={e => setValue(e.target.value)}
autoComplete="current-password" autoComplete="current-password"
aria-invalid={error ? true : undefined}
aria-describedby={error ? 'login-error' : undefined}
className="field-input font-mono tracking-widest" className="field-input font-mono tracking-widest"
placeholder="••••••••••••" placeholder="••••••••••••"
/> />
</div> </div>
{error && ( {error && (
<p className="text-sm text-[var(--red)] bg-[var(--red)]/10 border border-[var(--red)]/30 px-3 py-2 font-display italic"> <p
id="login-error"
role="alert"
className="text-sm text-[var(--red)] bg-[var(--red)]/10 border border-[var(--red)]/30 px-3 py-2 font-display italic"
>
{error} {error}
</p> </p>
)} )}
<button <button
type="submit" type="submit"
disabled={busy} disabled={busy}
className="btn btn--primary btn--block" className="btn btn--primary btn--block btn--lg"
> >
{busy ? 'Unlocking' : 'Enter'} {busy ? 'Unlocking' : 'Enter'}
</button> </button>
</form> </form>
</div> </div>
<div className="text-center mt-6">
<a
href="/"
className="inline-flex items-center gap-1.5 text-xs font-display italic text-[var(--subtext0)] hover:text-[var(--mauve)] transition-colors group"
>
<span className="transition-transform group-hover:-translate-x-0.5"></span>
Back to the catalogue
</a>
</div>
</div> </div>
); );
} }
+57 -47
View File
@@ -14,9 +14,12 @@ interface Props {
description?: string; description?: string;
image?: string; image?: string;
type?: 'website' | 'article'; type?: 'website' | 'article';
/** Strips the top-bar controls and footer nav — used by the login page
* so the sign-in screen is a focused, chrome-free stage. */
minimal?: boolean;
} }
const { title, wide = false, description, image, type = 'website' } = Astro.props; const { title, wide = false, description, image, type = 'website', minimal = false } = Astro.props;
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000'; const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
const isAdmin = Astro.cookies.get('admin_session')?.value === '1'; const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
@@ -92,7 +95,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
<div class="salon-atmosphere" aria-hidden="true"></div> <div class="salon-atmosphere" aria-hidden="true"></div>
<header class="border-b border-[var(--surface2)]/60"> <header class="border-b border-[var(--surface2)]/60">
<div class="max-w-6xl mx-auto px-6 md:px-10 py-5 md:py-7 flex flex-col md:flex-row md:items-end gap-4 md:gap-6"> <div class={`max-w-6xl mx-auto px-6 md:px-10 py-5 md:py-7 flex flex-col gap-4 md:flex-row md:items-end md:gap-6 ${minimal ? 'justify-center text-center' : 'md:justify-between'}`}>
<a href="/" class="nameplate group" aria-label="Home"> <a href="/" class="nameplate group" aria-label="Home">
{isAdmin ? ( {isAdmin ? (
<EditableText <EditableText
@@ -120,18 +123,21 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
)} )}
</a> </a>
<div class="flex flex-wrap items-center gap-2 md:gap-3 justify-start md:justify-end md:ml-auto w-full md:w-auto"> {!minimal && (
{hasContact && ( <nav class="topbar-cluster w-full md:w-auto" aria-label="Site controls">
<a href="/contact" class="topbar-control">Contact</a> {hasContact && (
)} <a href="/contact" class="topbar-control">Contact</a>
<Search client:idle /> )}
<ThemeSwitcher client:only="react" defaultTheme={siteConfig.theme} /> <Search client:idle />
{isAdmin && ( <ThemeSwitcher client:only="react" defaultTheme={siteConfig.theme} />
<div class="flex items-center md:pl-3 md:border-l md:border-[var(--surface2)]/60"> {isAdmin && (
<LogoutButton client:idle /> <>
</div> <span class="topbar-divider" aria-hidden="true"></span>
)} <LogoutButton client:idle />
</div> </>
)}
</nav>
)}
</div> </div>
</header> </header>
@@ -139,39 +145,43 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
<slot /> <slot />
</main> </main>
<footer class="max-w-6xl mx-auto px-6 md:px-10 py-12 md:py-16 text-center border-t border-[var(--surface2)]/40 mt-12"> <footer class={`max-w-6xl mx-auto px-6 md:px-10 text-center border-t border-[var(--surface2)]/40 ${minimal ? 'py-8 mt-8' : 'py-12 md:py-16 mt-12'}`}>
<div class="section-rule mb-6"> {!minimal && (
<span class="ornament">✦</span> <>
<span class="font-display italic text-[var(--subtext0)] text-sm">end of catalogue</span> <div class="section-rule mb-6">
<span class="ornament">✦</span> <span class="ornament">✦</span>
</div> <span class="font-display italic text-[var(--subtext0)] text-sm">end of catalogue</span>
<p class="font-display italic text-base text-[var(--subtext1)] mb-2"> <span class="ornament">✦</span>
{isAdmin ? ( </div>
<EditableText <p class="font-display italic text-base text-[var(--subtext1)] mb-2">
client:visible {isAdmin ? (
initial={siteConfig.footer} <EditableText
fieldKey="footer" client:visible
isAdmin initial={siteConfig.footer}
ariaLabel="footer text" fieldKey="footer"
className="inline" isAdmin
/> ariaLabel="footer text"
) : siteConfig.footer} className="inline"
</p> />
<div class="text-xs text-[var(--subtext0)] tracking-[0.2em] uppercase mb-3 flex items-center justify-center gap-3 flex-wrap"> ) : siteConfig.footer}
<a href="/feed.xml" class="hover:text-[var(--mauve)] transition-colors">RSS Feed</a> </p>
{hasContact && ( <div class="text-xs text-[var(--subtext0)] tracking-[0.2em] uppercase mb-3 flex items-center justify-center gap-3 flex-wrap">
<> <a href="/feed.xml" class="hover:text-[var(--mauve)] transition-colors">RSS Feed</a>
<span class="text-[var(--overlay0)]" aria-hidden="true">·</span> {hasContact && (
<a href="/contact" class="hover:text-[var(--mauve)] transition-colors">Contact</a> <>
</> <span class="text-[var(--overlay0)]" aria-hidden="true">·</span>
)} <a href="/contact" class="hover:text-[var(--mauve)] transition-colors">Contact</a>
{!isAdmin && ( </>
<> )}
<span class="text-[var(--overlay0)]" aria-hidden="true">·</span> {!isAdmin && (
<a href="/admin/login" class="hover:text-[var(--mauve)] transition-colors">Admin login</a> <>
</> <span class="text-[var(--overlay0)]" aria-hidden="true">·</span>
)} <a href="/admin/login" class="hover:text-[var(--mauve)] transition-colors">Admin login</a>
</div> </>
)}
</div>
</>
)}
<div class="text-[var(--overlay0)] text-xs italic"> <div class="text-[var(--overlay0)] text-xs italic">
&copy; {year} · {siteConfig.title} &copy; {year} · {siteConfig.title}
</div> </div>
+1 -1
View File
@@ -3,6 +3,6 @@ import Layout from '../../layouts/Layout.astro';
import Login from '../../components/react/admin/Login'; import Login from '../../components/react/admin/Login';
--- ---
<Layout title="Admin Login"> <Layout title="Admin Login" description="Sign in to the back room." minimal>
<Login client:only="react" /> <Login client:only="react" />
</Layout> </Layout>
+1 -1
View File
@@ -91,7 +91,7 @@ const displayTitle = post ? (post.title || formatSlug(post.slug)) : 'Work';
{post && ( {post && (
<article class="plate-enter"> <article class="plate-enter">
{/* Toolbar — exhibit nav */} {/* Toolbar — exhibit nav */}
<div class="flex items-center justify-between gap-3 mb-10 md:mb-14 font-display italic text-sm text-[var(--subtext0)]"> <div class="flex flex-wrap items-center justify-between gap-3 mb-10 md:mb-14 font-display italic text-sm text-[var(--subtext0)]">
<a href="/" class="inline-flex items-center gap-2 hover:text-[var(--mauve)] transition-colors group"> <a href="/" class="inline-flex items-center gap-2 hover:text-[var(--mauve)] transition-colors group">
<span class="transition-transform group-hover:-translate-x-1">←</span> <span class="transition-transform group-hover:-translate-x-1">←</span>
Back to catalogue Back to catalogue
+58 -9
View File
@@ -974,18 +974,18 @@ code, pre, kbd, samp {
.btn--primary { .btn--primary {
background: var(--mauve); background: var(--mauve);
color: var(--rosewater); color: var(--rosewater);
border-color: var(--mauve); border-color: color-mix(in srgb, var(--mauve) 80%, var(--crust));
box-shadow: 0 4px 0 -2px color-mix(in srgb, var(--mauve) 55%, var(--crust)); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22);
} }
.btn--primary:hover { .btn--primary:hover {
transform: translateY(-1px); transform: translateY(-1px);
background: var(--red); background: var(--red);
border-color: var(--red); border-color: color-mix(in srgb, var(--red) 80%, var(--crust));
box-shadow: 0 6px 0 -2px color-mix(in srgb, var(--red) 55%, var(--crust)); box-shadow: 0 7px 16px -7px color-mix(in srgb, var(--red) 65%, transparent);
} }
.btn--primary:active { .btn--primary:active {
transform: translateY(1px); transform: translateY(0);
box-shadow: 0 1px 0 -1px color-mix(in srgb, var(--mauve) 55%, var(--crust)); box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
} }
.btn--ghost { .btn--ghost {
color: var(--subtext1); color: var(--subtext1);
@@ -1023,7 +1023,30 @@ code, pre, kbd, samp {
.btn--icon.btn--sm { width: 2rem; } .btn--icon.btn--sm { width: 2rem; }
.btn--block { width: 100%; } .btn--block { width: 100%; }
/* ───── Top-bar controls — one height, one language ───── */ /* ───── Top-bar controls — one height, one language ─────
* `.topbar-cluster` lays the chrome controls out as one tidy, right-aligned
* group that wraps as a unit (never a ragged full-width column on mobile).
* Every control is the same 2rem height; icon-only variants are exact
* squares so they line up cleanly next to each other. */
.topbar-cluster {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: flex-start;
}
@media (min-width: 768px) {
.topbar-cluster { justify-content: flex-end; }
}
/* A hairline divider between the public controls and the admin group. */
.topbar-divider {
align-self: stretch;
width: 1px;
margin: 0.15rem 0.15rem;
background: var(--surface2);
flex: none;
}
.topbar-control { .topbar-control {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1031,6 +1054,7 @@ code, pre, kbd, samp {
gap: 0.4rem; gap: 0.4rem;
height: 2rem; height: 2rem;
padding: 0 0.7rem; padding: 0 0.7rem;
flex: none;
font-family: var(--font-display); font-family: var(--font-display);
font-style: italic; font-style: italic;
font-size: 0.85rem; font-size: 0.85rem;
@@ -1055,17 +1079,25 @@ code, pre, kbd, samp {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mauve) 40%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--mauve) 40%, transparent);
} }
.topbar-control:disabled { opacity: 0.5; cursor: default; } .topbar-control:disabled { opacity: 0.5; cursor: default; }
.topbar-control svg { width: 14px; height: 14px; flex-shrink: 0; } .topbar-control svg { width: 15px; height: 15px; flex-shrink: 0; }
/* Exact-square icon-only variant — keeps the row aligned. */
.topbar-control--icon { width: 2rem; padding: 0; }
.topbar-control--danger:hover { .topbar-control--danger:hover {
color: var(--red); color: var(--red);
border-color: color-mix(in srgb, var(--red) 55%, var(--surface2)); border-color: color-mix(in srgb, var(--red) 55%, var(--surface2));
} }
/* Native <select> variant — leave room for the chevron overlay */ /* Native <select> variant — leave room for the chevron overlay.
* Fixed width so switching themes never resizes the whole top bar. */
select.topbar-control { select.topbar-control {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
padding-right: 1.9rem; padding-right: 1.9rem;
} }
select.topbar-control.theme-select {
width: 8.75rem;
justify-content: flex-start;
text-align: left;
}
.topbar-control kbd { .topbar-control kbd {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1079,6 +1111,23 @@ select.topbar-control {
color: var(--subtext0); color: var(--subtext0);
} }
/* Responsive collapse — below a breakpoint a control drops its label and
* becomes an exact 2rem square so the cluster stays a tidy aligned row on
* phones. Written unlayered (not Tailwind utilities) so it reliably wins
* over the `.topbar-control` base in the Tailwind v4 cascade. */
.topbar-control .tc-label { display: inline; }
@media (max-width: 1023px) {
.topbar-control .tc-kbd { display: none; }
}
@media (max-width: 767px) {
.topbar-control.tc-collapse-md { width: 2rem; padding: 0; }
.topbar-control.tc-collapse-md .tc-label { display: none; }
}
@media (max-width: 639px) {
.topbar-control.tc-collapse-sm { width: 2rem; padding: 0; }
.topbar-control.tc-collapse-sm .tc-label { display: none; }
}
/* Form input look */ /* Form input look */
.field-input { .field-input {
width: 100%; width: 100%;