Farbschema wie VVL-Tool, Dark Mode und lila Textmarkierung
- Farbwerte (hell/dunkel), IBM Plex Sans und Textmarkierung aus dem VVL-Tool übernommen - Umschalter Hell/Dunkel in der Kopfzeile, Wahl wird gemerkt (Standard: Systemeinstellung) - Kopfleiste <hi-topbar> folgt dem Farbschema des jeweiligen Tools Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cfe59e3652
commit
97acf2cb66
11 files changed
+221
-68
No files matched your search
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#2B0F71" />
|
<meta name="theme-color" content="#2d1b69" />
|
||||||
|
<script src="/theme-init.js"></script>
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
<title>Handy-Insel Portal</title>
|
<title>Handy-Insel Portal</title>
|
||||||
|
|||||||
+48
-37
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { api, type App as PortalApp, type Links, type News, type NewsInput, type User } from './api';
|
import { api, type App as PortalApp, type Links, type News, type NewsInput, type User } from './api';
|
||||||
import { Icon } from './icons';
|
import { Icon } from './icons';
|
||||||
|
import { useTheme } from './useTheme';
|
||||||
|
|
||||||
function greeting(date = new Date()): string {
|
function greeting(date = new Date()): string {
|
||||||
const h = date.getHours();
|
const h = date.getHours();
|
||||||
@@ -25,14 +26,14 @@ export function App() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!me) return <div className="grid min-h-screen place-items-center text-sm text-slate-500">Lade Portal …</div>;
|
if (!me) return <div className="grid min-h-screen place-items-center text-sm text-fg-3">Lade Portal …</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<Header user={me.user} links={me.links} />
|
<Header user={me.user} links={me.links} />
|
||||||
<main className="mx-auto grid w-full max-w-6xl flex-1 gap-12 px-4 pb-16 pt-10 sm:px-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:pt-14">
|
<main className="mx-auto grid w-full max-w-6xl flex-1 gap-12 px-4 pb-16 pt-10 sm:px-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:pt-14">
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-3xl font-bold leading-tight text-ink sm:text-4xl">
|
<h1 className="text-3xl font-bold leading-tight text-fg sm:text-4xl">
|
||||||
{greeting()},
|
{greeting()},
|
||||||
<br />
|
<br />
|
||||||
{me.user.firstName}.
|
{me.user.firstName}.
|
||||||
@@ -49,6 +50,7 @@ export function App() {
|
|||||||
|
|
||||||
function Header({ user, links }: { user: User; links: Links }) {
|
function Header({ user, links }: { user: User; links: Links }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const { theme, toggle } = useTheme();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,19 +67,28 @@ function Header({ user, links }: { user: User; links: Links }) {
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 border-b border-slate-200 bg-white">
|
<header className="sticky top-0 z-20 border-b border-line bg-surface">
|
||||||
<div className="flex h-14 items-center justify-between px-4 sm:px-6">
|
<div className="flex h-14 items-center justify-between px-4 sm:px-6">
|
||||||
<a href="/" className="flex items-center gap-2.5" aria-label="Handy-Insel Portal">
|
<a href="/" className="flex items-center gap-2.5" aria-label="Handy-Insel Portal">
|
||||||
<img src="/icon-192.png" alt="" className="h-8 w-8" />
|
<img src="/icon-192.png" alt="" className="logo-badge h-8 w-8 rounded-full" />
|
||||||
<span className="text-lg tracking-tight text-ink">
|
<span className="text-lg tracking-tight text-fg">
|
||||||
Handy-<span className="font-bold">Insel</span>
|
Handy-<span className="font-bold">Insel</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<div className="relative" ref={ref}>
|
<div className="relative flex items-center gap-2" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
className="grid h-9 w-9 place-items-center rounded-full text-fg-2 hover:bg-surface-3 hover:text-fg"
|
||||||
|
title={theme === 'dark' ? 'Helles Design' : 'Dunkles Design'}
|
||||||
|
aria-label={theme === 'dark' ? 'Helles Design' : 'Dunkles Design'}
|
||||||
|
>
|
||||||
|
<Icon name={theme === 'dark' ? 'sun' : 'moon'} className="h-[18px] w-[18px]" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="grid h-9 w-9 place-items-center rounded-full border-2 border-slate-300 bg-slate-100 text-xs font-bold text-ink hover:border-brand-500"
|
className="grid h-9 w-9 place-items-center rounded-full border-2 border-line-2 bg-surface-3 text-xs font-bold text-fg hover:border-accent"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
title={user.name}
|
title={user.name}
|
||||||
@@ -87,17 +98,17 @@ function Header({ user, links }: { user: User; links: Links }) {
|
|||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
className="absolute right-0 mt-2 w-64 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg"
|
className="absolute right-0 top-full mt-2 w-64 overflow-hidden rounded-xl border border-line bg-surface shadow-pop"
|
||||||
>
|
>
|
||||||
<div className="border-b border-slate-100 px-4 py-3">
|
<div className="border-b border-line px-4 py-3">
|
||||||
<div className="font-semibold text-ink">{user.name}</div>
|
<div className="font-semibold text-fg">{user.name}</div>
|
||||||
<div className="truncate text-sm text-slate-500">{user.email}</div>
|
<div className="truncate text-sm text-fg-3">{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<a role="menuitem" href={links.account} className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-brand-50">
|
<a role="menuitem" href={links.account} className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accent-soft">
|
||||||
<Icon name="user" className="h-4 w-4 text-brand-600" /> Konto & Passwort
|
<Icon name="user" className="h-4 w-4 text-accent" /> Konto & Passwort
|
||||||
</a>
|
</a>
|
||||||
<a role="menuitem" href={links.logout} className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-brand-50">
|
<a role="menuitem" href={links.logout} className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accent-soft">
|
||||||
<Icon name="logout" className="h-4 w-4 text-brand-600" /> Abmelden
|
<Icon name="logout" className="h-4 w-4 text-accent" /> Abmelden
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -119,14 +130,14 @@ function AppSections({ apps, error }: { apps: PortalApp[] | null; error: string
|
|||||||
if (apps === null) {
|
if (apps === null) {
|
||||||
return (
|
return (
|
||||||
<SectionTitle title="Anwendungen">
|
<SectionTitle title="Anwendungen">
|
||||||
<div className="text-sm text-slate-500">Lade Anwendungen …</div>
|
<div className="text-sm text-fg-3">Lade Anwendungen …</div>
|
||||||
</SectionTitle>
|
</SectionTitle>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!apps.length) {
|
if (!apps.length) {
|
||||||
return (
|
return (
|
||||||
<SectionTitle title="Anwendungen">
|
<SectionTitle title="Anwendungen">
|
||||||
<p className="text-sm text-slate-600">
|
<p className="text-sm text-fg-2">
|
||||||
{error || 'Für dein Konto sind noch keine Anwendungen freigeschaltet. Bitte wende dich an einen Admin.'}
|
{error || 'Für dein Konto sind noch keine Anwendungen freigeschaltet. Bitte wende dich an einen Admin.'}
|
||||||
</p>
|
</p>
|
||||||
</SectionTitle>
|
</SectionTitle>
|
||||||
@@ -152,8 +163,8 @@ function AppSections({ apps, error }: { apps: PortalApp[] | null; error: string
|
|||||||
function SectionTitle({ title, children }: { title: string; children: React.ReactNode }) {
|
function SectionTitle({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h2 className="mb-5 flex items-center gap-2 text-base font-semibold text-ink">
|
<h2 className="mb-5 flex items-center gap-2 text-base font-semibold text-fg">
|
||||||
<Icon name="grid" className="h-4 w-4 text-slate-500" />
|
<Icon name="grid" className="h-4 w-4 text-fg-3" />
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{children}
|
{children}
|
||||||
@@ -170,10 +181,10 @@ function AppTile({ app }: { app: PortalApp }) {
|
|||||||
title={app.description || app.name}
|
title={app.description || app.name}
|
||||||
className="group flex flex-col items-center gap-2 text-center"
|
className="group flex flex-col items-center gap-2 text-center"
|
||||||
>
|
>
|
||||||
<span className="grid h-[72px] w-[72px] place-items-center rounded-xl border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)] transition group-hover:-translate-y-0.5 group-hover:border-brand-200 group-hover:shadow-md">
|
<span className="grid h-[72px] w-[72px] place-items-center rounded-xl border border-line bg-[var(--tile-icon-bg)] shadow-card transition group-hover:-translate-y-0.5 group-hover:border-line-2 group-hover:shadow-pop">
|
||||||
<img src={app.iconUrl} alt="" className="h-10 w-10 object-contain" loading="lazy" />
|
<img src={app.iconUrl} alt="" className="h-10 w-10 object-contain" loading="lazy" />
|
||||||
</span>
|
</span>
|
||||||
<span lang="de" className="w-full hyphens-auto break-words text-[13px] font-medium leading-tight text-ink">{app.name}</span>
|
<span lang="de" className="w-full hyphens-auto break-words text-[13px] font-medium leading-tight text-fg">{app.name}</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -212,7 +223,7 @@ function NewsPanel({ canEdit }: { canEdit: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<aside className="lg:pt-[4.5rem]">
|
<aside className="lg:pt-[4.5rem]">
|
||||||
<div className="mb-4 flex items-center justify-between gap-4">
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
<h2 className="flex items-center gap-2 text-base font-semibold text-ink">
|
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||||
<Icon name="megaphone" className="h-4 w-4" />
|
<Icon name="megaphone" className="h-4 w-4" />
|
||||||
Aktuelles
|
Aktuelles
|
||||||
</h2>
|
</h2>
|
||||||
@@ -220,7 +231,7 @@ function NewsPanel({ canEdit }: { canEdit: boolean }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing('new')}
|
onClick={() => setEditing('new')}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border border-brand-500 bg-white px-3 py-1 text-xs font-semibold text-brand-600 hover:bg-brand-50"
|
className="inline-flex items-center gap-1.5 rounded-md border border-accent bg-surface px-3 py-1 text-xs font-semibold text-accent hover:bg-accent-soft"
|
||||||
>
|
>
|
||||||
<Icon name="plus" className="h-3.5 w-3.5" /> Neuigkeit
|
<Icon name="plus" className="h-3.5 w-3.5" /> Neuigkeit
|
||||||
</button>
|
</button>
|
||||||
@@ -231,15 +242,15 @@ function NewsPanel({ canEdit }: { canEdit: boolean }) {
|
|||||||
<NewsForm initial={editing === 'new' ? undefined : editing} onCancel={() => setEditing(null)} onSave={save} />
|
<NewsForm initial={editing === 'new' ? undefined : editing} onCancel={() => setEditing(null)} onSave={save} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
{news && !news.length && !editing && <p className="text-sm text-slate-600">Es gibt noch keine Neuigkeiten.</p>}
|
{news && !news.length && !editing && <p className="text-sm text-fg-2">Es gibt noch keine Neuigkeiten.</p>}
|
||||||
|
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{news?.map((item) => (
|
{news?.map((item) => (
|
||||||
<li key={item.id} className="rounded-xl border border-slate-200 bg-white p-4">
|
<li key={item.id} className="rounded-xl border border-line bg-surface p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h3 className="font-semibold text-ink">
|
<h3 className="font-semibold text-fg">
|
||||||
{item.pinned && <Icon name="pin" className="-mt-0.5 mr-1.5 inline h-3.5 w-3.5 text-brand-600" />}
|
{item.pinned && <Icon name="pin" className="-mt-0.5 mr-1.5 inline h-3.5 w-3.5 text-accent" />}
|
||||||
{item.title}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
@@ -249,10 +260,10 @@ function NewsPanel({ canEdit }: { canEdit: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-xs text-slate-500">
|
<div className="mt-0.5 text-xs text-fg-3">
|
||||||
{dateFmt.format(new Date(item.createdAt))} · {item.author}
|
{dateFmt.format(new Date(item.createdAt))} · {item.author}
|
||||||
</div>
|
</div>
|
||||||
{item.body && <p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-700">{item.body}</p>}
|
{item.body && <p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-fg-2">{item.body}</p>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -267,7 +278,7 @@ function IconButton({ label, icon, onClick }: { label: string; icon: 'pencil' |
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="grid h-7 w-7 place-items-center rounded-md text-slate-500 hover:bg-slate-100 hover:text-ink"
|
className="grid h-7 w-7 place-items-center rounded-md text-fg-3 hover:bg-surface-3 hover:text-fg"
|
||||||
>
|
>
|
||||||
<Icon name={icon} className="h-3.5 w-3.5" />
|
<Icon name={icon} className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -301,24 +312,24 @@ function NewsForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = 'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none';
|
const field = 'w-full rounded-md border border-line-2 bg-surface px-3 py-2 text-sm focus:border-accent focus:outline-none';
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit} className="mb-4 space-y-3 rounded-xl border border-brand-200 bg-white p-4">
|
<form onSubmit={submit} className="mb-4 space-y-3 rounded-xl border border-line-2 bg-surface p-4">
|
||||||
<input className={field} placeholder="Titel" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={200} required autoFocus />
|
<input className={field} placeholder="Titel" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={200} required autoFocus />
|
||||||
<textarea className={`${field} min-h-28`} placeholder="Text (optional)" value={body} onChange={(e) => setBody(e.target.value)} />
|
<textarea className={`${field} min-h-28`} placeholder="Text (optional)" value={body} onChange={(e) => setBody(e.target.value)} />
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} className="accent-brand-600" />
|
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} className="accent-[var(--accent)]" />
|
||||||
Oben anheften
|
Oben anheften
|
||||||
</label>
|
</label>
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={onCancel} className="rounded-md px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100">
|
<button type="button" onClick={onCancel} className="rounded-md px-3 py-1.5 text-sm text-fg-2 hover:bg-surface-3">
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
|
className="rounded-md bg-accent px-4 py-1.5 text-sm font-semibold text-on-accent hover:bg-accent-2 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{initial ? 'Speichern' : 'Veröffentlichen'}
|
{initial ? 'Speichern' : 'Veröffentlichen'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const paths = {
|
|||||||
trash: 'M9 3h6l1 2h4v2H4V5h4zm-3 6h12l-1 12H7z',
|
trash: 'M9 3h6l1 2h4v2H4V5h4zm-3 6h12l-1 12H7z',
|
||||||
user: 'M12 12a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zm-8 9c0-4.4 3.6-7 8-7s8 2.6 8 7z',
|
user: 'M12 12a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zm-8 9c0-4.4 3.6-7 8-7s8 2.6 8 7z',
|
||||||
logout: 'M10 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h5v-2H5V5h5zm6.6 4.4L15.2 8.8 17.4 11H9v2h8.4l-2.2 2.2 1.4 1.4L21.2 12z',
|
logout: 'M10 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h5v-2H5V5h5zm6.6 4.4L15.2 8.8 17.4 11H9v2h8.4l-2.2 2.2 1.4 1.4L21.2 12z',
|
||||||
|
moon: 'M20.4 14.6A8.5 8.5 0 0 1 9.4 3.6a8.5 8.5 0 1 0 11 11z',
|
||||||
|
sun: 'M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm-1-5h2v3h-2zm0 17h2v3h-2zM2 11h3v2H2zm17 0h3v2h-3zM4.2 5.6l1.4-1.4 2.1 2.1-1.4 1.4zm12.1 12.1 1.4-1.4 2.1 2.1-1.4 1.4zM4.2 18.4l2.1-2.1 1.4 1.4-2.1 2.1zM16.3 6.3l2.1-2.1 1.4 1.4-2.1 2.1z',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function Icon({ name, className }: { name: keyof typeof paths; className?: string }) {
|
export function Icon({ name, className }: { name: keyof typeof paths; className?: string }) {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import '@fontsource/ibm-plex-sans/400.css';
|
||||||
|
import '@fontsource/ibm-plex-sans/500.css';
|
||||||
|
import '@fontsource/ibm-plex-sans/600.css';
|
||||||
|
import '@fontsource/ibm-plex-sans/700.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
|
||||||
|
|||||||
+86
-14
@@ -1,25 +1,97 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
/*
|
||||||
--color-brand-50: #f3f0fd;
|
* Farbschema wie im VVL-Tool (lavendel-getönte Purple-Palette, Light + Dark).
|
||||||
--color-brand-100: #e6defb;
|
* Umgeschaltet über :root[data-theme="…"], gesetzt von theme-init.js bzw. useTheme.
|
||||||
--color-brand-200: #cbbdf7;
|
*/
|
||||||
--color-brand-500: #6a3de8;
|
:root,
|
||||||
--color-brand-600: #5a2fd6;
|
:root[data-theme="light"] {
|
||||||
--color-brand-700: #4a22b8;
|
--bg: #f1eef8;
|
||||||
--color-brand-900: #2b0f71;
|
--surface: #ffffff;
|
||||||
--color-ink: #1f2a52;
|
--surface-2: #faf8fd;
|
||||||
--color-canvas: #f4f4f5;
|
--surface-3: #f1edf8;
|
||||||
--font-sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
|
--border: #e5e0f0;
|
||||||
|
--border-2: #d6cee8;
|
||||||
|
--text: #211a38;
|
||||||
|
--text-2: #5b5372;
|
||||||
|
--text-3: #8a83a0;
|
||||||
|
--primary: #2d1b69;
|
||||||
|
--accent: #5e35b1;
|
||||||
|
--accent-2: #7c4dd6;
|
||||||
|
--accent-soft: #ede7fa;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--red: #d42a4e;
|
||||||
|
--tile-icon-bg: #ffffff;
|
||||||
|
--shadow: 0 1px 2px rgba(45, 27, 105, 0.06), 0 4px 16px rgba(45, 27, 105, 0.06);
|
||||||
|
--shadow-lg: 0 8px 40px rgba(45, 27, 105, 0.18);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #120c22;
|
||||||
|
--surface: #1c1436;
|
||||||
|
--surface-2: #221940;
|
||||||
|
--surface-3: #2a2049;
|
||||||
|
--border: #312551;
|
||||||
|
--border-2: #3f3167;
|
||||||
|
--text: #ece8f6;
|
||||||
|
--text-2: #ada5c6;
|
||||||
|
--text-3: #7b7396;
|
||||||
|
--primary: #c3adee;
|
||||||
|
--accent: #a88beb;
|
||||||
|
--accent-2: #bca4f2;
|
||||||
|
--accent-soft: #2a2048;
|
||||||
|
--on-accent: #1b1233;
|
||||||
|
--red: #f26982;
|
||||||
|
--tile-icon-bg: #f4f0fc;
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-lg: 0 12px 48px rgba(0, 0, 0, 0.55);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-bg: var(--bg);
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
--color-surface-2: var(--surface-2);
|
||||||
|
--color-surface-3: var(--surface-3);
|
||||||
|
--color-line: var(--border);
|
||||||
|
--color-line-2: var(--border-2);
|
||||||
|
--color-fg: var(--text);
|
||||||
|
--color-fg-2: var(--text-2);
|
||||||
|
--color-fg-3: var(--text-3);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-2: var(--accent-2);
|
||||||
|
--color-accent-soft: var(--accent-soft);
|
||||||
|
--color-on-accent: var(--on-accent);
|
||||||
|
--color-danger: var(--red);
|
||||||
|
--shadow-card: var(--shadow);
|
||||||
|
--shadow-pop: var(--shadow-lg);
|
||||||
|
--font-sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
background: var(--color-canvas);
|
background: var(--bg);
|
||||||
color: var(--color-ink);
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markierter Text in Flieder statt Browser-Blau (wie im VVL-Tool) */
|
||||||
|
::selection {
|
||||||
|
background: #d7c6f7;
|
||||||
|
color: #1b1233;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] ::selection {
|
||||||
|
background: #5b3fa0;
|
||||||
|
color: #f4efff;
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-brand-500);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Das dunkle Insel-Logo braucht auf dunklem Grund einen hellen Rand */
|
||||||
|
:root[data-theme="dark"] .logo-badge {
|
||||||
|
box-shadow: 0 0 0 2px #f4f0fc;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
function current(): Theme {
|
||||||
|
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hell/Dunkel-Umschaltung; merkt sich die Wahl (localStorage "hi-theme"). */
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(current);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('hi-theme', theme);
|
||||||
|
} catch {
|
||||||
|
/* Speicher gesperrt */
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => setTheme((t) => (t === 'dark' ? 'light' : 'dark')), []);
|
||||||
|
return { theme, toggle };
|
||||||
|
}
|
||||||
Generated
+9
@@ -8,6 +8,7 @@
|
|||||||
"name": "handy-insel-portal",
|
"name": "handy-insel-portal",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"express": "^4.22.3",
|
"express": "^4.22.3",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
@@ -451,6 +452,14 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/ibm-plex-sans": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"express": "^4.22.3",
|
"express": "^4.22.3",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Setzt das Farbschema vor dem ersten Rendern (verhindert Aufblitzen im Dark Mode).
|
||||||
|
(function () {
|
||||||
|
var t;
|
||||||
|
try { t = localStorage.getItem('hi-theme'); } catch (e) {}
|
||||||
|
if (t !== 'light' && t !== 'dark') t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
})();
|
||||||
Binary file not shown.
+38
-16
@@ -37,10 +37,14 @@ const PORTAL = (script?.dataset.portal || (script?.src ? new URL(script.src).ori
|
|||||||
|
|
||||||
const css = `
|
const css = `
|
||||||
:host { all: initial; display: block; position: relative; z-index: 2147483000; height: ${HEIGHT}px;
|
:host { all: initial; display: block; position: relative; z-index: 2147483000; height: ${HEIGHT}px;
|
||||||
font-family: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; font-size: 14px; color: #fff; }
|
font-family: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; font-size: 14px; color: #fff;
|
||||||
|
--p-bg: #ffffff; --p-text: #211a38; --p-text-3: #8a83a0; --p-border: #e5e0f0; --p-hover: #ede7fa; --p-current: #e3d9f8;
|
||||||
|
--p-accent: #5e35b1; --p-tile: transparent; --p-shadow: 0 8px 40px rgba(45,27,105,.18); }
|
||||||
|
:host([theme="dark"]) { --p-bg: #1c1436; --p-text: #ece8f6; --p-text-3: #7b7396; --p-border: #312551; --p-hover: #2a2048;
|
||||||
|
--p-current: #2f2452; --p-accent: #a88beb; --p-tile: #f4f0fc; --p-shadow: 0 12px 48px rgba(0,0,0,.55); }
|
||||||
:host([sticky]) { position: sticky; top: 0; }
|
:host([sticky]) { position: sticky; top: 0; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
.bar { display: flex; align-items: center; gap: 6px; height: ${HEIGHT}px; padding: 0 8px 0 4px; background: #2b0f71;
|
.bar { display: flex; align-items: center; gap: 6px; height: ${HEIGHT}px; padding: 0 8px 0 4px; background: #2d1b69;
|
||||||
box-shadow: 0 1px 0 rgba(0,0,0,.12); }
|
box-shadow: 0 1px 0 rgba(0,0,0,.12); }
|
||||||
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
|
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
|
||||||
a { color: inherit; text-decoration: none; }
|
a { color: inherit; text-decoration: none; }
|
||||||
@@ -54,29 +58,29 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.app-name { opacity: .85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
.app-name { opacity: .85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||||
.sep { width: 1px; height: 18px; background: rgba(255,255,255,.3); margin: 0 4px; }
|
.sep { width: 1px; height: 18px; background: rgba(255,255,255,.3); margin: 0 4px; }
|
||||||
.spacer { flex: 1; }
|
.spacer { flex: 1; }
|
||||||
.avatar { width: 30px; height: 30px; border-radius: 50%; background: #fff; color: #2b0f71; font-size: 12px; font-weight: 700;
|
.avatar { width: 30px; height: 30px; border-radius: 50%; background: #fff; color: #2d1b69; font-size: 12px; font-weight: 700;
|
||||||
display: grid; place-items: center; }
|
display: grid; place-items: center; }
|
||||||
.panel { position: absolute; top: ${HEIGHT + 6}px; background: #fff; color: #1f2a52; border-radius: 12px;
|
.panel { position: absolute; top: ${HEIGHT + 6}px; background: var(--p-bg); color: var(--p-text); border-radius: 12px;
|
||||||
border: 1px solid #e2e8f0; box-shadow: 0 12px 32px rgba(15,23,42,.18); overflow: hidden; }
|
border: 1px solid var(--p-border); box-shadow: var(--p-shadow); overflow: hidden; }
|
||||||
.panel[hidden] { display: none; }
|
.panel[hidden] { display: none; }
|
||||||
.apps { left: 6px; width: 344px; max-width: calc(100vw - 12px); max-height: calc(100vh - 60px); overflow-y: auto; padding: 12px; }
|
.apps { left: 6px; width: 344px; max-width: calc(100vw - 12px); max-height: calc(100vh - 60px); overflow-y: auto; padding: 12px; }
|
||||||
.apps h3 { margin: 4px 6px 8px; font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: .04em; }
|
.apps h3 { margin: 4px 6px 8px; font-size: 12px; font-weight: 600; color: var(--p-text-3); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; margin-bottom: 8px; }
|
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; margin-bottom: 8px; }
|
||||||
.tile { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px; border-radius: 10px;
|
.tile { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px; border-radius: 10px;
|
||||||
text-align: center; font-size: 12.5px; font-weight: 500; line-height: 1.2; overflow-wrap: anywhere; hyphens: auto; }
|
text-align: center; font-size: 12.5px; font-weight: 500; line-height: 1.2; overflow-wrap: anywhere; hyphens: auto; }
|
||||||
.tile:hover { background: #f3f0fd; }
|
.tile:hover { background: var(--p-hover); }
|
||||||
.tile[aria-current="page"] { background: #e6defb; }
|
.tile[aria-current="page"] { background: var(--p-current); }
|
||||||
.tile img { width: 36px; height: 36px; }
|
.tile img { width: 36px; height: 36px; padding: 2px; border-radius: 8px; background: var(--p-tile); }
|
||||||
.footer { display: block; margin: 4px -12px -12px; padding: 12px 18px; border-top: 1px solid #f1f5f9; font-weight: 600; color: #5a2fd6; }
|
.footer { display: block; margin: 4px -12px -12px; padding: 12px 18px; border-top: 1px solid var(--p-border); font-weight: 600; color: var(--p-accent); }
|
||||||
.footer:hover { background: #f3f0fd; }
|
.footer:hover { background: var(--p-hover); }
|
||||||
.hint { margin: 6px; font-size: 13px; line-height: 1.45; color: #475569; }
|
.hint { margin: 6px; font-size: 13px; line-height: 1.45; color: var(--p-text); }
|
||||||
.hint a { color: #5a2fd6; font-weight: 600; }
|
.hint a { color: var(--p-accent); font-weight: 600; }
|
||||||
.user { right: 6px; width: 260px; }
|
.user { right: 6px; width: 260px; }
|
||||||
.user .who { padding: 12px 16px; border-bottom: 1px solid #f1f5f9; }
|
.user .who { padding: 12px 16px; border-bottom: 1px solid var(--p-border); }
|
||||||
.user .who div:first-child { font-weight: 600; }
|
.user .who div:first-child { font-weight: 600; }
|
||||||
.user .who div:last-child { font-size: 13px; color: #64748b; overflow: hidden; text-overflow: ellipsis; }
|
.user .who div:last-child { font-size: 13px; color: var(--p-text-3); overflow: hidden; text-overflow: ellipsis; }
|
||||||
.user a { display: block; padding: 10px 16px; font-size: 14px; }
|
.user a { display: block; padding: 10px 16px; font-size: 14px; }
|
||||||
.user a:hover { background: #f3f0fd; }
|
.user a:hover { background: var(--p-hover); }
|
||||||
@media (max-width: 480px) { .brand span { display: none; } }
|
@media (max-width: 480px) { .brand span { display: none; } }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -112,8 +116,24 @@ class HiTopbar extends HTMLElement {
|
|||||||
private nav: Navigation | null = null;
|
private nav: Navigation | null = null;
|
||||||
private open: 'apps' | 'user' | null = null;
|
private open: 'apps' | 'user' | null = null;
|
||||||
|
|
||||||
|
private themeObserver?: MutationObserver;
|
||||||
|
private darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
/** Folgt dem Farbschema des Tools: data-theme="dark" (VVL, Portal), Klasse "dark" (Tailwind) oder System. */
|
||||||
|
private syncTheme = () => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const attr = html.getAttribute('data-theme');
|
||||||
|
const dark = attr ? attr === 'dark' : html.classList.contains('dark') || this.darkQuery.matches;
|
||||||
|
if (dark) this.setAttribute('theme', 'dark');
|
||||||
|
else this.removeAttribute('theme');
|
||||||
|
};
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
document.documentElement.style.setProperty('--hi-topbar-height', `${HEIGHT}px`);
|
document.documentElement.style.setProperty('--hi-topbar-height', `${HEIGHT}px`);
|
||||||
|
this.syncTheme();
|
||||||
|
this.themeObserver = new MutationObserver(this.syncTheme);
|
||||||
|
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
|
||||||
|
this.darkQuery.addEventListener('change', this.syncTheme);
|
||||||
this.nav = readCache();
|
this.nav = readCache();
|
||||||
this.render();
|
this.render();
|
||||||
void this.load();
|
void this.load();
|
||||||
@@ -122,6 +142,8 @@ class HiTopbar extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
|
this.themeObserver?.disconnect();
|
||||||
|
this.darkQuery.removeEventListener('change', this.syncTheme);
|
||||||
document.removeEventListener('mousedown', this.onOutside);
|
document.removeEventListener('mousedown', this.onOutside);
|
||||||
document.removeEventListener('keydown', this.onKey);
|
document.removeEventListener('keydown', this.onKey);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user