From 7294dd47ef799b40b5f5e80604f31a091f0269d9 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 15 May 2026 15:44:08 +0200 Subject: [PATCH] updated breakcore theme --- .../src/components/react/DeletePostButton.tsx | 11 +- frontend/src/components/react/PostList.tsx | 11 +- .../components/react/admin/AssetManager.tsx | 8 +- .../src/components/react/admin/Editor.tsx | 8 +- frontend/src/components/react/admin/Inbox.tsx | 10 +- frontend/src/layouts/Layout.astro | 2 +- frontend/src/lib/confirm.ts | 131 +++++++ frontend/src/styles/global.css | 329 ++++++++++++++++++ 8 files changed, 501 insertions(+), 9 deletions(-) create mode 100644 frontend/src/lib/confirm.ts diff --git a/frontend/src/components/react/DeletePostButton.tsx b/frontend/src/components/react/DeletePostButton.tsx index 0f85539..17a0aa7 100644 --- a/frontend/src/components/react/DeletePostButton.tsx +++ b/frontend/src/components/react/DeletePostButton.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { deletePost } from '../../lib/api'; +import { confirmDialog, notify } from '../../lib/confirm'; interface Props { slug: string; @@ -12,13 +13,19 @@ export default function DeletePostButton({ slug, title, variant = 'full' }: Prop async function handleClick() { if (busy) return; - if (!window.confirm(`Delete "${title}"? This cannot be undone.`)) return; + const ok = await confirmDialog({ + title: 'Delete this work?', + message: `“${title}” will be permanently removed. This cannot be undone.`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + }); + if (!ok) return; setBusy(true); try { await deletePost(slug); window.location.href = '/'; } catch (e) { - window.alert(`Failed to delete: ${e instanceof Error ? e.message : 'unknown error'}`); + notify(`Failed to delete: ${e instanceof Error ? e.message : 'unknown error'}`); setBusy(false); } } diff --git a/frontend/src/components/react/PostList.tsx b/frontend/src/components/react/PostList.tsx index ccac36a..bedc1ce 100644 --- a/frontend/src/components/react/PostList.tsx +++ b/frontend/src/components/react/PostList.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { deletePost } from '../../lib/api'; +import { confirmDialog, notify } from '../../lib/confirm'; const PAGE_SIZE = 9; @@ -66,13 +67,19 @@ export default function PostList({ posts: initialPosts, isAdmin = false }: Props async function handleDelete(slug: string, title: string) { if (deleting) return; - if (!window.confirm(`Take "${title}" off the wall? This cannot be undone.`)) return; + const ok = await confirmDialog({ + title: 'Take this off the wall?', + message: `“${title}” will be removed from the catalogue. This cannot be undone.`, + confirmLabel: 'Remove', + cancelLabel: 'Keep', + }); + if (!ok) return; setDeleting(slug); try { await deletePost(slug); setPosts(p => p.filter(x => x.slug !== slug)); } catch (e) { - window.alert(`Failed to remove: ${e instanceof Error ? e.message : 'unknown error'}`); + notify(`Failed to remove: ${e instanceof Error ? e.message : 'unknown error'}`); } finally { setDeleting(null); } diff --git a/frontend/src/components/react/admin/AssetManager.tsx b/frontend/src/components/react/admin/AssetManager.tsx index 60c0d58..d794d41 100644 --- a/frontend/src/components/react/admin/AssetManager.tsx +++ b/frontend/src/components/react/admin/AssetManager.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { getAssets, uploadAsset, deleteAsset as deleteAssetApi, ApiError } from '../../../lib/api'; import type { Asset } from '../../../lib/types'; +import { confirmDialog } from '../../../lib/confirm'; interface Props { mode?: 'manage' | 'select'; @@ -42,7 +43,12 @@ export default function AssetManager({ mode = 'manage', onSelect }: Props) { } async function handleDelete(name: string) { - if (!confirm(`Delete "${name}" permanently?`)) return; + const ok = await confirmDialog({ + title: 'Delete asset?', + message: `“${name}” will be permanently deleted. Posts referencing it will break.`, + confirmLabel: 'Delete', + }); + if (!ok) return; try { await deleteAssetApi(name); showAlert('File deleted.', 'success'); diff --git a/frontend/src/components/react/admin/Editor.tsx b/frontend/src/components/react/admin/Editor.tsx index 8a40948..43c588f 100644 --- a/frontend/src/components/react/admin/Editor.tsx +++ b/frontend/src/components/react/admin/Editor.tsx @@ -8,6 +8,7 @@ import { search, searchKeymap } from '@codemirror/search'; import { closeBrackets } from '@codemirror/autocomplete'; import { getPost, savePost, deletePost, getAssets, uploadAsset, ApiError } from '../../../lib/api'; import type { Asset } from '../../../lib/types'; +import { confirmDialog } from '../../../lib/confirm'; const AssetManager = lazy(() => import('./AssetManager')); @@ -386,7 +387,12 @@ export default function Editor({ editSlug }: Props) { async function handleDelete() { const target = originalSlug || slug; - if (!confirm(`Remove work "${target}" from the catalogue permanently?`)) return; + const ok = await confirmDialog({ + title: 'Remove from catalogue?', + message: `“${target}” will be permanently removed. This cannot be undone.`, + confirmLabel: 'Remove', + }); + if (!ok) return; try { await deletePost(target); window.location.href = '/admin'; diff --git a/frontend/src/components/react/admin/Inbox.tsx b/frontend/src/components/react/admin/Inbox.tsx index 7280608..3b78d29 100644 --- a/frontend/src/components/react/admin/Inbox.tsx +++ b/frontend/src/components/react/admin/Inbox.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { listMessages, deleteMessage, ApiError } from '../../../lib/api'; import type { Message } from '../../../lib/types'; +import { confirmDialog, notify } from '../../../lib/confirm'; export default function Inbox() { const [messages, setMessages] = useState(null); @@ -23,12 +24,17 @@ export default function Inbox() { } async function remove(id: string) { - if (!confirm('Delete this message? This cannot be undone.')) return; + const ok = await confirmDialog({ + title: 'Delete this message?', + message: 'This cannot be undone.', + confirmLabel: 'Delete', + }); + if (!ok) return; try { await deleteMessage(id); setMessages(prev => (prev ?? []).filter(m => m.id !== id)); } catch (e) { - alert(e instanceof ApiError ? e.message : 'Failed to delete.'); + notify(e instanceof ApiError ? e.message : 'Failed to delete.'); } } diff --git a/frontend/src/layouts/Layout.astro b/frontend/src/layouts/Layout.astro index 435f4a7..bf72026 100644 --- a/frontend/src/layouts/Layout.astro +++ b/frontend/src/layouts/Layout.astro @@ -57,7 +57,7 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0; - + {fullTitle} diff --git a/frontend/src/lib/confirm.ts b/frontend/src/lib/confirm.ts new file mode 100644 index 0000000..a7f81aa --- /dev/null +++ b/frontend/src/lib/confirm.ts @@ -0,0 +1,131 @@ +/* + * Imperative confirm() / notify() that replace native window.confirm / + * window.alert. The site hydrates many independent React islands that share + * no React root, so this is a vanilla DOM singleton: any island (or inline + * script) can `await confirmDialog(...)`. Styling is class-driven, so it + * inherits every theme — including the breakcore neon/hazard layer — for free. + */ + +export interface ConfirmOptions { + title: string; + message?: string; + confirmLabel?: string; + cancelLabel?: string; + /** Visual weight of the confirm button. Defaults to 'danger'. */ + tone?: 'danger' | 'primary'; +} + +let activeCleanup: (() => void) | null = null; + +export function confirmDialog(opts: ConfirmOptions): Promise { + // Collapse any in-flight dialog (treat as cancel) before opening a new one. + activeCleanup?.(); + + return new Promise(resolve => { + const { + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + tone = 'danger', + } = opts; + + const lastFocused = document.activeElement as HTMLElement | null; + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + const overlay = document.createElement('div'); + overlay.className = 'cdialog-overlay'; + overlay.setAttribute('role', 'alertdialog'); + overlay.setAttribute('aria-modal', 'true'); + + const backdrop = document.createElement('div'); + backdrop.className = 'cdialog-backdrop'; + backdrop.setAttribute('aria-hidden', 'true'); + + const panel = document.createElement('div'); + panel.className = 'glass cdialog-panel'; + + const titleId = `cdlg-t-${Math.random().toString(36).slice(2)}`; + const h = document.createElement('h2'); + h.className = 'cdialog-title'; + h.id = titleId; + h.textContent = title; + overlay.setAttribute('aria-labelledby', titleId); + panel.appendChild(h); + + if (message) { + const p = document.createElement('p'); + p.className = 'cdialog-msg'; + p.textContent = message; + panel.appendChild(p); + } + + const actions = document.createElement('div'); + actions.className = 'cdialog-actions'; + + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'btn btn--ghost'; + cancelBtn.textContent = cancelLabel; + + const confirmBtn = document.createElement('button'); + confirmBtn.type = 'button'; + confirmBtn.className = `btn ${tone === 'primary' ? 'btn--primary' : 'btn--danger'}`; + confirmBtn.textContent = confirmLabel; + + actions.append(cancelBtn, confirmBtn); + panel.appendChild(actions); + overlay.append(backdrop, panel); + document.body.appendChild(overlay); + + let settled = false; + function close(result: boolean) { + if (settled) return; + settled = true; + activeCleanup = null; + document.removeEventListener('keydown', onKey, true); + document.body.style.overflow = prevOverflow; + overlay.remove(); + lastFocused?.focus?.(); + resolve(result); + } + + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + close(false); + } else if (e.key === 'Tab') { + // Two-stop focus trap. + e.preventDefault(); + const next = document.activeElement === confirmBtn ? cancelBtn : confirmBtn; + next.focus(); + } else if (e.key === 'Enter' && document.activeElement === confirmBtn) { + e.preventDefault(); + close(true); + } + } + + backdrop.addEventListener('click', () => close(false)); + cancelBtn.addEventListener('click', () => close(false)); + confirmBtn.addEventListener('click', () => close(true)); + document.addEventListener('keydown', onKey, true); + activeCleanup = () => close(false); + + // Destructive default: focus Cancel so an accidental Enter is safe. + (tone === 'primary' ? confirmBtn : cancelBtn).focus(); + }); +} + +/** Transient bottom-center toast. Replaces window.alert for failures. */ +export function notify(message: string, tone: 'error' | 'success' = 'error') { + document.querySelector('.toast[data-notify]')?.remove(); + const el = document.createElement('div'); + el.className = `toast${tone === 'error' ? ' toast--error' : ''}`; + el.dataset.notify = ''; + el.setAttribute('role', tone === 'error' ? 'alert' : 'status'); + el.textContent = message; + el.addEventListener('click', () => el.remove()); + document.body.appendChild(el); + window.setTimeout(() => el.remove(), 4500); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index e43e095..a5bdf22 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -688,6 +688,21 @@ code, pre, kbd, samp { 0 32px 60px -28px rgba(255, 46, 166, 0.45), 0 0 32px -8px color-mix(in srgb, var(--mauve) 50%, transparent); } +/* Keyboard focus for the card link — salon-appropriate inset frame + ring. */ +.plate:focus-visible { + outline: none; + box-shadow: + inset 0 0 0 2px var(--mauve), + 0 0 0 3px color-mix(in srgb, var(--mauve) 40%, transparent), + 0 22px 42px -28px rgba(20, 16, 12, 0.5); +} +.breakcore .plate:focus-visible { + box-shadow: + inset 0 0 0 2px var(--mauve), + 0 0 0 2px var(--green), + 0 0 28px -6px color-mix(in srgb, var(--mauve) 60%, transparent); +} + .plate .plate-image { position: relative; overflow: hidden; @@ -1037,6 +1052,7 @@ code, pre, kbd, samp { .topbar-control:focus-visible { outline: none; border-color: var(--mauve); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--mauve) 40%, transparent); } .topbar-control:disabled { opacity: 0.5; cursor: default; } .topbar-control svg { width: 14px; height: 14px; flex-shrink: 0; } @@ -1185,3 +1201,316 @@ input[type="date"] { color-scheme: light; } transform: scaleX(0); transition: transform 80ms linear; } + +/* ═══════════════════════════════════════════════════════════════════════ + * BREAKCORE — refined-neon layer. + * Everything below is scoped to `.breakcore`; salon / salon-noir / gothic + * are untouched. Aesthetic: editorial serif body in deliberate tension with + * hard-edged web-rot chrome — RGB split, hazard tape, neon outline, hard + * offset shadows. Motion is *reactive only* (hover / focus / one-shot on + * load) and settles fast. All motion is killed by prefers-reduced-motion + * at the very end of this file. + * ═══════════════════════════════════════════════════════════════════════ */ + +/* CRT tube depth — static vignette layered on the existing base fill. */ +.breakcore body::before { + background-image: radial-gradient( + ellipse at center, + transparent 52%, + color-mix(in srgb, var(--crust) 75%, transparent) 100% + ); +} + +/* Nameplate — striped datamosh underline + glitch-shear burst on hover. */ +.breakcore .nameplate::after { + height: 3px; + bottom: -6px; + opacity: 0.9; + background: repeating-linear-gradient( + 90deg, + var(--mauve) 0 6px, + var(--green) 6px 12px, + var(--blue) 12px 18px + ); +} +@keyframes bc-shear { + 0% { clip-path: inset(0 0 0 0); transform: translateX(0); + text-shadow: -1px 0 0 var(--teal), 1px 0 0 var(--mauve); } + 20% { clip-path: inset(16% 0 56% 0); transform: translateX(-5px); + text-shadow: -5px 0 0 var(--green), 5px 0 0 var(--mauve); } + 40% { clip-path: inset(62% 0 10% 0); transform: translateX(5px); + text-shadow: 5px 0 0 var(--teal), -5px 0 0 var(--red); } + 60% { clip-path: inset(30% 0 42% 0); transform: translateX(-3px); + text-shadow: -3px 0 0 var(--mauve), 3px 0 0 var(--green); } + 80% { clip-path: inset(6% 0 78% 0); transform: translateX(2px); + text-shadow: 2px 0 0 var(--teal), -2px 0 0 var(--mauve); } + 100% { clip-path: inset(0 0 0 0); transform: translateX(0); + text-shadow: -1px 0 0 var(--teal), 1px 0 0 var(--mauve); } +} +.breakcore .nameplate:hover .nameplate-title { + animation: bc-shear 200ms steps(3, jump-none) 1; +} + +/* Display headings — one-shot glitch-in on page load. The static chromatic + * text-shadow (defined earlier) remains as the resting state. */ +@keyframes bc-load-glitch { + 0% { opacity: 0; clip-path: inset(46% 0 46% 0); transform: translateX(-9px); } + 20% { opacity: 1; clip-path: inset(8% 0 70% 0); transform: translateX(7px); } + 40% { clip-path: inset(68% 0 8% 0); transform: translateX(-5px); } + 60% { clip-path: inset(24% 0 36% 0); transform: translateX(3px); } + 80% { clip-path: inset(4% 0 84% 0); transform: translateX(-2px); } + 100% { opacity: 1; clip-path: inset(0 0 0 0); transform: translateX(0); } +} +.breakcore .prose h1, +.breakcore h1.font-display { + animation: bc-load-glitch 460ms steps(5, jump-none) both; +} + +/* Plate — hard hover (no soft lift), RGB-split image, scanline sweep. */ +.breakcore .plate:hover { + transform: translateY(-3px); +} +.breakcore .plate:hover .plate-caption-title { + text-shadow: -1px 0 0 var(--teal), 1px 0 0 var(--mauve); +} +.breakcore .plate:hover .plate-image img { + filter: + drop-shadow(-3px 0 0 color-mix(in srgb, var(--mauve) 70%, transparent)) + drop-shadow(3px 0 0 color-mix(in srgb, var(--teal) 70%, transparent)) + saturate(1.12) contrast(1.06); +} +.breakcore .plate .plate-image::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + transform: translateY(-110%); + mix-blend-mode: screen; + background: linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--sky) 28%, transparent) 46%, + color-mix(in srgb, var(--mauve) 70%, transparent) 49%, + color-mix(in srgb, var(--green) 55%, transparent) 51%, + color-mix(in srgb, var(--sky) 28%, transparent) 54%, + transparent 100% + ); +} +@keyframes bc-scan { + 0% { transform: translateY(-110%); opacity: 0; } + 12% { opacity: 1; } + 88% { opacity: 1; } + 100% { transform: translateY(110%); opacity: 0; } +} +.breakcore .plate:hover .plate-image::after, +.breakcore .plate:focus-visible .plate-image::after { + animation: bc-scan 0.62s cubic-bezier(0.4, 0, 0.2, 1) 1; +} + +/* Section rule — hazard tape. Used on footer, post header, 404. */ +.breakcore .section-rule { + color: var(--green); + font-family: var(--font-mono); + font-style: normal; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.22em; +} +.breakcore .section-rule::before, +.breakcore .section-rule::after { + height: 6px; + opacity: 0.55; + background: repeating-linear-gradient( + 45deg, + var(--yellow) 0 8px, + var(--crust) 8px 16px + ); +} +.breakcore .section-rule .ornament { + color: var(--mauve); +} + +/* Chips — neon outline, monospace caps. */ +.breakcore .chip { + background: transparent; + border-color: color-mix(in srgb, var(--teal) 55%, transparent); + color: var(--teal); + font-family: var(--font-mono); + font-style: normal; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + border-radius: 0; +} +.breakcore .chip-accent { + background: var(--mauve); + color: var(--crust); + border-color: var(--mauve); +} +.breakcore .chip-draft { + background: transparent; + border-color: color-mix(in srgb, var(--green) 60%, transparent); + color: var(--green); +} + +/* Plate caption meta — bracketed mono coordinates. */ +.breakcore .plate-caption-meta { + font-family: var(--font-mono); + letter-spacing: 0.16em; +} +.breakcore .plate-caption-sep { + color: var(--green); + opacity: 1; +} + +/* Buttons & inputs — square, hard offset block-shadow, neon focus. */ +.breakcore .btn, +.breakcore .field-input, +.breakcore .topbar-control, +.breakcore .topbar-control kbd { border-radius: 0; } +.breakcore .btn--primary { + color: var(--crust); + border-color: var(--mauve); + box-shadow: 3px 3px 0 0 var(--green); +} +.breakcore .btn--primary:hover { + background: var(--green); + border-color: var(--green); + color: var(--crust); + box-shadow: 3px 3px 0 0 var(--mauve); +} +.breakcore .btn--primary:active { + transform: translate(2px, 2px); + box-shadow: 1px 1px 0 0 var(--mauve); +} +.breakcore .btn--danger { + box-shadow: 3px 3px 0 0 color-mix(in srgb, var(--red) 60%, var(--crust)); +} +.breakcore .btn--danger:hover { + box-shadow: 3px 3px 0 0 var(--mauve); +} +.breakcore .btn--danger:active { + transform: translate(2px, 2px); + box-shadow: 1px 1px 0 0 var(--mauve); +} +.breakcore .btn:focus-visible { + border-color: var(--green); + box-shadow: 0 0 0 2px var(--green); +} +.breakcore .field-input:focus { + border-color: var(--green); + background: color-mix(in srgb, var(--surface0) 85%, var(--green) 8%); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 35%, transparent); +} + +/* Prose links — magenta resting, acid-green on hover. */ +.breakcore .prose a { + text-decoration-color: color-mix(in srgb, var(--mauve) 55%, transparent); +} +.breakcore .prose a:hover { + color: var(--green); + text-decoration-color: var(--green); +} + +/* Reading progress — acid scan with bloom. */ +.breakcore .reading-progress { + background: var(--green); + box-shadow: 0 0 8px var(--green), 0 0 3px var(--mauve); +} + +/* ───── Confirm dialog (replaces window.confirm) ───── */ +.cdialog-overlay { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} +.cdialog-backdrop { + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--crust) 60%, transparent); + backdrop-filter: blur(8px); +} +.cdialog-panel { + position: relative; + width: 100%; + max-width: 26rem; + padding: 1.6rem 1.6rem 1.4rem; + animation: cdialog-in 0.18s cubic-bezier(0.2, 0.7, 0.2, 1); +} +@keyframes cdialog-in { + from { opacity: 0; transform: translateY(10px) scale(0.98); } + to { opacity: 1; transform: none; } +} +.cdialog-title { + font-family: var(--font-display); + font-style: italic; + font-weight: 600; + font-size: 1.4rem; + line-height: 1.15; + color: var(--text); + letter-spacing: -0.01em; +} +.cdialog-msg { + font-family: var(--font-sans); + font-size: 0.98rem; + line-height: 1.55; + color: var(--subtext1); + margin-top: 0.6rem; +} +.cdialog-actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; + margin-top: 1.5rem; +} + +/* Breakcore: hard edges + hazard cap + chromatic title. */ +.breakcore .cdialog-panel { + border-radius: 0; + padding-top: 1.9rem; +} +.breakcore .cdialog-panel::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 5px; + background: repeating-linear-gradient( + 45deg, + var(--yellow) 0 8px, + var(--crust) 8px 16px + ); + opacity: 0.6; +} +.breakcore .cdialog-title { + text-shadow: -1px 0 0 var(--teal), 1px 0 0 var(--mauve); +} + +/* Toast error variant (replaces window.alert). */ +.toast--error { + border-left: 3px solid var(--red); + color: var(--rosewater); + cursor: pointer; +} +.toast--error::before { + content: "⚠ "; + color: var(--red); +} + +/* ═══ Reduced motion — universal kill-switch. Final word in the file so it + * overrides every animation/transition above, all themes. Content still + * resolves to its final state (forwards-filled keyframes complete). ═══ */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +}