init elas atelier #1
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
<nav class="topbar-cluster w-full md:w-auto" aria-label="Site controls">
|
||||||
{hasContact && (
|
{hasContact && (
|
||||||
<a href="/contact" class="topbar-control">Contact</a>
|
<a href="/contact" class="topbar-control">Contact</a>
|
||||||
)}
|
)}
|
||||||
<Search client:idle />
|
<Search client:idle />
|
||||||
<ThemeSwitcher client:only="react" defaultTheme={siteConfig.theme} />
|
<ThemeSwitcher client:only="react" defaultTheme={siteConfig.theme} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div class="flex items-center md:pl-3 md:border-l md:border-[var(--surface2)]/60">
|
<>
|
||||||
|
<span class="topbar-divider" aria-hidden="true"></span>
|
||||||
<LogoutButton client:idle />
|
<LogoutButton client:idle />
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -139,7 +145,9 @@ 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'}`}>
|
||||||
|
{!minimal && (
|
||||||
|
<>
|
||||||
<div class="section-rule mb-6">
|
<div class="section-rule mb-6">
|
||||||
<span class="ornament">✦</span>
|
<span class="ornament">✦</span>
|
||||||
<span class="font-display italic text-[var(--subtext0)] text-sm">end of catalogue</span>
|
<span class="font-display italic text-[var(--subtext0)] text-sm">end of catalogue</span>
|
||||||
@@ -172,6 +180,8 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div class="text-[var(--overlay0)] text-xs italic">
|
<div class="text-[var(--overlay0)] text-xs italic">
|
||||||
© {year} · {siteConfig.title}
|
© {year} · {siteConfig.title}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
Reference in New Issue
Block a user