forked from BRETT/BRETT
Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
DATABASE_URL=mysql://nuxt:nuxt_password@localhost:3306/nuxt_app
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.env
|
||||
data/
|
||||
public/uploads/
|
||||
@@ -0,0 +1,17 @@
|
||||
# Nuxt shadcn Demo
|
||||
|
||||
## Workflow
|
||||
|
||||
- Keine Headless-Browser-Verifizierung nach UI-Änderungen — der User testet selbst. Nur bei wiederholten Fehlern nachfragen/debuggen. Syntax-/Build-Checks sind weiterhin okay.
|
||||
- Dev-Server: `npm run dev` (Port 3000). Der User lässt ihn meist selbst laufen — nicht blind starten, erst prüfen ob bereits aktiv.
|
||||
- DB: MariaDB via `docker-compose.yml` (Container `nuxt-shadcn-demo-db`), URL in `DATABASE_URL`. Migrationen: `npm run db:generate` (Schema → SQL), `npm run db:migrate` (anwenden; im Dev-Modus auch automatisch per Nitro-Plugin).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Vue 3 `<script setup lang="ts">`, Composition API, shadcn-vue-Komponenten unter `app/components/ui/`.
|
||||
- Theme-Farben über semantische Tokens (`bg-primary`, `border-primary`, …), nie hardcoden. Themes: `standard`, `neo`, `neo-pink`, `neo-mint` — `--primary` ist die Signaturfarbe, `--accent` der Kontrast; in `neo-*` ist `--radius: 0` (eckig), daher `rounded-md`/`rounded-sm` statt `rounded-2xl`/`rounded-xl` verwenden.
|
||||
- Tailwind: `size-*` statt `w-* h-*`, `gap-*` statt `space-x/y-*`, `cn()` für Konditionales.
|
||||
- Live-Updates laufen über `server/routes/_ws.ts` + `broadcastToAll`; Client-Verarbeitung in `app/plugins/notifications.client.ts`.
|
||||
- Kommentar-Reactions: `useCommentReactions` (State-Map pro Comment-Id), Emoji-Liste `REACTION_EMOJIS`.
|
||||
- Thread-Benachrichtigungen: `threadSubscriptions` speichert Abos und explizite Deaktivierungen. Erstellen, Kommentieren, Favorisieren und Post-Reactions rufen `ensureThreadSubscription()` auf; ein deaktiviertes Abo darf dadurch nicht reaktiviert werden. `notifyComment()` benachrichtigt aktive Abonnenten und erwähnte Nutzer, respektiert Deaktivierungen auch bei Erwähnungen und fasst Meldungen pro Nutzer/Thread zusammen.
|
||||
- Berechtigungen: Katalog in `shared/utils/permissions.ts` (`PERMISSIONS`), Server-Check per `requirePermission(event, key)` in `server/utils/auth.ts` — `admin`-Rolle ist implizit wildcard. Client-seitig `can(key)` aus `useAuth`. Seeding via `seedPermissions()` im Migrations-Plugin/Script.
|
||||
@@ -0,0 +1,101 @@
|
||||
# BRETT
|
||||
|
||||
Ein Imageboard-artiges Upload- und Diskussions-Tool, gebaut mit **Nuxt 4** und **shadcn-vue**. Nutzer können Dateien oder URLs hochladen, in Threads kommentieren, mit Discord-artigen Reactions antworten und aus über einem Dutzend Themes wählen.
|
||||
|
||||
## Features
|
||||
|
||||
- **Uploads:** Drag & Drop (Seiten-Dropzone + Upload-Popover), Datei- oder URL-Upload, Thumbnails für Bilder, eingebetteter Plyr-Videoplayer, YouTube-Embeds und Link-Preview-Cards
|
||||
- **Threads & Kommentare:** Jeder Upload ist ein Thread mit Kommentarfunktion, Discord-Style-Reactions (Toggle, Zähler, Tooltips) und Live-Updates per WebSocket
|
||||
- **Auth & Rollen:** Session-Cookie-Login (30 Tage), der erste registrierte Nutzer wird automatisch Admin; Admin-Panel mit Rollenverwaltung und **pro Rolle konfigurierbaren Berechtigungen** (`admin.access`, `roles.manage`, `users.manage`, `comment.delete`, `upload.delete`) — die `admin`-Rolle darf immer alles
|
||||
- **Favoriten & Notifications:** Uploads favorisieren, Benachrichtigungs-Glocke mit Live-Events
|
||||
- **Themes:** 15+ Themes (Standard, Discord, Cyberpunk, Spring, Terminal, Infoseer, Homebrew, Florense, Kawi Green, Clo.nz, Clawmorphism, Realmorphism, Amber, Neo, Neo Pink, Neo Mint), umgeschaltet über `data-theme` auf `<html>`, persistiert per Cookie
|
||||
- **Layouts:** Umschaltbares Bento-/Uniform-Grid für die Upload-Übersicht
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
| Bereich | Technologie |
|
||||
|---|---|
|
||||
| Framework | Nuxt 4 / Nitro, Vue 3 (`<script setup>` + TS) |
|
||||
| UI | shadcn-vue (`shadcn-nuxt`), Reka UI, Tailwind CSS v4, Lucide-Icons, vue-sonner |
|
||||
| Datenbank | MariaDB 11 + Drizzle ORM |
|
||||
| Realtime | Nitro WebSocket (`crossws`) |
|
||||
| Medien | `@nuxt/image`, Plyr |
|
||||
| State | Nuxt `useState`-Composables, VueUse |
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
app/
|
||||
assets/css/tailwind.css # Tailwind v4 Entry + alle Theme-Token ([data-theme="…"])
|
||||
components/ # App-Komponenten (Header, Dropzone, Tiles, Comments, …)
|
||||
ui/ # shadcn-vue Basis-Komponenten
|
||||
composables/ # useAuth, useTheme, useLiveComments, useCommentReactions, …
|
||||
layouts/ middleware/ plugins/ pages/
|
||||
index.vue # Upload-Grid / Startseite
|
||||
thread/[id].vue # Thread-Detailseite mit Kommentaren
|
||||
admin.vue user/[id].vue settings.vue login.vue register.vue
|
||||
server/
|
||||
api/ # Nitro-Endpoints (auth, uploads, comments, notifications, admin, …)
|
||||
db/schema.ts # Drizzle-Schema (users, sessions, uploads, comments,
|
||||
# comment_reactions, favorites, link_previews,
|
||||
# notifications, roles, user_roles)
|
||||
db/migrations/ # Generierte SQL-Migrationen
|
||||
plugins/migrate.ts # Führt Migrationen beim Server-Start aus (Dev)
|
||||
routes/_ws.ts # WebSocket-Route für Live-Events
|
||||
utils/ # auth, db, roles, uploads, notifications, linkPreview
|
||||
scripts/migrate.ts # Manueller Migrations-Runner (npm run db:migrate)
|
||||
shared/utils/ # Gemeinsame Utilities (Client & Server)
|
||||
public/uploads/ # Upload-Ziel (gitignored)
|
||||
docker-compose.yml # MariaDB-Container
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
**Voraussetzungen:** Node.js ≥ 20, Docker (für die mitgelieferte MariaDB) oder eine eigene MySQL/MariaDB-Instanz.
|
||||
|
||||
```bash
|
||||
# 1. Datenbank starten
|
||||
docker compose up -d
|
||||
|
||||
# 2. Umgebung konfigurieren
|
||||
cp .env.example .env # enthält DATABASE_URL, passt zum Docker-Setup
|
||||
|
||||
# 3. Abhängigkeiten installieren
|
||||
npm install
|
||||
|
||||
# 4. ffmpeg-Binary nachinstallieren (wird für Sprachnachrichten → MP3 gebraucht;
|
||||
# der Download läuft über das postinstall-Skript von ffmpeg-static, das je
|
||||
# nach npm-Konfiguration ggf. geblockt ist)
|
||||
node node_modules/ffmpeg-static/install.js
|
||||
|
||||
# 5. Dev-Server starten (Migrationen laufen automatisch)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Die App läuft auf <http://localhost:3000>. Der **erste registrierte Account erhält automatisch die Admin-Rolle** und kann danach im Admin-Panel weitere Rollen (`moderator`, …) anlegen und vergeben.
|
||||
|
||||
### Ohne Docker
|
||||
|
||||
Eigene MariaDB/MySQL-Instanz bereitstellen und `DATABASE_URL` in `.env` anpassen:
|
||||
|
||||
```
|
||||
DATABASE_URL=mysql://user:pass@host:3306/datenbank
|
||||
```
|
||||
|
||||
## NPM-Scripts
|
||||
|
||||
| Befehl | Zweck |
|
||||
|---|---|
|
||||
| `npm run dev` | Dev-Server mit HMR, Auto-Migrationen |
|
||||
| `npm run build` | Production-Build |
|
||||
| `npm run preview` | Build lokal ausführen |
|
||||
| `npm run db:generate` | Neue Drizzle-Migration aus Schema-Änderung erzeugen |
|
||||
| `npm run db:migrate` | Migrationen manuell anwenden |
|
||||
| `npm run db:studio` | Drizzle Studio (DB-GUI) |
|
||||
|
||||
## Entwicklung
|
||||
|
||||
- **Schema ändern:** `server/db/schema.ts` anpassen → `npm run db:generate` → Migration committen. Im Dev-Betrieb wendet `server/plugins/migrate.ts` sie beim Start automatisch an.
|
||||
- **Neues Theme hinzufügen:** `[data-theme="…"]`-Block mit den CSS-Variablen in `app/assets/css/tailwind.css` anlegen, den Namen in der `ThemeName`-Union in `app/composables/useTheme.ts` ergänzen und einen Eintrag in `app/components/ThemeSelector.vue` hinzufügen. Themes können auch die `shadow-*`-Tokens überschreiben (Glow-, Offset- oder Flach-Schatten) sowie `font-sans`/`font-heading`/`font-mono`; `--font-logo` bleibt bewusst theme-unabhängig.
|
||||
- **Konventionen:** Semantische Tokens statt hartcodierter Farben (`primary`, `accent`, `muted` …), `rounded-md`/`rounded-sm` skalieren über `--radius` mit dem Theme. Details stehen in `AGENTS.md`.
|
||||
- **Health-Check:** `GET /api/health` meldet den DB-Verbindungsstatus.
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import 'vue-sonner/style.css'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
const theme = useTheme()
|
||||
useHead({
|
||||
htmlAttrs: { 'data-theme': theme },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLoadingIndicator :height="2" :throttle="0" color="var(--primary)" />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<Toaster />
|
||||
</template>
|
||||
@@ -0,0 +1,814 @@
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn-vue/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@utility cn-font-heading {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-sans);
|
||||
--font-weight-normal: 500;
|
||||
--font-weight-medium: 600;
|
||||
--font-weight-semibold: 700;
|
||||
--font-weight-bold: 700;
|
||||
--font-heading: var(--font-heading);
|
||||
--font-logo: var(--font-logo);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-xs: calc(var(--radius) * 0.2);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) * 1.6);
|
||||
--radius-3xl: calc(var(--radius) * 2.4);
|
||||
}
|
||||
|
||||
:root, [data-theme="standard"] {
|
||||
--font-sans: 'Figtree', sans-serif;
|
||||
--font-heading: 'Manrope', sans-serif;
|
||||
--font-logo: 'Archivo Black', sans-serif;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.459 0.187 3.815);
|
||||
--primary-foreground: oklch(0.971 0.014 343.198);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.459 0.187 3.815);
|
||||
--accent-foreground: oklch(0.971 0.014 343.198);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.823 0.12 346.018);
|
||||
--chart-2: oklch(0.656 0.241 354.308);
|
||||
--chart-3: oklch(0.592 0.249 0.584);
|
||||
--chart-4: oklch(0.525 0.223 3.958);
|
||||
--chart-5: oklch(0.459 0.187 3.815);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.656 0.241 354.308);
|
||||
--sidebar-primary-foreground: oklch(0.971 0.014 343.198);
|
||||
--sidebar-accent: oklch(0.459 0.187 3.815);
|
||||
--sidebar-accent-foreground: oklch(0.971 0.014 343.198);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
/* Discord-Theme (tweakcn, Dark-Variante) */
|
||||
[data-theme="discord"] {
|
||||
--font-sans: 'Noto Sans JP', ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: 'Noto Sans JP', ui-sans-serif, sans-serif, system-ui;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.3225 0.0107 278.2517);
|
||||
--foreground: oklch(1 0 0);
|
||||
--card: oklch(0.3502 0.0122 279.2835);
|
||||
--card-foreground: oklch(1 0 0);
|
||||
--popover: oklch(0.3502 0.0122 279.2835);
|
||||
--popover-foreground: oklch(1 0 0);
|
||||
--primary: oklch(0.5774 0.2091 273.8504);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.3779 0.0118 285.8207);
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.3066 0.0108 278.2166);
|
||||
--muted-foreground: oklch(0.723 0.0087 278.582);
|
||||
--accent: oklch(0.4074 0.0134 280.1154);
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(0.8004 0.1141 23.185);
|
||||
--border: oklch(0.369 0.0103 278.3345);
|
||||
--input: oklch(0.3066 0.0108 278.2166);
|
||||
--ring: oklch(0.5774 0.2091 273.8504);
|
||||
--chart-1: oklch(0.8092 0.1122 345.1956);
|
||||
--chart-2: oklch(0.8757 0.0979 83.6546);
|
||||
--chart-3: oklch(0.9075 0.0946 165.054);
|
||||
--chart-4: oklch(0.8406 0.0699 242.1914);
|
||||
--chart-5: oklch(0.7865 0.1232 309.4396);
|
||||
--sidebar: oklch(0.2981 0.0091 276.8201);
|
||||
--sidebar-foreground: oklch(0.6876 0.0102 279.6082);
|
||||
--sidebar-primary: oklch(0.3804 0.0102 278.3511);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.3309 0.0087 285.9137);
|
||||
--sidebar-accent-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(0.3494 0.0087 276.903);
|
||||
--sidebar-ring: oklch(0.7151 0.1523 235.2888);
|
||||
}
|
||||
|
||||
/* Cyberpunk-Theme (tweakcn „赛博朋克", Dark-Variante):
|
||||
Neon-Grün auf Schwarz, Magenta-Primary, eckig, Glow-Schatten */
|
||||
[data-theme="cyberpunk"] {
|
||||
--font-sans: 'Orbitron', sans-serif;
|
||||
--font-heading: 'Orbitron', sans-serif;
|
||||
--radius: 0rem;
|
||||
--background: oklch(0.1149 0 0);
|
||||
--foreground: oklch(0.8803 0.2083 157.334);
|
||||
--card: oklch(0.1591 0 0);
|
||||
--card-foreground: oklch(1 0 0);
|
||||
--popover: oklch(0.1149 0 0);
|
||||
--popover-foreground: oklch(0.8803 0.2083 157.334);
|
||||
--primary: oklch(0.6083 0.2973 312.1096);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.7392 0.1576 235.3407);
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.2178 0 0);
|
||||
--muted-foreground: oklch(0.5693 0 0);
|
||||
--accent: oklch(0.6354 0.2541 15.4582);
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(0.6317 0.2544 21.7529);
|
||||
--border: oklch(0.3211 0 0);
|
||||
--input: oklch(0.1591 0 0);
|
||||
--ring: oklch(0.6083 0.2973 312.1096);
|
||||
--chart-1: oklch(0.6083 0.2973 312.1096);
|
||||
--chart-2: oklch(0.8803 0.2083 157.334);
|
||||
--chart-3: oklch(0.7392 0.1576 235.3407);
|
||||
--chart-4: oklch(0.6354 0.2541 15.4582);
|
||||
--chart-5: oklch(0.9616 0.213 112.0777);
|
||||
--sidebar: oklch(0.0847 0 0);
|
||||
--sidebar-foreground: oklch(0.6268 0 0);
|
||||
--sidebar-primary: oklch(0.6083 0.2973 312.1096);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.2178 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.8803 0.2083 157.334);
|
||||
--sidebar-border: oklch(0.252 0 0);
|
||||
--sidebar-ring: oklch(0.7392 0.1576 235.3407);
|
||||
--shadow-2xs: 0 0 20px 4px hsl(340 100% 50% / 0.4);
|
||||
--shadow-xs: 0 0 20px 4px hsl(340 100% 50% / 0.4);
|
||||
--shadow-sm: 0 0 20px 4px hsl(340 100% 50% / 0.8), 0 1px 2px 3px hsl(340 100% 50% / 0.8);
|
||||
--shadow: 0 0 20px 4px hsl(340 100% 50% / 0.8), 0 1px 2px 3px hsl(340 100% 50% / 0.8);
|
||||
--shadow-md: 0 0 20px 4px hsl(340 100% 50% / 0.8), 0 2px 4px 3px hsl(340 100% 50% / 0.8);
|
||||
--shadow-lg: 0 0 20px 4px hsl(340 100% 50% / 0.8), 0 4px 6px 3px hsl(340 100% 50% / 0.8);
|
||||
--shadow-xl: 0 0 20px 4px hsl(340 100% 50% / 0.8), 0 8px 10px 3px hsl(340 100% 50% / 0.8);
|
||||
--shadow-2xl: 0 0 20px 4px hsl(340 100% 50%);
|
||||
}
|
||||
|
||||
[data-theme="cyberpunk"] body {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* Spring-Theme (tweakcn „CuteSpringTheme", Dark-Variante):
|
||||
Mauve/Pflaume-Hintergrund, Pink-Primary, Mint-Secondary, sehr rund */
|
||||
[data-theme="spring"] {
|
||||
--font-sans: 'Jua', sans-serif;
|
||||
--font-heading: 'Jua', sans-serif;
|
||||
--radius: 1.25rem;
|
||||
--background: oklch(0.2482 0.0307 354.5794);
|
||||
--foreground: oklch(0.9678 0.0166 3.6461);
|
||||
--card: oklch(0.3001 0.0369 357.054);
|
||||
--card-foreground: oklch(0.9678 0.0166 3.6461);
|
||||
--popover: oklch(0.3001 0.0369 357.054);
|
||||
--popover-foreground: oklch(0.9678 0.0166 3.6461);
|
||||
--primary: oklch(0.7716 0.1387 9.6611);
|
||||
--primary-foreground: oklch(0.2482 0.0307 354.5794);
|
||||
--secondary: oklch(0.766 0.1179 145.295);
|
||||
--secondary-foreground: oklch(0.2941 0.0509 145.0535);
|
||||
--muted: oklch(0.3458 0.0388 356.0232);
|
||||
--muted-foreground: oklch(0.8178 0.0293 355.5969);
|
||||
--accent: oklch(0.8391 0.1615 84.3763);
|
||||
--accent-foreground: oklch(0.2482 0.0307 354.5794);
|
||||
--destructive: oklch(0.6875 0.142 21.4566);
|
||||
--border: oklch(0.4074 0.043 356.7696);
|
||||
--input: oklch(0.3001 0.0369 357.054);
|
||||
--ring: oklch(0.7716 0.1387 9.6611);
|
||||
--chart-1: oklch(0.7716 0.1387 9.6611);
|
||||
--chart-2: oklch(0.766 0.1179 145.295);
|
||||
--chart-3: oklch(0.8391 0.1615 84.3763);
|
||||
--chart-4: oklch(0.6297 0.1321 299.3578);
|
||||
--chart-5: oklch(0.7546 0.1512 38.2575);
|
||||
--sidebar: oklch(0.2239 0.0279 355.4364);
|
||||
--sidebar-foreground: oklch(0.9678 0.0166 3.6461);
|
||||
--sidebar-primary: oklch(0.7716 0.1387 9.6611);
|
||||
--sidebar-primary-foreground: oklch(0.2482 0.0307 354.5794);
|
||||
--sidebar-accent: oklch(0.3001 0.0369 357.054);
|
||||
--sidebar-accent-foreground: oklch(0.9678 0.0166 3.6461);
|
||||
--sidebar-border: oklch(0.3458 0.0388 356.0232);
|
||||
--sidebar-ring: oklch(0.7716 0.1387 9.6611);
|
||||
}
|
||||
|
||||
[data-theme="spring"] body {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Terminal-Theme (tweakcn „iconic terminal", Dark-Variante):
|
||||
Monospace, Teal-Phosphor auf Schwarz, eckig, keine Schatten */
|
||||
[data-theme="terminal"] {
|
||||
--font-sans: 'JetBrains Mono', monospace;
|
||||
--font-heading: 'JetBrains Mono', monospace;
|
||||
--radius: 0rem;
|
||||
--background: oklch(0.1234 0.0134 174.8007);
|
||||
--foreground: oklch(0.7678 0.1655 162.189);
|
||||
--card: oklch(0.1577 0.018 172.3648);
|
||||
--card-foreground: oklch(0.7678 0.1655 162.189);
|
||||
--popover: oklch(0.1414 0.0154 174.4925);
|
||||
--popover-foreground: oklch(0.7678 0.1655 162.189);
|
||||
--primary: oklch(0.7678 0.1655 162.189);
|
||||
--primary-foreground: oklch(0.1234 0.0134 174.8007);
|
||||
--secondary: oklch(0.2393 0.0298 170.0849);
|
||||
--secondary-foreground: oklch(0.7678 0.1655 162.189);
|
||||
--muted: oklch(0.2123 0.0248 170.4461);
|
||||
--muted-foreground: oklch(0.4814 0.0721 168.7206);
|
||||
--accent: oklch(0.304 0.0414 169.4923);
|
||||
--accent-foreground: oklch(0.8309 0.1796 162.0706);
|
||||
--destructive: oklch(0.4344 0.1466 25.7809);
|
||||
--border: oklch(0.4274 0.0886 163.5258);
|
||||
--input: oklch(0.3519 0.0714 164.2596);
|
||||
--ring: oklch(0.7678 0.1655 162.189);
|
||||
--chart-1: oklch(0.7678 0.1655 162.189);
|
||||
--chart-2: oklch(0.8052 0.1423 171.7621);
|
||||
--chart-3: oklch(0.7935 0.133 163.1545);
|
||||
--chart-4: oklch(0.6201 0.0963 179.7192);
|
||||
--chart-5: oklch(0.4814 0.0721 168.7206);
|
||||
--sidebar: oklch(0.0979 0.0106 174.8007);
|
||||
--sidebar-foreground: oklch(0.7678 0.1655 162.189);
|
||||
--sidebar-primary: oklch(0.7678 0.1655 162.189);
|
||||
--sidebar-primary-foreground: oklch(0.1234 0.0134 174.8007);
|
||||
--sidebar-accent: oklch(0.2393 0.0298 170.0849);
|
||||
--sidebar-accent-foreground: oklch(0.8309 0.1796 162.0706);
|
||||
--sidebar-border: oklch(0.3519 0.0714 164.2596);
|
||||
--sidebar-ring: oklch(0.7678 0.1655 162.189);
|
||||
--shadow-2xs: 0 0 0 0 transparent;
|
||||
--shadow-xs: 0 0 0 0 transparent;
|
||||
--shadow-sm: 0 0 0 0 transparent;
|
||||
--shadow: 0 0 0 0 transparent;
|
||||
--shadow-md: 0 0 0 0 transparent;
|
||||
--shadow-lg: 0 0 0 0 transparent;
|
||||
--shadow-xl: 0 0 0 0 transparent;
|
||||
--shadow-2xl: 0 0 0 0 transparent;
|
||||
}
|
||||
|
||||
[data-theme="terminal"] body {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Infoseer-Theme (tweakcn, Dark-Variante): schlichtes Monochrome,
|
||||
weiße Primary-Buttons mit dunkler Schrift */
|
||||
[data-theme="infoseer"] {
|
||||
--font-sans: 'Fira Sans', ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: 'Fira Sans', ui-sans-serif, sans-serif, system-ui;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.2138 0.0019 286.2347);
|
||||
--foreground: oklch(0.9851 0 0);
|
||||
--card: oklch(0.2138 0.0019 286.2347);
|
||||
--card-foreground: oklch(0.9851 0 0);
|
||||
--popover: oklch(0.2686 0 0);
|
||||
--popover-foreground: oklch(0.9851 0 0);
|
||||
--primary: oklch(0.9219 0 0);
|
||||
--primary-foreground: oklch(0.2138 0.0019 286.2347);
|
||||
--secondary: oklch(0.2686 0 0);
|
||||
--secondary-foreground: oklch(0.9851 0 0);
|
||||
--muted: oklch(0.2686 0 0);
|
||||
--muted-foreground: oklch(0.709 0 0);
|
||||
--accent: oklch(0.3715 0 0);
|
||||
--accent-foreground: oklch(0.9851 0 0);
|
||||
--destructive: oklch(0.709 0.1879 11.8353);
|
||||
--border: oklch(0.2768 0 0);
|
||||
--input: oklch(0.325 0 0);
|
||||
--ring: oklch(0.5555 0 0);
|
||||
--chart-1: oklch(0.8497 0.0888 233.6561);
|
||||
--chart-2: oklch(0.6874 0.157 248.1539);
|
||||
--chart-3: oklch(0.619 0.181 254.6741);
|
||||
--chart-4: oklch(0.5528 0.1826 257.4254);
|
||||
--chart-5: oklch(0.4684 0.1508 259.5323);
|
||||
--sidebar: oklch(0.2138 0.0019 286.2347);
|
||||
--sidebar-foreground: oklch(0.9851 0 0);
|
||||
--sidebar-primary: oklch(0.5526 0.2035 259.283);
|
||||
--sidebar-primary-foreground: oklch(0.9851 0 0);
|
||||
--sidebar-accent: oklch(0.2686 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.9851 0 0);
|
||||
--sidebar-border: oklch(0.2768 0 0);
|
||||
--sidebar-ring: oklch(0.4386 0 0);
|
||||
}
|
||||
|
||||
[data-theme="infoseer"] body {
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
/* Homebrew-Theme (tweakcn, Dark-Variante): Matrix-Grün auf Schwarz,
|
||||
eckig, grüner Neon-Glow */
|
||||
[data-theme="homebrew"] {
|
||||
--font-sans: 'Inter', ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--radius: 0rem;
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(0.8686 0.2776 144.4661);
|
||||
--card: oklch(0.2295 0.0624 145.0466);
|
||||
--card-foreground: oklch(0.8686 0.2776 144.4661);
|
||||
--popover: oklch(0 0 0);
|
||||
--popover-foreground: oklch(0.8686 0.2776 144.4661);
|
||||
--primary: oklch(0.8686 0.2776 144.4661);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.3053 0.1039 142.4953);
|
||||
--secondary-foreground: oklch(0.8686 0.2776 144.4661);
|
||||
--muted: oklch(0.1887 0.0642 142.4953);
|
||||
--muted-foreground: oklch(0.7556 0.2021 146.5242);
|
||||
--accent: oklch(0.3053 0.1039 142.4953);
|
||||
--accent-foreground: oklch(0.8686 0.2776 144.4661);
|
||||
--destructive: oklch(0.6786 0.2095 24.6583);
|
||||
--border: oklch(0.5412 0.175 144.0774);
|
||||
--input: oklch(0.1149 0 0);
|
||||
--ring: oklch(0.8686 0.2776 144.4661);
|
||||
--chart-1: oklch(0.8686 0.2776 144.4661);
|
||||
--chart-2: oklch(0.5638 0.1872 143.245);
|
||||
--chart-3: oklch(0.3053 0.1039 142.4953);
|
||||
--chart-4: oklch(0.1179 0.0327 343.3438);
|
||||
--chart-5: oklch(0.8686 0.2776 144.4661);
|
||||
--sidebar: oklch(0.1149 0 0);
|
||||
--sidebar-foreground: oklch(0.8686 0.2776 144.4661);
|
||||
--sidebar-primary: oklch(0.8686 0.2776 144.4661);
|
||||
--sidebar-primary-foreground: oklch(0 0 0);
|
||||
--sidebar-accent: oklch(0.3053 0.1039 142.4953);
|
||||
--sidebar-accent-foreground: oklch(0.8686 0.2776 144.4661);
|
||||
--sidebar-border: oklch(0.5412 0.175 144.0774);
|
||||
--sidebar-ring: oklch(0.8686 0.2776 144.4661);
|
||||
--shadow-2xs: 0 0 15px 2px hsl(135 100% 50% / 0.2);
|
||||
--shadow-xs: 0 0 15px 2px hsl(135 100% 50% / 0.2);
|
||||
--shadow-sm: 0 0 15px 2px hsl(135 100% 50% / 0.4), 0 1px 2px 1px hsl(135 100% 50% / 0.4);
|
||||
--shadow: 0 0 15px 2px hsl(135 100% 50% / 0.4), 0 1px 2px 1px hsl(135 100% 50% / 0.4);
|
||||
--shadow-md: 0 0 15px 2px hsl(135 100% 50% / 0.4), 0 2px 4px 1px hsl(135 100% 50% / 0.4);
|
||||
--shadow-lg: 0 0 15px 2px hsl(135 100% 50% / 0.4), 0 4px 6px 1px hsl(135 100% 50% / 0.4);
|
||||
--shadow-xl: 0 0 15px 2px hsl(135 100% 50% / 0.4), 0 8px 10px 1px hsl(135 100% 50% / 0.4);
|
||||
--shadow-2xl: 0 0 15px 2px hsl(135 100% 50%);
|
||||
}
|
||||
|
||||
[data-theme="homebrew"] body {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Florense-Theme (tweakcn, Dark-Variante): reines Monochrome,
|
||||
weiße Primary mit schwarzer Schrift, keine Schatten */
|
||||
[data-theme="florense"] {
|
||||
--font-sans: 'Geist', ui-sans-serif, sans-serif, system-ui;
|
||||
--font-heading: 'Lora', ui-serif, serif;
|
||||
--radius: 1rem;
|
||||
--background: oklch(0.0969 0 0);
|
||||
--foreground: oklch(0.9851 0 0);
|
||||
--card: oklch(0 0 0);
|
||||
--card-foreground: oklch(0.9851 0 0);
|
||||
--popover: oklch(0 0 0);
|
||||
--popover-foreground: oklch(0.9851 0 0);
|
||||
--primary: oklch(1 0 0);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.2178 0 0);
|
||||
--secondary-foreground: oklch(0.9851 0 0);
|
||||
--muted: oklch(0.2178 0 0);
|
||||
--muted-foreground: oklch(0.683 0 0);
|
||||
--accent: oklch(0.2686 0 0);
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(1 0 0);
|
||||
--border: oklch(0.1913 0 0);
|
||||
--input: oklch(0.2686 0 0);
|
||||
--ring: oklch(1 0 0);
|
||||
--chart-1: oklch(1 0 0);
|
||||
--chart-2: oklch(0.8452 0 0);
|
||||
--chart-3: oklch(0.683 0 0);
|
||||
--chart-4: oklch(0.5103 0 0);
|
||||
--chart-5: oklch(0.3211 0 0);
|
||||
--sidebar: oklch(0 0 0);
|
||||
--sidebar-foreground: oklch(0.9851 0 0);
|
||||
--sidebar-primary: oklch(1 0 0);
|
||||
--sidebar-primary-foreground: oklch(0 0 0);
|
||||
--sidebar-accent: oklch(0.2178 0 0);
|
||||
--sidebar-accent-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(0.2686 0 0);
|
||||
--sidebar-ring: oklch(1 0 0);
|
||||
--shadow-2xs: 0 0 0 0 transparent;
|
||||
--shadow-xs: 0 0 0 0 transparent;
|
||||
--shadow-sm: 0 0 0 0 transparent;
|
||||
--shadow: 0 0 0 0 transparent;
|
||||
--shadow-md: 0 0 0 0 transparent;
|
||||
--shadow-lg: 0 0 0 0 transparent;
|
||||
--shadow-xl: 0 0 0 0 transparent;
|
||||
--shadow-2xl: 0 0 0 0 transparent;
|
||||
}
|
||||
|
||||
/* Kawi-Green-Theme (tweakcn „high tech kawi green", Dark-Variante):
|
||||
Schwarz mit Neon-Grün-Borders, roter Secondary, weiße Offset-Schatten */
|
||||
[data-theme="kawi-green"] {
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-heading: 'JetBrains Mono', monospace;
|
||||
--radius: 0rem;
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(1 0 0);
|
||||
--card: oklch(0 0 0);
|
||||
--card-foreground: oklch(1 0 0);
|
||||
--popover: oklch(0 0 0);
|
||||
--popover-foreground: oklch(1 0 0);
|
||||
--primary: oklch(0.8815 0.275 138.4872);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.628 0.2577 29.2339);
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.1448 0 0);
|
||||
--muted-foreground: oklch(1 0 0);
|
||||
--accent: oklch(1 0 0);
|
||||
--accent-foreground: oklch(0 0 0);
|
||||
--destructive: oklch(0.628 0.2577 29.2339);
|
||||
--border: oklch(0.8815 0.275 138.4872);
|
||||
--input: oklch(0.8815 0.275 138.4872);
|
||||
--ring: oklch(0.8815 0.275 138.4872);
|
||||
--chart-1: oklch(0.8815 0.275 138.4872);
|
||||
--chart-2: oklch(0.628 0.2577 29.2339);
|
||||
--chart-3: oklch(1 0 0);
|
||||
--chart-4: oklch(0.8003 0.1821 151.711);
|
||||
--chart-5: oklch(0.645 0.2154 16.4393);
|
||||
--sidebar: oklch(0 0 0);
|
||||
--sidebar-foreground: oklch(1 0 0);
|
||||
--sidebar-primary: oklch(0.8815 0.275 138.4872);
|
||||
--sidebar-primary-foreground: oklch(0 0 0);
|
||||
--sidebar-accent: oklch(0.1776 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.8815 0.275 138.4872);
|
||||
--sidebar-border: oklch(0.8815 0.275 138.4872);
|
||||
--sidebar-ring: oklch(0.8815 0.275 138.4872);
|
||||
--shadow-2xs: -4px 4px 0 0 hsl(0 0% 100% / 0.5);
|
||||
--shadow-xs: -4px 4px 0 0 hsl(0 0% 100% / 0.5);
|
||||
--shadow-sm: -4px 4px 0 0 hsl(0 0% 100%), -4px 1px 2px -1px hsl(0 0% 100%);
|
||||
--shadow: -4px 4px 0 0 hsl(0 0% 100%), -4px 1px 2px -1px hsl(0 0% 100%);
|
||||
--shadow-md: -4px 4px 0 0 hsl(0 0% 100%), -4px 2px 4px -1px hsl(0 0% 100%);
|
||||
--shadow-lg: -4px 4px 0 0 hsl(0 0% 100%), -4px 4px 6px -1px hsl(0 0% 100%);
|
||||
--shadow-xl: -4px 4px 0 0 hsl(0 0% 100%), -4px 8px 10px -1px hsl(0 0% 100%);
|
||||
--shadow-2xl: -4px 4px 0 0 hsl(0 0% 100%);
|
||||
}
|
||||
|
||||
[data-theme="kawi-green"] body {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Clo.nz-Theme (tweakcn, Dark-Variante): Slate-Dunkelblau,
|
||||
Off-White-Primary, orangener Focus-Ring */
|
||||
[data-theme="clonz"] {
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-heading: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(0.2184 0.0079 274.5602);
|
||||
--foreground: oklch(0.9596 0.0131 82.4019);
|
||||
--card: oklch(0.2614 0.0094 276.7359);
|
||||
--card-foreground: oklch(0.9596 0.0131 82.4019);
|
||||
--popover: oklch(0.2614 0.0094 276.7359);
|
||||
--popover-foreground: oklch(0.9596 0.0131 82.4019);
|
||||
--primary: oklch(0.9596 0.0131 82.4019);
|
||||
--primary-foreground: oklch(0.2184 0.0079 274.5602);
|
||||
--secondary: oklch(0.3225 0.0205 73.1712);
|
||||
--secondary-foreground: oklch(0.9596 0.0131 82.4019);
|
||||
--muted: oklch(0.3022 0.0091 276.8278);
|
||||
--muted-foreground: oklch(0.7333 0.0115 280.4017);
|
||||
--accent: oklch(0.3022 0.0091 276.8278);
|
||||
--accent-foreground: oklch(0.9596 0.0131 82.4019);
|
||||
--destructive: oklch(0.5231 0.1583 27.6707);
|
||||
--border: oklch(0.3022 0.0091 276.8278);
|
||||
--input: oklch(0.3022 0.0091 276.8278);
|
||||
--ring: oklch(0.6711 0.1394 48.3137);
|
||||
--chart-1: oklch(0.9596 0.0131 82.4019);
|
||||
--chart-2: oklch(0.6711 0.1394 48.3137);
|
||||
--chart-3: oklch(0.484 0.0665 184.1633);
|
||||
--chart-4: oklch(0.5194 0.0174 277.5749);
|
||||
--chart-5: oklch(0.7333 0.0115 280.4017);
|
||||
--sidebar: oklch(0.2184 0.0079 274.5602);
|
||||
--sidebar-foreground: oklch(0.9596 0.0131 82.4019);
|
||||
--sidebar-primary: oklch(0.3022 0.0091 276.8278);
|
||||
--sidebar-primary-foreground: oklch(0.9596 0.0131 82.4019);
|
||||
--sidebar-accent: oklch(0.3022 0.0091 276.8278);
|
||||
--sidebar-accent-foreground: oklch(0.9596 0.0131 82.4019);
|
||||
--sidebar-border: oklch(0.3022 0.0091 276.8278);
|
||||
--sidebar-ring: oklch(0.6711 0.1394 48.3137);
|
||||
}
|
||||
|
||||
/* Clawmorphism-Theme (tweakcn, Dark-Variante): neutrales Grau,
|
||||
markante 6px-Offset-Schatten */
|
||||
[data-theme="clawmorphism"] {
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-heading: 'Inter', sans-serif;
|
||||
--radius: 0.575rem;
|
||||
--background: oklch(0.2972 0 0);
|
||||
--foreground: oklch(0.7826 0 0);
|
||||
--card: oklch(0.325 0 0);
|
||||
--card-foreground: oklch(0.7826 0 0);
|
||||
--popover: oklch(0.2603 0 0);
|
||||
--popover-foreground: oklch(0.931 0 0);
|
||||
--primary: oklch(0.8822 0 0);
|
||||
--primary-foreground: oklch(0.2435 0 0);
|
||||
--secondary: oklch(0.3092 0 0);
|
||||
--secondary-foreground: oklch(0.937 0 0);
|
||||
--muted: oklch(0.2435 0 0);
|
||||
--muted-foreground: oklch(0.6993 0 0);
|
||||
--accent: oklch(0.3329 0 0);
|
||||
--accent-foreground: oklch(0.9672 0 0);
|
||||
--destructive: oklch(0.6398 0.0838 195.2629);
|
||||
--border: oklch(0.36 0 0);
|
||||
--input: oklch(0.3753 0 0);
|
||||
--ring: oklch(0.2603 0 0);
|
||||
--chart-1: oklch(0.9249 0 0);
|
||||
--chart-2: oklch(0.7921 0 0);
|
||||
--chart-3: oklch(0.66 0 0);
|
||||
--chart-4: oklch(0.5208 0 0);
|
||||
--chart-5: oklch(0.4017 0 0);
|
||||
--sidebar: oklch(0.2435 0 0);
|
||||
--sidebar-foreground: oklch(0.931 0 0);
|
||||
--sidebar-primary: oklch(0.8822 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.2435 0 0);
|
||||
--sidebar-accent: oklch(0.2809 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.931 0 0);
|
||||
--sidebar-border: oklch(0.3171 0 0);
|
||||
--sidebar-ring: oklch(0.6401 0 0);
|
||||
--shadow-2xs: 6px 6px 16px 0 hsl(0 0% 8% / 0.38);
|
||||
--shadow-xs: 6px 6px 16px 0 hsl(0 0% 8% / 0.38);
|
||||
--shadow-sm: 6px 6px 16px 0 hsl(0 0% 8% / 0.76), 6px 1px 2px -1px hsl(0 0% 8% / 0.76);
|
||||
--shadow: 6px 6px 16px 0 hsl(0 0% 8% / 0.76), 6px 1px 2px -1px hsl(0 0% 8% / 0.76);
|
||||
--shadow-md: 6px 6px 16px 0 hsl(0 0% 8% / 0.76), 6px 2px 4px -1px hsl(0 0% 8% / 0.76);
|
||||
--shadow-lg: 6px 6px 16px 0 hsl(0 0% 8% / 0.76), 6px 4px 6px -1px hsl(0 0% 8% / 0.76);
|
||||
--shadow-xl: 6px 6px 16px 0 hsl(0 0% 8% / 0.76), 6px 8px 10px -1px hsl(0 0% 8% / 0.76);
|
||||
--shadow-2xl: 6px 6px 16px 0 hsl(0 0% 8%);
|
||||
}
|
||||
|
||||
/* Realmorphism-Theme (tweakcn, Dark-Variante): dunkles Teal/Forest,
|
||||
harte dunkelgrüne Offset-Schatten */
|
||||
[data-theme="realmorphism"] {
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-heading: 'Inter', sans-serif;
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(0.1276 0.0035 245.8087);
|
||||
--foreground: oklch(0.9457 0.0091 161.3462);
|
||||
--card: oklch(0.1712 0.0039 229.0605);
|
||||
--card-foreground: oklch(0.9457 0.0091 161.3462);
|
||||
--popover: oklch(0.1712 0.0039 229.0605);
|
||||
--popover-foreground: oklch(0.9457 0.0091 161.3462);
|
||||
--primary: oklch(0.4794 0.075 168.9054);
|
||||
--primary-foreground: oklch(0.9457 0.0091 161.3462);
|
||||
--secondary: oklch(0.2201 0.0095 159.2095);
|
||||
--secondary-foreground: oklch(0.9457 0.0091 161.3462);
|
||||
--muted: oklch(0.2201 0.0095 159.2095);
|
||||
--muted-foreground: oklch(0.7341 0.0188 169.8711);
|
||||
--accent: oklch(0.5955 0.0953 168.0699);
|
||||
--accent-foreground: oklch(0.9457 0.0091 161.3462);
|
||||
--destructive: oklch(0.4041 0.112 23.5585);
|
||||
--border: oklch(0.317 0.0173 165.969);
|
||||
--input: oklch(0.2201 0.0095 159.2095);
|
||||
--ring: oklch(0.5955 0.0953 168.0699);
|
||||
--chart-1: oklch(0.4794 0.075 168.9054);
|
||||
--chart-2: oklch(0.5337 0.0845 74.0079);
|
||||
--chart-3: oklch(0.9068 0.1567 156.0616);
|
||||
--chart-4: oklch(0.7341 0.0188 169.8711);
|
||||
--chart-5: oklch(0.879 0.1534 91.6054);
|
||||
--sidebar: oklch(0.1276 0.0035 245.8087);
|
||||
--sidebar-foreground: oklch(0.9457 0.0091 161.3462);
|
||||
--sidebar-primary: oklch(0.4794 0.075 168.9054);
|
||||
--sidebar-primary-foreground: oklch(0.9457 0.0091 161.3462);
|
||||
--sidebar-accent: oklch(0.2201 0.0095 159.2095);
|
||||
--sidebar-accent-foreground: oklch(0.9457 0.0091 161.3462);
|
||||
--sidebar-border: oklch(0.317 0.0173 165.969);
|
||||
--sidebar-ring: oklch(0.5955 0.0953 168.0699);
|
||||
--shadow-2xs: 2px 2px 0 1px hsl(160.6 43.6% 29.2% / 0.5);
|
||||
--shadow-xs: 2px 2px 0 1px hsl(160.6 43.6% 29.2% / 0.5);
|
||||
--shadow-sm: 2px 2px 0 1px hsl(160.6 43.6% 29.2%), 2px 1px 2px 0 hsl(160.6 43.6% 29.2%);
|
||||
--shadow: 2px 2px 0 1px hsl(160.6 43.6% 29.2%), 2px 1px 2px 0 hsl(160.6 43.6% 29.2%);
|
||||
--shadow-md: 2px 2px 0 1px hsl(160.6 43.6% 29.2%), 2px 2px 4px 0 hsl(160.6 43.6% 29.2%);
|
||||
--shadow-lg: 2px 2px 0 1px hsl(160.6 43.6% 29.2%), 2px 4px 6px 0 hsl(160.6 43.6% 29.2%);
|
||||
--shadow-xl: 2px 2px 0 1px hsl(160.6 43.6% 29.2%), 2px 8px 10px 0 hsl(160.6 43.6% 29.2%);
|
||||
--shadow-2xl: 2px 2px 0 1px hsl(160.6 43.6% 29.2%);
|
||||
}
|
||||
|
||||
/* Amber-Theme (tweakcn „my amber theme", Dark-Variante):
|
||||
Amber-Phosphor auf Schwarz, eckig, Amber-Glow, Retro-Font Workbench */
|
||||
[data-theme="amber"] {
|
||||
--font-sans: 'Workbench', ui-monospace, monospace;
|
||||
--font-heading: 'Workbench', ui-monospace, monospace;
|
||||
--radius: 0rem;
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(0.8403 0.1724 84.0757);
|
||||
--card: oklch(0.1149 0 0);
|
||||
--card-foreground: oklch(0.8403 0.1724 84.0757);
|
||||
--popover: oklch(0.1149 0 0);
|
||||
--popover-foreground: oklch(0.8403 0.1724 84.0757);
|
||||
--primary: oklch(0.8403 0.1724 84.0757);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.266 0.055 80.8676);
|
||||
--secondary-foreground: oklch(0.8403 0.1724 84.0757);
|
||||
--muted: oklch(0.1845 0.0378 85.8185);
|
||||
--muted-foreground: oklch(0.5155 0.107 79.2295);
|
||||
--accent: oklch(0.8403 0.1724 84.0757);
|
||||
--accent-foreground: oklch(0 0 0);
|
||||
--destructive: oklch(0.2428 0.0996 29.2339);
|
||||
--border: oklch(0.8403 0.1724 84.0757);
|
||||
--input: oklch(0.8403 0.1724 84.0757);
|
||||
--ring: oklch(0.8403 0.1724 84.0757);
|
||||
--chart-1: oklch(0.8403 0.1724 84.0757);
|
||||
--chart-2: oklch(0.7773 0.1595 84.1047);
|
||||
--chart-3: oklch(0.7119 0.146 84.6103);
|
||||
--chart-4: oklch(0.6462 0.1325 84.7159);
|
||||
--chart-5: oklch(0.5775 0.1183 85.4662);
|
||||
--sidebar: oklch(0 0 0);
|
||||
--sidebar-foreground: oklch(0.8403 0.1724 84.0757);
|
||||
--sidebar-primary: oklch(0.8403 0.1724 84.0757);
|
||||
--sidebar-primary-foreground: oklch(0 0 0);
|
||||
--sidebar-accent: oklch(0.266 0.055 80.8676);
|
||||
--sidebar-accent-foreground: oklch(0.8403 0.1724 84.0757);
|
||||
--sidebar-border: oklch(0.8403 0.1724 84.0757);
|
||||
--sidebar-ring: oklch(0.8403 0.1724 84.0757);
|
||||
--shadow-2xs: 0 0 4px 0 hsl(45 100% 50% / 0.1);
|
||||
--shadow-xs: 0 0 4px 0 hsl(45 100% 50% / 0.1);
|
||||
--shadow-sm: 0 0 4px 0 hsl(45 100% 50% / 0.2), 0 1px 2px -1px hsl(45 100% 50% / 0.2);
|
||||
--shadow: 0 0 4px 0 hsl(45 100% 50% / 0.2), 0 1px 2px -1px hsl(45 100% 50% / 0.2);
|
||||
--shadow-md: 0 0 4px 0 hsl(45 100% 50% / 0.2), 0 2px 4px -1px hsl(45 100% 50% / 0.2);
|
||||
--shadow-lg: 0 0 4px 0 hsl(45 100% 50% / 0.2), 0 4px 6px -1px hsl(45 100% 50% / 0.2);
|
||||
--shadow-xl: 0 0 4px 0 hsl(45 100% 50% / 0.2), 0 8px 10px -1px hsl(45 100% 50% / 0.2);
|
||||
--shadow-2xl: 0 0 4px 0 hsl(45 100% 50% / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="amber"] body {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Neo-Brutalismus: helle Pastell-Flächen, schwarze 2px-Borders,
|
||||
harte Offset-Schatten, keine Rundungen */
|
||||
|
||||
[data-theme="neo"] {
|
||||
--font-sans: 'Space Grotesk', sans-serif;
|
||||
--font-heading: 'Archivo Black', sans-serif;
|
||||
--radius: 0rem;
|
||||
--background: oklch(0.96 0.045 95);
|
||||
--foreground: oklch(0.22 0.02 60);
|
||||
--card: oklch(0.99 0.01 95);
|
||||
--card-foreground: oklch(0.22 0.02 60);
|
||||
--popover: oklch(0.99 0.01 95);
|
||||
--popover-foreground: oklch(0.22 0.02 60);
|
||||
--primary: oklch(0.86 0.17 95);
|
||||
--primary-foreground: oklch(0.22 0.02 60);
|
||||
--secondary: oklch(0.9 0.06 95);
|
||||
--secondary-foreground: oklch(0.22 0.02 60);
|
||||
--muted: oklch(0.92 0.04 95);
|
||||
--muted-foreground: oklch(0.45 0.03 60);
|
||||
--accent: oklch(0.75 0.19 350);
|
||||
--accent-foreground: oklch(0.22 0.02 60);
|
||||
--destructive: oklch(0.6 0.24 25);
|
||||
--border: oklch(0.22 0.02 60);
|
||||
--input: oklch(0.22 0.02 60);
|
||||
--ring: oklch(0.22 0.02 60);
|
||||
--sidebar: oklch(0.99 0.01 95);
|
||||
--sidebar-foreground: oklch(0.22 0.02 60);
|
||||
--sidebar-primary: oklch(0.86 0.17 95);
|
||||
--sidebar-primary-foreground: oklch(0.22 0.02 60);
|
||||
--sidebar-accent: oklch(0.9 0.06 95);
|
||||
--sidebar-accent-foreground: oklch(0.22 0.02 60);
|
||||
--sidebar-border: oklch(0.22 0.02 60);
|
||||
--sidebar-ring: oklch(0.22 0.02 60);
|
||||
}
|
||||
|
||||
[data-theme="neo-pink"] {
|
||||
--font-sans: 'Space Grotesk', sans-serif;
|
||||
--font-heading: 'Archivo Black', sans-serif;
|
||||
--radius: 0rem;
|
||||
--background: oklch(0.93 0.07 355);
|
||||
--foreground: oklch(0.25 0.05 340);
|
||||
--card: oklch(0.97 0.04 355);
|
||||
--card-foreground: oklch(0.25 0.05 340);
|
||||
--popover: oklch(0.97 0.04 355);
|
||||
--popover-foreground: oklch(0.25 0.05 340);
|
||||
--primary: oklch(0.85 0.15 200);
|
||||
--primary-foreground: oklch(0.25 0.05 340);
|
||||
--secondary: oklch(0.88 0.08 350);
|
||||
--secondary-foreground: oklch(0.25 0.05 340);
|
||||
--muted: oklch(0.9 0.06 355);
|
||||
--muted-foreground: oklch(0.48 0.07 340);
|
||||
--accent: oklch(0.88 0.17 95);
|
||||
--accent-foreground: oklch(0.25 0.05 340);
|
||||
--destructive: oklch(0.58 0.24 25);
|
||||
--border: oklch(0.25 0.05 340);
|
||||
--input: oklch(0.25 0.05 340);
|
||||
--ring: oklch(0.25 0.05 340);
|
||||
--sidebar: oklch(0.97 0.04 355);
|
||||
--sidebar-foreground: oklch(0.25 0.05 340);
|
||||
--sidebar-primary: oklch(0.85 0.15 200);
|
||||
--sidebar-primary-foreground: oklch(0.25 0.05 340);
|
||||
--sidebar-accent: oklch(0.88 0.08 350);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.05 340);
|
||||
--sidebar-border: oklch(0.25 0.05 340);
|
||||
--sidebar-ring: oklch(0.25 0.05 340);
|
||||
}
|
||||
|
||||
[data-theme="neo-mint"] {
|
||||
--font-sans: 'Space Grotesk', sans-serif;
|
||||
--font-heading: 'Archivo Black', sans-serif;
|
||||
--radius: 0rem;
|
||||
--background: oklch(0.93 0.08 160);
|
||||
--foreground: oklch(0.25 0.05 200);
|
||||
--card: oklch(0.97 0.04 160);
|
||||
--card-foreground: oklch(0.25 0.05 200);
|
||||
--popover: oklch(0.97 0.04 160);
|
||||
--popover-foreground: oklch(0.25 0.05 200);
|
||||
--primary: oklch(0.68 0.2 305);
|
||||
--primary-foreground: oklch(0.98 0.02 300);
|
||||
--secondary: oklch(0.88 0.08 165);
|
||||
--secondary-foreground: oklch(0.25 0.05 200);
|
||||
--muted: oklch(0.89 0.07 160);
|
||||
--muted-foreground: oklch(0.45 0.06 200);
|
||||
--accent: oklch(0.8 0.18 60);
|
||||
--accent-foreground: oklch(0.25 0.05 200);
|
||||
--destructive: oklch(0.58 0.24 25);
|
||||
--border: oklch(0.25 0.05 200);
|
||||
--input: oklch(0.25 0.05 200);
|
||||
--ring: oklch(0.25 0.05 200);
|
||||
--sidebar: oklch(0.97 0.04 160);
|
||||
--sidebar-foreground: oklch(0.25 0.05 200);
|
||||
--sidebar-primary: oklch(0.68 0.2 305);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.02 300);
|
||||
--sidebar-accent: oklch(0.88 0.08 165);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.05 200);
|
||||
--sidebar-border: oklch(0.25 0.05 200);
|
||||
--sidebar-ring: oklch(0.25 0.05 200);
|
||||
}
|
||||
|
||||
/* Brutalist-Regeln für alle neo-* Themes:
|
||||
2px schwarze Borders + harter 4px-Offset-Schatten auf Karten-Elementen,
|
||||
Buttons mit „reingedrückt"-Effekt beim Klick */
|
||||
[data-theme^="neo"] .border,
|
||||
[data-theme^="neo"] [data-slot="button"],
|
||||
[data-theme^="neo"] .upload-tile-media {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: var(--border);
|
||||
box-shadow: 4px 4px 0 0 var(--border);
|
||||
}
|
||||
|
||||
[data-theme^="neo"] [data-slot="button"] {
|
||||
transition: translate 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
|
||||
[data-theme^="neo"] [data-slot="button"]:hover {
|
||||
translate: -1px -1px;
|
||||
box-shadow: 5px 5px 0 0 var(--border);
|
||||
}
|
||||
|
||||
[data-theme^="neo"] [data-slot="button"]:active {
|
||||
translate: 2px 2px;
|
||||
box-shadow: 1px 1px 0 0 var(--border);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply font-sans;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
h1, h2, h3, h4 {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
/* View Transition für Upload-Media:
|
||||
Das Bild morpht geschmeidig vom Thumbnail auf Thread-Größe.
|
||||
Old/New nutzen den synchronen Standard-Crossfade (kein Blinken),
|
||||
nur Dauer und Easing werden angeglichen. */
|
||||
::view-transition-group(upload-media) {
|
||||
animation-duration: 380ms;
|
||||
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
::view-transition-old(upload-media),
|
||||
::view-transition-new(upload-media) {
|
||||
animation-duration: 380ms;
|
||||
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
/* Phosphor-Glyphen haben mehr Innenrand im ViewBox als Lucide —
|
||||
leicht hochskalieren, damit sie bei gleicher size-* gleich groß wirken. */
|
||||
svg.iconify--ph {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { useMediaControls } from '@vueuse/core'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { formatFileSize } from '~~/shared/utils/attachments'
|
||||
import type { AttachmentAudioMeta } from '~~/shared/utils/attachments'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
name: string
|
||||
size?: number | null
|
||||
cover?: string | null
|
||||
meta?: AttachmentAudioMeta | null
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const { playing, currentTime, duration, volume, muted } = useMediaControls(audioRef)
|
||||
|
||||
const metaLine = computed(() => {
|
||||
const m = props.meta
|
||||
if (!m) return null
|
||||
const parts = [m.artist, m.title].filter(Boolean)
|
||||
return parts.length ? parts.join(' – ') : (m.album || null)
|
||||
})
|
||||
|
||||
// Die Komponente wird per v-if nur gemountet, wenn der Dialog offen ist
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
playing.value = true
|
||||
})
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) playing.value = false
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function seek(event: Event) {
|
||||
currentTime.value = Number((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function formatTime(sec: number) {
|
||||
if (!Number.isFinite(sec)) return '0:00'
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60).toString().padStart(2, '0')
|
||||
return `${m}:${s}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex min-w-0 items-center gap-2 pr-10">
|
||||
<Icon name="ph:file-audio-fill" class="size-4 shrink-0" />
|
||||
<span class="min-w-0 truncate">{{ name }}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="metaLine" class="truncate">
|
||||
{{ metaLine }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<img
|
||||
v-if="cover"
|
||||
:src="cover"
|
||||
:alt="name"
|
||||
class="max-h-56 w-full rounded-md border bg-muted object-contain"
|
||||
>
|
||||
|
||||
<audio ref="audioRef" :src="src" preload="metadata" class="hidden" />
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2.5 rounded-md border bg-muted/50 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||
:aria-label="playing ? 'Pause' : 'Abspielen'"
|
||||
@click="toggle"
|
||||
>
|
||||
<Icon name="ph:pause-fill" v-if="playing" class="size-4" />
|
||||
<Icon name="ph:play-fill" v-else class="size-4" />
|
||||
</button>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(currentTime) }}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="Number.isFinite(duration) ? duration : 0"
|
||||
step="0.1"
|
||||
:value="currentTime"
|
||||
aria-label="Wiedergabeposition"
|
||||
class="min-w-0 flex-1 cursor-pointer accent-primary"
|
||||
@input="seek"
|
||||
>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||
:aria-label="muted ? 'Ton an' : 'Stumm'"
|
||||
@click="muted = !muted"
|
||||
>
|
||||
<Icon name="ph:speaker-x-fill" v-if="muted" class="size-4" />
|
||||
<Icon name="ph:speaker-high-fill" v-else class="size-4" />
|
||||
</button>
|
||||
<input
|
||||
v-model.number="volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
aria-label="Lautstärke"
|
||||
class="w-16 min-w-0 cursor-pointer accent-primary sm:w-20"
|
||||
:class="muted && 'opacity-40'"
|
||||
>
|
||||
<span class="ml-auto shrink-0 text-xs text-muted-foreground">{{ formatFileSize(size) }}</span>
|
||||
<Button variant="outline" size="sm" class="shrink-0" as-child>
|
||||
<a :href="src" :download="name">
|
||||
<Icon name="ph:download-fill" class="size-4" />
|
||||
Herunterladen
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command'
|
||||
|
||||
const { open } = useCommandPalette()
|
||||
const { user, can } = useAuth()
|
||||
|
||||
useEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
open.value = !open.value
|
||||
}
|
||||
})
|
||||
|
||||
async function go(to: string) {
|
||||
open.value = false
|
||||
await navigateTo(to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommandDialog
|
||||
v-model:open="open"
|
||||
title="Schnellsuche"
|
||||
description="Seiten, Tags oder Uploader suchen"
|
||||
class="rounded-md!"
|
||||
>
|
||||
<CommandInput placeholder="Seiten, Tags oder Uploader suchen…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Keine Ergebnisse.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Seiten">
|
||||
<CommandItem value="Startseite" class="gap-2" @select="go('/')">
|
||||
<Icon name="ph:house-fill" class="size-4 text-muted-foreground" />
|
||||
Startseite
|
||||
<CommandShortcut>/</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem value="Benutzer" class="gap-2" @select="go('/users')">
|
||||
<Icon name="ph:users-fill" class="size-4 text-muted-foreground" />
|
||||
Benutzer
|
||||
<CommandShortcut>/users</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem v-if="user" value="Mein Profil" class="gap-2" @select="go(`/user/${user.id}`)">
|
||||
<Icon name="ph:user-fill" class="size-4 text-muted-foreground" />
|
||||
Mein Profil
|
||||
</CommandItem>
|
||||
<CommandItem v-if="user" value="Einstellungen" class="gap-2" @select="go('/settings')">
|
||||
<Icon name="ph:gear-fill" class="size-4 text-muted-foreground" />
|
||||
Einstellungen
|
||||
</CommandItem>
|
||||
<CommandItem v-if="can('admin.access')" value="Admin-Bereich" class="gap-2" @select="go('/admin')">
|
||||
<Icon name="ph:shield-fill" class="size-4 text-muted-foreground" />
|
||||
Admin-Bereich
|
||||
</CommandItem>
|
||||
<CommandItem v-if="!user" value="Login" class="gap-2" @select="go('/login')">
|
||||
<Icon name="ph:sign-in-fill" class="size-4 text-muted-foreground" />
|
||||
Login
|
||||
</CommandItem>
|
||||
<CommandItem v-if="!user" value="Registrieren" class="gap-2" @select="go('/register')">
|
||||
<Icon name="ph:user-plus-fill" class="size-4 text-muted-foreground" />
|
||||
Registrieren
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
<CommandPaletteResults @select="go" />
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { useCommand } from '@/components/ui/command'
|
||||
import { CommandGroup, CommandItem } from '@/components/ui/command'
|
||||
|
||||
interface TagResult { name: string, count: number }
|
||||
interface ListedUser { id: number, name: string, color: string | null, avatarUrl: string | null }
|
||||
|
||||
const emit = defineEmits<{ select: [to: string] }>()
|
||||
|
||||
const { filterState } = useCommand()
|
||||
|
||||
const tags = ref<TagResult[]>([])
|
||||
const users = ref<ListedUser[]>([])
|
||||
|
||||
let tagTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(() => filterState.search, (q) => {
|
||||
clearTimeout(tagTimer)
|
||||
tagTimer = setTimeout(async () => {
|
||||
try {
|
||||
tags.value = await $fetch<TagResult[]>('/api/tags', { query: { q } })
|
||||
}
|
||||
catch {
|
||||
tags.value = []
|
||||
}
|
||||
}, 150)
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
users.value = await $fetch<ListedUser[]>('/api/users')
|
||||
}
|
||||
catch {
|
||||
users.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommandGroup v-if="tags.length" heading="Tags">
|
||||
<CommandItem
|
||||
v-for="t in tags"
|
||||
:key="t.name"
|
||||
:value="t.name"
|
||||
class="gap-2"
|
||||
@select="emit('select', `/?tag=${encodeURIComponent(t.name)}`)"
|
||||
>
|
||||
<Icon name="ph:tag-fill" class="size-4 text-muted-foreground" />
|
||||
{{ t.name }}
|
||||
<span class="ml-auto text-xs text-muted-foreground">{{ t.count }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup heading="Uploader">
|
||||
<CommandItem
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
:value="u.name"
|
||||
class="gap-2"
|
||||
@select="emit('select', `/user/${u.id}`)"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="u.avatarUrl"
|
||||
:src="u.avatarUrl"
|
||||
:alt="u.name"
|
||||
width="20"
|
||||
height="20"
|
||||
fit="cover"
|
||||
class="size-5 rounded-sm"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-5 items-center justify-center rounded-sm text-[10px] font-medium"
|
||||
:style="u.color ? { backgroundColor: u.color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
|
||||
>{{ u.name.slice(0, 2).toUpperCase() }}</span>
|
||||
<span :style="u.color ? { color: u.color } : undefined">{{ u.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import AudioPlayerDialog from '@/components/AudioPlayerDialog.vue'
|
||||
import ImageLightbox from '@/components/ImageLightbox.vue'
|
||||
import VoiceMessagePlayer from '@/components/VoiceMessagePlayer.vue'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { formatFileSize, isAudioAttachment, isImageAttachment, isVoiceAttachment } from '~~/shared/utils/attachments'
|
||||
import type { CommentAttachment } from '~~/shared/utils/attachments'
|
||||
|
||||
const props = defineProps<{
|
||||
attachments: CommentAttachment[]
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const voices = computed(() => props.attachments.filter(isVoiceAttachment))
|
||||
const images = computed(() => props.attachments.filter(a => isImageAttachment(a) && !isVoiceAttachment(a)))
|
||||
const audios = computed(() => props.attachments.filter(a => isAudioAttachment(a) && !isImageAttachment(a) && !isVoiceAttachment(a)))
|
||||
const files = computed(() => props.attachments.filter(a => !isImageAttachment(a) && !isAudioAttachment(a) && !isVoiceAttachment(a)))
|
||||
const hasFileAttachments = computed(() => images.value.length + audios.value.length + files.value.length > 0)
|
||||
|
||||
const lightboxSrc = ref<string | null>(null)
|
||||
const lightboxAlt = ref('')
|
||||
const audioAtt = ref<CommentAttachment | null>(null)
|
||||
|
||||
function fileIcon(a: CommentAttachment) {
|
||||
const mime = a.mimeType ?? ''
|
||||
const name = a.name.toLowerCase()
|
||||
if (mime.startsWith('video/')) return 'ph:file-video-fill'
|
||||
if (mime === 'application/pdf' || /\.(txt|md|pdf|docx?|odt)$/.test(name)) return 'ph:file-text-fill'
|
||||
if (/\.(zip|rar|7z|tar|gz|bz2|xz)$/.test(name) || /zip|compressed|x-tar|gzip/.test(mime)) return 'ph:file-archive-fill'
|
||||
return 'ph:file-fill'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Kompakt-Modus (Sidebar): nur ein Hinweis-Chip -->
|
||||
<span
|
||||
v-if="compact && attachments.length"
|
||||
class="mx-0.5 inline-flex items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 align-middle text-xs text-muted-foreground"
|
||||
>
|
||||
<Icon name="ph:folder-plus-fill" class="size-3.5" />
|
||||
{{ attachments.length }}
|
||||
</span>
|
||||
|
||||
<div v-else-if="attachments.length" class="mt-2.5 space-y-2.5">
|
||||
<!-- Sprachnachrichten gehören direkt zum Kommentar, nicht in den Anhang-Bereich -->
|
||||
<div v-if="voices.length" class="flex flex-col gap-1.5">
|
||||
<VoiceMessagePlayer
|
||||
v-for="a in voices"
|
||||
:key="a.id"
|
||||
:src="a.url"
|
||||
:name="a.name"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="hasFileAttachments">
|
||||
<div class="mb-1.5 flex items-center gap-2">
|
||||
<span class="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Anhänge</span>
|
||||
<Separator class="flex-1" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
v-for="a in images"
|
||||
:key="a.id"
|
||||
type="button"
|
||||
class="group relative size-16 cursor-pointer overflow-hidden rounded-md border bg-muted transition-colors hover:border-primary"
|
||||
:title="a.name"
|
||||
@click.stop="lightboxSrc = a.url; lightboxAlt = a.name"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="a.url"
|
||||
:alt="a.name"
|
||||
width="64"
|
||||
height="64"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-for="a in audios"
|
||||
:key="a.id"
|
||||
type="button"
|
||||
class="flex max-w-48 cursor-pointer items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs transition-colors hover:border-primary hover:bg-accent"
|
||||
:title="`${a.name} abspielen`"
|
||||
@click.stop="audioAtt = a"
|
||||
>
|
||||
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="size-5 shrink-0 rounded-sm object-cover">
|
||||
<Icon name="ph:file-audio-fill" v-else class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ a.name }}</span>
|
||||
<span v-if="formatFileSize(a.size)" class="shrink-0 text-muted-foreground">{{ formatFileSize(a.size) }}</span>
|
||||
<Icon name="ph:play-fill" class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
<a
|
||||
v-for="a in files"
|
||||
:key="a.id"
|
||||
:href="a.url"
|
||||
:download="a.name"
|
||||
class="flex max-w-48 items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs transition-colors hover:border-primary hover:bg-accent"
|
||||
:title="a.name"
|
||||
@click.stop
|
||||
>
|
||||
<Icon :name="fileIcon(a)" class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ a.name }}</span>
|
||||
<span v-if="formatFileSize(a.size)" class="shrink-0 text-muted-foreground">{{ formatFileSize(a.size) }}</span>
|
||||
<Icon name="ph:download-fill" class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</a>
|
||||
|
||||
<ImageLightbox
|
||||
v-if="lightboxSrc"
|
||||
:src="lightboxSrc"
|
||||
:alt="lightboxAlt"
|
||||
@close="lightboxSrc = null"
|
||||
/>
|
||||
<AudioPlayerDialog
|
||||
v-if="audioAtt"
|
||||
:open="true"
|
||||
:src="audioAtt.url"
|
||||
:name="audioAtt.name"
|
||||
:size="audioAtt.size"
|
||||
:cover="audioAtt.cover"
|
||||
:meta="audioAtt.meta"
|
||||
@update:open="audioAtt = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { mentionParts } from '~~/shared/utils/mentions'
|
||||
import { getYoutubeId, youtubeEmbedUrl } from '~~/shared/utils/youtube'
|
||||
import { getTwitchTarget, serializeTwitchTarget, twitchEmbedUrl } from '~~/shared/utils/twitch'
|
||||
|
||||
interface Part {
|
||||
type: 'text' | 'link' | 'youtube' | 'twitch' | 'image'
|
||||
text: string
|
||||
href?: string
|
||||
videoId?: string
|
||||
twitchTarget?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
// Twitch-Embeds brauchen den Hostname als parent-Parameter
|
||||
const requestHost = useRequestURL().hostname
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"']+/g
|
||||
const TRAILING_PUNCT = /[.,;:!?)\]]+$/
|
||||
|
||||
const IMAGE_EXT = /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i
|
||||
|
||||
function isImageUrl(href: string): boolean {
|
||||
try {
|
||||
return IMAGE_EXT.test(new URL(href).pathname)
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function hostnameFor(part: Part): string {
|
||||
const raw = part.href ?? part.text
|
||||
try {
|
||||
return new URL(raw).hostname.replace(/^www\./, '')
|
||||
}
|
||||
catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
const { data: mentionUsers } = useMentionUsers()
|
||||
const parts = computed<Part[]>(() => {
|
||||
const result: Part[] = []
|
||||
let last = 0
|
||||
for (const match of props.content.matchAll(URL_RE)) {
|
||||
const raw = match[0]
|
||||
const href = raw.replace(TRAILING_PUNCT, '')
|
||||
const trailing = raw.slice(href.length)
|
||||
if (match.index > last) {
|
||||
result.push({ type: 'text', text: props.content.slice(last, match.index) })
|
||||
}
|
||||
const videoId = getYoutubeId(href)
|
||||
const twitchTarget = videoId ? null : serializeTwitchTarget(getTwitchTarget(href))
|
||||
if (videoId) {
|
||||
result.push({ type: 'youtube', text: href, videoId })
|
||||
}
|
||||
else if (twitchTarget) {
|
||||
result.push({ type: 'twitch', text: href, twitchTarget })
|
||||
}
|
||||
else if (isImageUrl(href)) {
|
||||
result.push({ type: 'image', text: href, href })
|
||||
}
|
||||
else {
|
||||
result.push({ type: 'link', text: href, href })
|
||||
}
|
||||
if (trailing) {
|
||||
result.push({ type: 'text', text: trailing })
|
||||
}
|
||||
last = match.index + raw.length
|
||||
}
|
||||
if (last < props.content.length) {
|
||||
result.push({ type: 'text', text: props.content.slice(last) })
|
||||
}
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="whitespace-pre-wrap break-words text-sm">
|
||||
<template v-for="(part, index) in parts" :key="index">
|
||||
<template v-if="part.type === 'text'">
|
||||
<template v-for="(segment, segmentIndex) in mentionParts(part.text, mentionUsers)" :key="segmentIndex">
|
||||
<UserHoverCard v-if="segment.user" :user-id="segment.user.id" :user-name="segment.user.name" :user-color="segment.user.color">
|
||||
<NuxtLink :to="`/user/${segment.user.id}`" class="rounded-sm bg-accent px-0.5 font-medium text-accent-foreground hover:underline" @click.stop>{{ segment.text }}</NuxtLink>
|
||||
</UserHoverCard>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- Kompakt-Modus (Sidebar): Icon + Hostname statt Embed/Card -->
|
||||
<span
|
||||
v-else-if="compact && part.type !== 'text'"
|
||||
class="mx-0.5 inline-flex items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 align-middle text-xs text-muted-foreground"
|
||||
>
|
||||
<Icon v-if="part.type === 'youtube'" name="simple-icons:youtube" class="size-3.5" />
|
||||
<Icon v-else-if="part.type === 'twitch'" name="simple-icons:twitch" class="size-3.5" />
|
||||
<Icon name="ph:image-fill" v-else-if="part.type === 'image'" class="size-3.5" />
|
||||
<Icon name="ph:link-fill" v-else class="size-3.5" />
|
||||
{{ hostnameFor(part) }}
|
||||
</span>
|
||||
<template v-else-if="!compact">
|
||||
<LinkPreviewCard
|
||||
v-if="part.type === 'link'"
|
||||
:url="part.href!"
|
||||
/>
|
||||
<div v-else-if="part.type === 'youtube'" class="mt-2 aspect-video overflow-hidden rounded-md">
|
||||
<iframe
|
||||
:src="youtubeEmbedUrl(part.videoId!)"
|
||||
:title="part.text"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="part.type === 'twitch'" class="mt-2 aspect-video overflow-hidden rounded-md">
|
||||
<iframe
|
||||
:src="twitchEmbedUrl(part.twitchTarget!, requestHost) ?? ''"
|
||||
:title="part.text"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
v-else-if="part.type === 'image'"
|
||||
:href="part.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-2 block"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="part.href"
|
||||
:alt="part.text"
|
||||
loading="lazy"
|
||||
class="max-h-60 rounded-md"
|
||||
/>
|
||||
</a>
|
||||
<template v-else>{{ part.text }}</template>
|
||||
</template>
|
||||
<template v-else>{{ part.text }}</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
const props = defineProps<{ commentId: number }>()
|
||||
|
||||
const { toggle } = useCommentReactions()
|
||||
|
||||
const open = ref(false)
|
||||
const busy = ref(false)
|
||||
|
||||
async function pick(emoji: string) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
open.value = false
|
||||
try {
|
||||
await toggle(props.commentId, emoji)
|
||||
}
|
||||
catch {
|
||||
toast.error('Reaktion fehlgeschlagen')
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer self-center rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Reaktion hinzufügen"
|
||||
>
|
||||
<Icon name="ph:smiley-fill" class="size-5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-auto p-2">
|
||||
<div class="grid grid-cols-8 gap-1">
|
||||
<button
|
||||
v-for="e in REACTION_EMOJIS"
|
||||
:key="e"
|
||||
type="button"
|
||||
class="flex size-9 cursor-pointer items-center justify-center rounded-md text-xl transition-colors hover:bg-accent"
|
||||
@click="pick(e)"
|
||||
>
|
||||
{{ e }}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const props = defineProps<{ commentId: number }>()
|
||||
|
||||
const { user } = useAuth()
|
||||
const { reactionsFor, toggle } = useCommentReactions()
|
||||
|
||||
const busy = ref(false)
|
||||
const pending = ref<string | null>(null)
|
||||
const reactions = computed(() => reactionsFor(props.commentId))
|
||||
|
||||
const BURST_LINES = Array.from({ length: 12 }, (_, i) => ({
|
||||
angle: i * 30,
|
||||
long: i % 2 === 0,
|
||||
}))
|
||||
const burstEmoji = ref<string | null>(null)
|
||||
const burstNonce = ref(0)
|
||||
|
||||
async function pick(emoji: string) {
|
||||
if (!user.value) {
|
||||
toast.error('Melde dich an, um zu reagieren.')
|
||||
return
|
||||
}
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
pending.value = emoji
|
||||
burstEmoji.value = emoji
|
||||
burstNonce.value++
|
||||
try {
|
||||
await toggle(props.commentId, emoji)
|
||||
}
|
||||
catch {
|
||||
toast.error('Reaktion fehlgeschlagen')
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
pending.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="reactions.length" class="mt-3.5 flex flex-wrap items-center gap-2">
|
||||
<Tooltip v-for="r in reactions" :key="r.emoji">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1 transition-colors"
|
||||
:class="cn(
|
||||
r.me
|
||||
? 'border-foreground/40 bg-secondary hover:border-foreground/60'
|
||||
: 'border-border bg-muted/40 hover:border-foreground/30',
|
||||
pending === r.emoji && 'animate-pulse',
|
||||
)"
|
||||
@click="pick(r.emoji)"
|
||||
>
|
||||
<span v-if="burstEmoji === r.emoji" :key="burstNonce" class="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||
<i
|
||||
v-for="l in BURST_LINES"
|
||||
:key="l.angle"
|
||||
class="burst-line"
|
||||
:style="{ '--a': `${l.angle}deg`, '--h': l.long ? '9px' : '6px', '--d': l.long ? '-26px' : '-19px' }"
|
||||
/>
|
||||
</span>
|
||||
<span :key="burstNonce" class="text-xl leading-none" :class="burstEmoji === r.emoji && 'emoji-pop'">{{ r.emoji }}</span>
|
||||
<span class="text-sm font-medium tabular-nums" :class="r.me ? 'text-foreground' : 'text-muted-foreground'">{{ r.count }}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ r.names.join(', ') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.burst-line {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: var(--h);
|
||||
margin-left: -1.5px;
|
||||
margin-top: -3px;
|
||||
border-radius: 1.5px;
|
||||
background: var(--color-primary);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.emoji-pop {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.burst-line {
|
||||
animation: burst-line 0.55s cubic-bezier(0.15, 0.85, 0.35, 1) forwards;
|
||||
}
|
||||
.emoji-pop {
|
||||
animation: emoji-pop 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes burst-line {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: rotate(var(--a)) translateY(-4px) scaleY(0.3);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: rotate(var(--a)) translateY(calc(var(--d) * 0.7)) scaleY(1.15);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: rotate(var(--a)) translateY(var(--d)) scaleY(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emoji-pop {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(1.35); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import type { CommentAttachment } from '~~/shared/utils/attachments'
|
||||
|
||||
interface LatestComment {
|
||||
id: number
|
||||
content: string
|
||||
createdAt: string
|
||||
userName: string
|
||||
userId: number
|
||||
userColor: string | null
|
||||
userAvatarUrl: string | null
|
||||
uploadId: string
|
||||
attachments?: CommentAttachment[]
|
||||
}
|
||||
|
||||
const { data: comments } = await useFetch<LatestComment[]>('/api/comments/latest', {
|
||||
key: 'latest-comments',
|
||||
default: () => [],
|
||||
})
|
||||
const { liveComments } = useLiveComments()
|
||||
const timeAgo = useTimeAgo()
|
||||
|
||||
const mergedComments = computed(() => {
|
||||
const seen = new Set<number>()
|
||||
return [...liveComments.value, ...comments.value]
|
||||
.filter((c) => {
|
||||
if (seen.has(c.id)) return false
|
||||
seen.add(c.id)
|
||||
return true
|
||||
})
|
||||
.slice(0, 15)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar side="right" collapsible="none" class="sticky top-0 hidden h-svh shrink-0 overflow-hidden rounded-l-md border-l lg:flex">
|
||||
<SidebarContent class="px-2 py-2">
|
||||
<p v-if="!mergedComments.length" class="px-2 py-1 text-sm text-muted-foreground">
|
||||
Noch keine Kommentare.
|
||||
</p>
|
||||
<NuxtLink
|
||||
v-for="comment in mergedComments"
|
||||
:key="comment.id"
|
||||
:to="`/thread/${comment.uploadId}#comment-${comment.id}`"
|
||||
class="block rounded-md border border-sidebar-border/60 bg-sidebar px-3 py-2.5 text-sm transition-colors hover:border-sidebar-border hover:bg-sidebar-accent"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<NuxtImg
|
||||
v-if="comment.userAvatarUrl"
|
||||
:src="comment.userAvatarUrl"
|
||||
:alt="comment.userName"
|
||||
width="28"
|
||||
height="28"
|
||||
fit="cover"
|
||||
class="size-7 shrink-0 rounded-sm"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-sm text-xs font-medium"
|
||||
:style="comment.userColor ? { backgroundColor: comment.userColor, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
|
||||
>{{ comment.userName.slice(0, 2).toUpperCase() }}</span>
|
||||
<UserHoverCard
|
||||
:user-id="comment.userId"
|
||||
:user-name="comment.userName"
|
||||
:user-color="comment.userColor"
|
||||
:user-avatar-url="comment.userAvatarUrl"
|
||||
>
|
||||
<span
|
||||
class="truncate text-sm font-medium text-foreground hover:underline"
|
||||
:style="comment.userColor ? { color: comment.userColor } : undefined"
|
||||
@click.stop.prevent="navigateTo(`/user/${comment.userId}`)"
|
||||
>{{ comment.userName }}</span>
|
||||
</UserHoverCard>
|
||||
<span class="shrink-0">· {{ timeAgo(comment.createdAt) }}</span>
|
||||
</div>
|
||||
<HoverCard :open-delay="250" :close-delay="100">
|
||||
<HoverCardTrigger as-child>
|
||||
<div class="mt-1.5 line-clamp-3 break-words">
|
||||
<CommentContent :content="comment.content" compact /><CommentAttachments v-if="comment.attachments?.length" :attachments="comment.attachments" compact />
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="left" align="start" class="w-80 max-w-[90vw] text-sm">
|
||||
<div class="max-h-64 overflow-y-auto break-words">
|
||||
<CommentContent :content="comment.content" />
|
||||
<CommentAttachments v-if="comment.attachments?.length" :attachments="comment.attachments" />
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</NuxtLink>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts" generic="TData">
|
||||
import type { ColumnDef, Row } from '@tanstack/vue-table'
|
||||
import { FlexRender, useTable } from '@tanstack/vue-table'
|
||||
import { dataTableFeatures, type DataTableFeatures } from '@/lib/table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableEmpty,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: ColumnDef<DataTableFeatures, TData, unknown>[]
|
||||
data: TData[]
|
||||
// Suchfeld über den Global-Filter — searchFn entscheidet, was gematcht wird
|
||||
searchPlaceholder?: string
|
||||
searchFn?: (row: TData, query: string) => boolean
|
||||
pageSize?: number
|
||||
}>(), {
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
const searchFn = props.searchFn
|
||||
|
||||
const table = useTable<DataTableFeatures, TData>({
|
||||
features: dataTableFeatures,
|
||||
get columns() { return props.columns },
|
||||
get data() { return props.data },
|
||||
globalFilterFn: searchFn
|
||||
? (row: Row<DataTableFeatures, TData>, _columnId: string, filterValue: unknown) =>
|
||||
searchFn(row.original, String(filterValue))
|
||||
: 'auto',
|
||||
initialState: {
|
||||
pagination: { pageSize: props.pageSize },
|
||||
},
|
||||
})
|
||||
|
||||
const pageIndex = computed(() => table.atoms.pagination.get().pageIndex)
|
||||
const pageCount = computed(() => table.getPageCount())
|
||||
const globalFilter = computed(() => String(table.atoms.globalFilter.get() ?? ''))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="searchPlaceholder" class="relative max-w-xs">
|
||||
<Icon name="ph:magnifying-glass-fill" class="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
:model-value="globalFilter"
|
||||
:placeholder="searchPlaceholder"
|
||||
class="pl-8"
|
||||
@update:model-value="table.setGlobalFilter(String($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead v-for="header in headerGroup.headers" :key="header.id">
|
||||
<slot :name="`header-${header.column.id}`" :header="header" :column="header.column">
|
||||
<button
|
||||
v-if="header.column.getCanSort()"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 font-medium transition-colors hover:text-foreground"
|
||||
@click="header.column.toggleSorting()"
|
||||
>
|
||||
<FlexRender :header="header" />
|
||||
<Icon name="ph:arrow-up-fill" v-if="header.column.getIsSorted() === 'asc'" class="size-3.5" />
|
||||
<Icon name="ph:arrow-down-fill" v-else-if="header.column.getIsSorted() === 'desc'" class="size-3.5" />
|
||||
<Icon name="ph:arrows-down-up-fill" v-else class="size-3.5 opacity-50" />
|
||||
</button>
|
||||
<FlexRender v-else :header="header" />
|
||||
</slot>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows.length">
|
||||
<TableRow v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<TableCell v-for="cell in row.getAllCells()" :key="cell.id">
|
||||
<slot :name="`cell-${cell.column.id}`" :row="row" :cell="cell">
|
||||
<FlexRender :cell="cell" />
|
||||
</slot>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
<TableEmpty v-else :colspan="columns.length">
|
||||
Keine Einträge.
|
||||
</TableEmpty>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div v-if="pageCount > 1" class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Seite {{ pageIndex + 1 }} von {{ pageCount }}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!table.getCanPreviousPage()"
|
||||
@click="table.previousPage()"
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!table.getCanNextPage()"
|
||||
@click="table.nextPage()"
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, type CSSProperties } from 'vue';
|
||||
|
||||
interface DepthTextProps {
|
||||
text?: string;
|
||||
layers?: number;
|
||||
depth?: number;
|
||||
faceColor?: string;
|
||||
depthColor?: string;
|
||||
tilt?: number;
|
||||
pointerTracking?: boolean;
|
||||
smoothing?: number;
|
||||
perspective?: number;
|
||||
autoOrbit?: boolean;
|
||||
orbitSpeed?: number;
|
||||
fontSize?: string;
|
||||
fontWeight?: number | string;
|
||||
shadow?: boolean;
|
||||
}
|
||||
|
||||
interface DepthLayer {
|
||||
index: number;
|
||||
color: string;
|
||||
transform: string;
|
||||
}
|
||||
|
||||
const MAX_LAYERS = 64;
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max);
|
||||
|
||||
const getLayerColor = (faceColor: string, depthColor: string, index: number, total: number): string => {
|
||||
const progress = total <= 1 ? 1 : index / total;
|
||||
const eased = progress * progress;
|
||||
const faceMix = Math.round((1 - eased) * 72 + 4);
|
||||
return `color-mix(in srgb, ${faceColor} ${faceMix}%, ${depthColor})`;
|
||||
};
|
||||
|
||||
const getTransform = (rotateX: number, rotateY: number): string =>
|
||||
`rotateX(${rotateX.toFixed(3)}deg) rotateY(${rotateY.toFixed(3)}deg)`;
|
||||
|
||||
const props = withDefaults(defineProps<DepthTextProps>(), {
|
||||
text: 'Elevate',
|
||||
layers: 34,
|
||||
depth: 2.4,
|
||||
faceColor: '#f8fafc',
|
||||
depthColor: '#117b5a',
|
||||
tilt: 7.5,
|
||||
pointerTracking: true,
|
||||
smoothing: 0.14,
|
||||
perspective: 900,
|
||||
autoOrbit: true,
|
||||
orbitSpeed: 0.35,
|
||||
fontSize: 'clamp(3rem, 12vw, 7rem)',
|
||||
fontWeight: 900,
|
||||
shadow: true
|
||||
});
|
||||
|
||||
const rootRef = ref<HTMLSpanElement | null>(null);
|
||||
const stageRef = ref<HTMLSpanElement | null>(null);
|
||||
|
||||
const safeLayers = computed(() => clamp(Math.round(Number(props.layers) || 1), 2, MAX_LAYERS));
|
||||
const safeDepth = computed(() => clamp(Number(props.depth) || 0, 0, 12));
|
||||
const safeTilt = computed(() => clamp(Number(props.tilt) || 0, 0, 12));
|
||||
const safeSmoothing = computed(() => clamp(Number(props.smoothing) || 0.14, 0.02, 0.35));
|
||||
const safePerspective = computed(() => clamp(Number(props.perspective) || 900, 300, 2000));
|
||||
const safeOrbitSpeed = computed(() => clamp(Number(props.orbitSpeed) || 0, 0, 2));
|
||||
|
||||
const baseRotation = computed(() => ({ x: -safeTilt.value * 0.32, y: safeTilt.value * 0.42 }));
|
||||
|
||||
const depthLayers = computed<DepthLayer[]>(() =>
|
||||
Array.from({ length: safeLayers.value }, (_, layerIndex) => {
|
||||
const index = safeLayers.value - layerIndex;
|
||||
return {
|
||||
index,
|
||||
color: getLayerColor(props.faceColor, props.depthColor, index, safeLayers.value),
|
||||
transform: `translateZ(${-index * safeDepth.value}px)`
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const rootStyle = computed<CSSProperties>(() => ({
|
||||
perspective: `${safePerspective.value}px`,
|
||||
perspectiveOrigin: '50% 50%',
|
||||
contain: 'layout paint',
|
||||
isolation: 'isolate'
|
||||
}));
|
||||
|
||||
const stageStyle = computed<CSSProperties>(() => ({
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: getTransform(baseRotation.value.x, baseRotation.value.y),
|
||||
transformOrigin: '50% 50%',
|
||||
willChange: 'transform'
|
||||
}));
|
||||
|
||||
const textStyle = computed<CSSProperties>(() => ({
|
||||
fontSize: props.fontSize,
|
||||
fontWeight: props.fontWeight,
|
||||
lineHeight: 0.86,
|
||||
letterSpacing: '-0.065em',
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
transformStyle: 'preserve-3d',
|
||||
backfaceVisibility: 'hidden',
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision'
|
||||
}));
|
||||
|
||||
let stopMotion: (() => void) | null = null;
|
||||
|
||||
const setupMotion = () => {
|
||||
const root = rootRef.value;
|
||||
const stage = stageRef.value;
|
||||
if (!root || !stage || typeof window === 'undefined') return;
|
||||
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
const canTrackPointer = props.pointerTracking && finePointer && !reducedMotion;
|
||||
|
||||
if (reducedMotion) {
|
||||
stage.style.transform = getTransform(baseRotation.value.x, baseRotation.value.y);
|
||||
return;
|
||||
}
|
||||
|
||||
let frameId = 0;
|
||||
let activePointer = false;
|
||||
const startTime = performance.now();
|
||||
const current = { ...baseRotation.value };
|
||||
const target = { ...baseRotation.value };
|
||||
|
||||
const applyTransform = () => {
|
||||
stage.style.transform = getTransform(current.x, current.y);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const rect = root.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
|
||||
activePointer = true;
|
||||
const x = clamp((event.clientX - (rect.left + rect.width / 2)) / (rect.width * 0.8), -1, 1);
|
||||
const y = clamp((event.clientY - (rect.top + rect.height / 2)) / (rect.height * 0.8), -1, 1);
|
||||
|
||||
target.x = baseRotation.value.x - y * safeTilt.value;
|
||||
target.y = baseRotation.value.y + x * safeTilt.value;
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
activePointer = false;
|
||||
target.x = baseRotation.value.x;
|
||||
target.y = baseRotation.value.y;
|
||||
};
|
||||
|
||||
if (canTrackPointer) {
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerleave', handlePointerLeave);
|
||||
window.addEventListener('blur', handlePointerLeave);
|
||||
}
|
||||
|
||||
const tick = (now: number) => {
|
||||
if ((!canTrackPointer || !activePointer) && props.autoOrbit) {
|
||||
const elapsed = (now - startTime) / 1000;
|
||||
const orbit = elapsed * safeOrbitSpeed.value * Math.PI * 2;
|
||||
const fallbackAmount = canTrackPointer ? 0.18 : 0.55;
|
||||
target.x = baseRotation.value.x + Math.sin(orbit) * safeTilt.value * fallbackAmount;
|
||||
target.y = baseRotation.value.y + Math.cos(orbit * 0.85) * safeTilt.value * fallbackAmount;
|
||||
}
|
||||
|
||||
current.x += (target.x - current.x) * safeSmoothing.value;
|
||||
current.y += (target.y - current.y) * safeSmoothing.value;
|
||||
applyTransform();
|
||||
frameId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
applyTransform();
|
||||
frameId = requestAnimationFrame(tick);
|
||||
|
||||
stopMotion = () => {
|
||||
if (canTrackPointer) {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerleave', handlePointerLeave);
|
||||
window.removeEventListener('blur', handlePointerLeave);
|
||||
}
|
||||
cancelAnimationFrame(frameId);
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupMotion();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopMotion?.();
|
||||
stopMotion = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.autoOrbit, props.pointerTracking, safeOrbitSpeed.value, safeSmoothing.value, safeTilt.value],
|
||||
() => {
|
||||
stopMotion?.();
|
||||
stopMotion = null;
|
||||
setupMotion();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="rootRef" class="inline-block" :style="rootStyle">
|
||||
<span ref="stageRef" class="inline-grid relative place-items-center" :style="stageStyle">
|
||||
<span
|
||||
v-for="layer in depthLayers"
|
||||
:key="layer.index"
|
||||
aria-hidden="true"
|
||||
class="inline-block z-0 absolute inset-0 brightness-95 saturate-95 pointer-events-none transition-[transform,color] duration-300 ease-out"
|
||||
:style="{ ...textStyle, color: layer.color, transform: layer.transform }"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-block z-10 relative transition-[color] duration-300"
|
||||
:style="{
|
||||
...textStyle,
|
||||
color: faceColor,
|
||||
textShadow: shadow
|
||||
? `0 22px 34px color-mix(in srgb, ${depthColor} 36%, transparent), 0 4px 8px rgba(0, 0, 0, 0.28)`
|
||||
: 'none',
|
||||
transform: 'translateZ(0.6px)'
|
||||
}"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { useDropZone, useEventListener } from '@vueuse/core'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { isMediaFile, isMediaItemType } from '~~/shared/utils/media'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const model = defineModel<File | null>()
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
accept?: string
|
||||
hint?: string
|
||||
}>(), {
|
||||
accept: 'image/*,video/*,audio/*,.mp3,.flac',
|
||||
hint: 'Bilder, Videos & Audio, max. 200 MB',
|
||||
})
|
||||
|
||||
const dropzoneRef = ref<HTMLElement>()
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
const previewUrl = ref<string | null>(null)
|
||||
|
||||
const { isOverDropZone } = useDropZone(dropzoneRef, {
|
||||
dataTypes: types => types.some(isMediaItemType),
|
||||
onDrop(files) {
|
||||
if (props.disabled) return
|
||||
const file = files?.find(isMediaFile)
|
||||
if (file) select(file)
|
||||
else if (files?.length) toast.error('Nur Bilder, Videos und Audiodateien sind erlaubt')
|
||||
},
|
||||
})
|
||||
|
||||
// Drag-Events nicht bis zur seitenweiten Dropzone (document.body) durchreichen —
|
||||
// sonst würde ein Drop hier zusätzlich den globalen Upload auslösen
|
||||
useEventListener(dropzoneRef, ['dragenter', 'dragover', 'dragleave', 'drop'], e => e.stopPropagation())
|
||||
|
||||
function select(file: File | null) {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = file?.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
||||
model.value = file
|
||||
if (inputRef.value) inputRef.value.value = ''
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
if (!props.disabled) inputRef.value?.click()
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
return bytes >= 1024 * 1024
|
||||
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
: `${Math.max(1, Math.round(bytes / 1024))} KB`
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="dropzoneRef"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-disabled="disabled"
|
||||
class="flex min-h-32 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-4 text-center text-sm transition-colors"
|
||||
:class="[
|
||||
isOverDropZone ? 'border-primary bg-accent' : 'border-primary/40 hover:border-primary',
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
]"
|
||||
@click="openPicker"
|
||||
@keydown.enter.prevent="openPicker"
|
||||
@keydown.space.prevent="openPicker"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
class="hidden"
|
||||
@change="select(($event.target as HTMLInputElement).files?.[0] ?? null)"
|
||||
>
|
||||
|
||||
<template v-if="model">
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:alt="model.name"
|
||||
class="max-h-32 rounded-md"
|
||||
>
|
||||
<span class="max-w-full truncate font-medium">{{ model.name }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ formatSize(model.size) }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click.stop="select(null)"
|
||||
>
|
||||
<Icon name="ph:x-fill" class="size-4" />
|
||||
Entfernen
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Icon name="ph:cloud-arrow-up-fill" class="size-8 text-muted-foreground" />
|
||||
<span>Datei hierher ziehen oder klicken</span>
|
||||
<span class="text-xs text-muted-foreground">{{ hint }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { onKeyStroke } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const scale = ref(1)
|
||||
const offset = ref({ x: 0, y: 0 })
|
||||
const dragging = ref(false)
|
||||
let last = { x: 0, y: 0 }
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15
|
||||
scale.value = Math.min(10, Math.max(0.2, scale.value * factor))
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
dragging.value = true
|
||||
last = { x: event.clientX, y: event.clientY }
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
offset.value = {
|
||||
x: offset.value.x + event.clientX - last.x,
|
||||
y: offset.value.y + event.clientY - last.y,
|
||||
}
|
||||
last = { x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
scale.value = 1
|
||||
offset.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
onKeyStroke('Escape', () => emit('close'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-black/90"
|
||||
@wheel.prevent="onWheel"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
class="max-h-[95vh] max-w-[95vw] touch-none select-none"
|
||||
:class="dragging ? 'cursor-grabbing' : 'cursor-grab'"
|
||||
:style="{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})` }"
|
||||
@click.stop
|
||||
@dblclick="reset"
|
||||
@pointerdown.prevent="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 rounded-md bg-black/50 p-2 text-white hover:bg-black/70"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<Icon name="ph:x-fill" class="size-5" />
|
||||
<span class="sr-only">Schließen</span>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
}>()
|
||||
|
||||
const image = ref<string | null>(null)
|
||||
const title = ref<string | null>(null)
|
||||
|
||||
const hostname = computed(() => {
|
||||
try {
|
||||
return new URL(props.url).hostname.replace(/^www\./, '')
|
||||
}
|
||||
catch {
|
||||
return props.url
|
||||
}
|
||||
})
|
||||
|
||||
if (import.meta.client) {
|
||||
$fetch<{ image: string | null, title: string | null }>('/api/link-preview', {
|
||||
query: { url: props.url },
|
||||
})
|
||||
.then((res) => {
|
||||
image.value = res.image
|
||||
title.value = res.title
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-2 flex overflow-hidden rounded-md border transition-colors hover:border-accent"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex size-24 shrink-0 items-center justify-center overflow-hidden bg-muted">
|
||||
<img
|
||||
v-if="image"
|
||||
:src="image"
|
||||
:alt="title ?? hostname"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<Icon name="ph:link-fill" v-else class="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col justify-center gap-0.5 px-3 py-2">
|
||||
<p class="truncate font-medium">{{ title ?? hostname }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ url }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { activeMention } from '~~/shared/utils/mentions'
|
||||
import type { MentionUser } from '~~/shared/utils/mentions'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const props = defineProps<{ placeholder?: string }>()
|
||||
const { data: users } = useMentionUsers()
|
||||
const listId = useId()
|
||||
const caret = shallowRef(0)
|
||||
const focused = shallowRef(false)
|
||||
const dismissed = shallowRef(false)
|
||||
const selected = shallowRef(0)
|
||||
let textarea: HTMLTextAreaElement | null = null
|
||||
const mention = computed(() => activeMention(model.value, caret.value))
|
||||
const suggestions = computed(() => {
|
||||
if (!mention.value) return []
|
||||
const query = mention.value.query.toLowerCase()
|
||||
return users.value.filter(u => u.name.toLowerCase().startsWith(query)).slice(0, 8)
|
||||
})
|
||||
const open = computed(() => focused.value && !dismissed.value && suggestions.value.length > 0)
|
||||
|
||||
function updateCaret(event: Event) {
|
||||
textarea = event.target as HTMLTextAreaElement
|
||||
caret.value = textarea.selectionStart
|
||||
focused.value = true
|
||||
dismissed.value = false
|
||||
selected.value = 0
|
||||
}
|
||||
|
||||
async function choose(user: MentionUser) {
|
||||
if (!mention.value || !textarea) return
|
||||
const { start, end } = mention.value
|
||||
const suffix = model.value.slice(end).replace(/^[\p{L}\p{N}_-]*/u, '')
|
||||
const insertion = `@${user.name} `
|
||||
model.value = model.value.slice(0, start) + insertion + suffix
|
||||
dismissed.value = true
|
||||
await nextTick()
|
||||
textarea.focus({ preventScroll: true })
|
||||
textarea.setSelectionRange(start + insertion.length, start + insertion.length)
|
||||
caret.value = start + insertion.length
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!open.value || event.isComposing) return
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
selected.value = (selected.value + (event.key === 'ArrowDown' ? 1 : -1) + suggestions.value.length) % suggestions.value.length
|
||||
}
|
||||
else if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
const user = suggestions.value[selected.value]
|
||||
if (user) choose(user)
|
||||
}
|
||||
else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
dismissed.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover :open="open" @update:open="value => { if (!value) dismissed = true }">
|
||||
<PopoverAnchor as-child>
|
||||
<div class="relative">
|
||||
<Textarea
|
||||
v-model="model"
|
||||
:placeholder="props.placeholder"
|
||||
class="min-h-32 pb-9"
|
||||
aria-label="Kommentar"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="open ? listId : undefined"
|
||||
:aria-activedescendant="open ? `${listId}-${selected}` : undefined"
|
||||
@input="updateCaret"
|
||||
@click="updateCaret"
|
||||
@keyup="event => { if (!['ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(event.key)) updateCaret(event) }"
|
||||
@focus="updateCaret"
|
||||
@blur="focused = false"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
class="gap-1 rounded-md p-1"
|
||||
@open-auto-focus.prevent
|
||||
@close-auto-focus.prevent
|
||||
@interact-outside="event => { if (event.target === textarea) event.preventDefault() }"
|
||||
>
|
||||
<div :id="listId" role="listbox" aria-label="Benutzer erwähnen">
|
||||
<button
|
||||
v-for="(user, index) in suggestions"
|
||||
:id="`${listId}-${index}`"
|
||||
:key="user.id"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="index === selected"
|
||||
:class="cn('flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-left', index === selected && 'bg-accent text-accent-foreground')"
|
||||
@pointerdown.prevent
|
||||
@pointermove="selected = index"
|
||||
@click="choose(user)"
|
||||
>
|
||||
<Icon name="ph:user-fill" class="size-4 shrink-0" :style="user.color ? { color: user.color } : undefined" />
|
||||
<span class="truncate">@{{ user.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
const motd = useMotd()
|
||||
const { data } = await useFetch<{ message: string }>('/api/motd', { default: () => ({ message: '' }) })
|
||||
|
||||
// SSR-/Init-Wert in den State übernehmen — danach kommen Updates per WebSocket
|
||||
if (data.value.message) motd.value = data.value.message
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="motd" class="border-b px-4 py-2 lg:px-6">
|
||||
<p class="flex items-center gap-2 text-sm">
|
||||
<Icon name="ph:warning-octagon-fill" class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 whitespace-pre-wrap">{{ motd }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import type { AppNotification } from '@/composables/useNotifications'
|
||||
|
||||
const { notifications, unreadCount, markRead } = useNotifications()
|
||||
const open = ref(false)
|
||||
const timeAgo = useTimeAgo()
|
||||
|
||||
async function goTo(n: AppNotification) {
|
||||
open.value = false
|
||||
await markRead(n.id)
|
||||
await navigateTo(`/thread/${n.uploadId}#comment-${n.commentId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="relative" aria-label="Benachrichtigungen">
|
||||
<Icon name="ph:bell-fill" class="size-5" />
|
||||
<span
|
||||
v-if="unreadCount"
|
||||
class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold text-accent-foreground"
|
||||
>{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-80 gap-0 p-0">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<p v-if="!notifications.length" class="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Keine Benachrichtigungen.
|
||||
</p>
|
||||
<button
|
||||
v-for="n in notifications"
|
||||
:key="n.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 border-b px-4 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-accent/10"
|
||||
@click="goTo(n)"
|
||||
>
|
||||
<div class="size-12 shrink-0 overflow-hidden rounded-md bg-muted">
|
||||
<NuxtImg
|
||||
v-if="n.thumbUrl"
|
||||
:src="n.thumbUrl"
|
||||
:alt="n.actorName"
|
||||
width="96"
|
||||
height="96"
|
||||
fit="cover"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else-if="n.uploadType === 'video'"
|
||||
:src="n.url"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex h-full w-full items-center justify-center">
|
||||
<Icon name="ph:link-fill" class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span
|
||||
class="truncate font-medium"
|
||||
:style="n.actorColor ? { color: n.actorColor } : undefined"
|
||||
>{{ n.actorName }}</span>
|
||||
<span class="shrink-0 text-muted-foreground">{{ n.isMention ? 'hat dich erwähnt' : 'hat kommentiert' }}</span>
|
||||
<span class="ml-auto shrink-0 text-xs text-muted-foreground">{{ timeAgo(n.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 line-clamp-2 break-words text-muted-foreground">
|
||||
{{ n.content }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const { user, can, logout } = useAuth()
|
||||
const { uniform, toggle: toggleGridMode } = useGridMode()
|
||||
const { open: paletteOpen } = useCommandPalette()
|
||||
const route = useRoute()
|
||||
|
||||
async function onLogoClick(e: MouseEvent) {
|
||||
if (route.path !== '/') return
|
||||
e.preventDefault()
|
||||
await refreshNuxtData()
|
||||
}
|
||||
|
||||
const initials = computed(() => user.value?.name.slice(0, 2).toUpperCase() ?? '')
|
||||
|
||||
async function onLogout() {
|
||||
await logout()
|
||||
await navigateTo('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10 flex h-(--header-height) shrink-0 items-center gap-2 border-b bg-background">
|
||||
<div class="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="mr-2 font-logo text-[2.4rem] leading-none tracking-tight transition-colors hover:text-accent"
|
||||
@click="onLogoClick"
|
||||
>
|
||||
BRETT
|
||||
</NuxtLink>
|
||||
<UploadPopover />
|
||||
<Button variant="ghost" as-child>
|
||||
<NuxtLink to="/users" class="flex items-center gap-1.5">
|
||||
<Icon name="ph:users-fill" class="size-4" />
|
||||
Benutzer
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<Button v-if="can('admin.access')" variant="ghost" as-child>
|
||||
<NuxtLink to="/admin" class="flex items-center gap-1.5">
|
||||
<Icon name="ph:shield-fill" class="size-4" />
|
||||
Admin
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto flex h-9 cursor-pointer items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Schnellsuche öffnen (Strg+K)"
|
||||
@click="paletteOpen = true"
|
||||
>
|
||||
<Icon name="ph:magnifying-glass-fill" class="size-4" />
|
||||
<span class="hidden text-left sm:inline">Suchen…</span>
|
||||
<kbd class="hidden rounded-sm border bg-background px-1.5 text-xs sm:inline">Strg K</kbd>
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="toggleGridMode"
|
||||
>
|
||||
<Icon name="ph:grid-nine-fill" v-if="uniform" class="size-5" />
|
||||
<Icon name="ph:squares-four-fill" v-else class="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ uniform ? 'Bento-Layout' : 'Einheitliche Größe' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ThemeSelector />
|
||||
<template v-if="user">
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger class="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 outline-none hover:bg-accent">
|
||||
<Avatar class="size-8">
|
||||
<AvatarImage v-if="user.avatarUrl" :src="user.avatarUrl" :alt="user.name" />
|
||||
<AvatarFallback
|
||||
class="text-xs"
|
||||
:style="user.color ? { backgroundColor: user.color, color: '#fff' } : undefined"
|
||||
>
|
||||
{{ initials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:style="user.color ? { color: user.color } : undefined"
|
||||
>{{ user.name }}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-44">
|
||||
<DropdownMenuLabel class="truncate">
|
||||
{{ user.name }}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem as-child>
|
||||
<NuxtLink :to="`/user/${user.id}`" class="flex items-center gap-2">
|
||||
<Icon name="ph:user-fill" class="size-4" />
|
||||
Profil
|
||||
</NuxtLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem as-child>
|
||||
<NuxtLink to="/settings" class="flex items-center gap-2">
|
||||
<Icon name="ph:gear-fill" class="size-4" />
|
||||
Einstellungen
|
||||
</NuxtLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" class="flex items-center gap-2" @click="onLogout">
|
||||
<Icon name="ph:sign-out-fill" class="size-4" />
|
||||
Ausloggen
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="ghost" as-child>
|
||||
<NuxtLink to="/login">Login</NuxtLink>
|
||||
</Button>
|
||||
<Button as-child>
|
||||
<NuxtLink to="/register">Registrieren</NuxtLink>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
const mode = useTheme()
|
||||
|
||||
const themes = [
|
||||
{ value: 'standard', label: 'Standard' },
|
||||
{ value: 'discord', label: 'Discord' },
|
||||
{ value: 'cyberpunk', label: 'Cyberpunk' },
|
||||
{ value: 'spring', label: 'Spring' },
|
||||
{ value: 'terminal', label: 'Terminal' },
|
||||
{ value: 'infoseer', label: 'Infoseer' },
|
||||
{ value: 'homebrew', label: 'Homebrew' },
|
||||
{ value: 'florense', label: 'Florense' },
|
||||
{ value: 'kawi-green', label: 'Kawi Green' },
|
||||
{ value: 'clonz', label: 'Clo.nz' },
|
||||
{ value: 'clawmorphism', label: 'Clawmorphism' },
|
||||
{ value: 'realmorphism', label: 'Realmorphism' },
|
||||
{ value: 'amber', label: 'Amber' },
|
||||
{ value: 'neo', label: 'Neo' },
|
||||
{ value: 'neo-pink', label: 'Neo Pink' },
|
||||
{ value: 'neo-mint', label: 'Neo Mint' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-model="mode">
|
||||
<SelectTrigger class="h-9 w-[150px]">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="ph:palette-fill" class="size-4 text-muted-foreground" />
|
||||
<SelectValue placeholder="Theme" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="t in themes" :key="t.value" :value="t.value">
|
||||
{{ t.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const props = defineProps<{ uploadId: string, enabled: boolean }>()
|
||||
const emit = defineEmits<{ change: [uploadId: string, enabled: boolean] }>()
|
||||
const busy = shallowRef(false)
|
||||
const label = computed(() => props.enabled
|
||||
? 'Benachrichtigungen für diesen Thread deaktivieren'
|
||||
: 'Benachrichtigungen für diesen Thread aktivieren')
|
||||
|
||||
async function toggle() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
const uploadId = props.uploadId
|
||||
try {
|
||||
const result = await $fetch<{ enabled: boolean }>(`/api/uploads/${uploadId}/subscription`, {
|
||||
method: 'PUT',
|
||||
body: { enabled: !props.enabled },
|
||||
})
|
||||
emit('change', uploadId, result.enabled)
|
||||
toast.success(result.enabled ? 'Thread-Benachrichtigungen aktiviert' : 'Thread-Benachrichtigungen deaktiviert')
|
||||
}
|
||||
catch {
|
||||
toast.error('Abonnement konnte nicht geändert werden')
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
:variant="enabled ? 'secondary' : 'ghost'"
|
||||
size="icon"
|
||||
:disabled="busy"
|
||||
:aria-label="label"
|
||||
:aria-pressed="enabled"
|
||||
@click="toggle"
|
||||
>
|
||||
<Icon :name="enabled ? 'ph:bell-fill' : 'ph:bell-slash-fill'" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ListedUser {
|
||||
id: number
|
||||
name: string
|
||||
color: string | null
|
||||
avatarUrl: string | null
|
||||
stats?: { uploads: number }
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'image', label: 'Bilder', icon: 'ph:image-fill' },
|
||||
{ value: 'video', label: 'Videos', icon: 'ph:video-camera-fill' },
|
||||
{ value: 'audio', label: 'Audio', icon: 'ph:music-note-fill' },
|
||||
{ value: 'youtube', label: 'YouTube', icon: 'simple-icons:youtube' },
|
||||
{ value: 'twitch', label: 'Twitch', icon: 'simple-icons:twitch' },
|
||||
{ value: 'link', label: 'Links', icon: 'ph:link-fill' },
|
||||
] as const
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: '1', label: 'Heute' },
|
||||
{ value: '7', label: '7 Tage' },
|
||||
{ value: '30', label: '30 Tage' },
|
||||
{ value: '365', label: 'Letztes Jahr' },
|
||||
] as const
|
||||
|
||||
const open = ref(false)
|
||||
const uploaderOpen = ref(false)
|
||||
const users = ref<ListedUser[]>([])
|
||||
const usersLoaded = ref(false)
|
||||
|
||||
async function loadUsers() {
|
||||
if (usersLoaded.value) return
|
||||
usersLoaded.value = true
|
||||
try {
|
||||
users.value = await $fetch<ListedUser[]>('/api/users')
|
||||
}
|
||||
catch {
|
||||
users.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, v => v && loadUsers())
|
||||
onMounted(() => {
|
||||
if (route.query.uploader) loadUsers()
|
||||
})
|
||||
|
||||
// Für die Uploader-Auswahl nur Nutzer mit Uploads anzeigen
|
||||
const uploaders = computed(() => users.value.filter(u => !u.stats || u.stats.uploads > 0))
|
||||
|
||||
const tagFilter = computed(() => typeof route.query.tag === 'string' ? route.query.tag : undefined)
|
||||
const selectedTypes = computed<string[]>({
|
||||
get: () => typeof route.query.type === 'string' ? route.query.type.split(',').filter(Boolean) : [],
|
||||
set: v => setQuery({ type: v.length ? v.join(',') : undefined }),
|
||||
})
|
||||
const uploaderId = computed(() => typeof route.query.uploader === 'string' ? route.query.uploader : '')
|
||||
const selectedUser = computed(() => users.value.find(u => String(u.id) === uploaderId.value))
|
||||
const daysModel = computed<string>({
|
||||
get: () => typeof route.query.days === 'string' && route.query.days ? route.query.days : 'all',
|
||||
set: v => setQuery({ days: v === 'all' ? undefined : v }),
|
||||
})
|
||||
const daysOption = computed(() => DAY_OPTIONS.find(d => d.value === daysModel.value))
|
||||
|
||||
const typeLabel = (value: string) => TYPE_OPTIONS.find(t => t.value === value)?.label ?? value
|
||||
const typeIcon = (value: string) => TYPE_OPTIONS.find(t => t.value === value)?.icon ?? 'ph:image-fill'
|
||||
|
||||
const activeCount = computed(() =>
|
||||
(tagFilter.value ? 1 : 0) + selectedTypes.value.length + (uploaderId.value ? 1 : 0) + (daysOption.value ? 1 : 0),
|
||||
)
|
||||
|
||||
function setQuery(patch: Record<string, string | undefined>) {
|
||||
const query = { ...route.query }
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value) query[key] = value
|
||||
else delete query[key]
|
||||
}
|
||||
router.replace({ query })
|
||||
}
|
||||
|
||||
function selectUser(u: ListedUser | null) {
|
||||
setQuery({ uploader: u ? String(u.id) : undefined })
|
||||
uploaderOpen.value = false
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setQuery({ tag: undefined, type: undefined, uploader: undefined, days: undefined })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="gap-1.5">
|
||||
<Icon name="ph:sliders-horizontal-fill" class="size-4" />
|
||||
Filter
|
||||
<Badge v-if="activeCount" variant="secondary" class="ml-0.5 px-1.5 text-xs">{{ activeCount }}</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-72" align="start">
|
||||
<FieldGroup class="gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Typ</FieldLabel>
|
||||
<ToggleGroup v-model="selectedTypes" type="multiple" variant="outline" :spacing="2" class="flex-wrap justify-start">
|
||||
<ToggleGroupItem
|
||||
v-for="t in TYPE_OPTIONS"
|
||||
:key="t.value"
|
||||
:value="t.value"
|
||||
:aria-label="t.label"
|
||||
class="gap-1.5 rounded-md px-2.5"
|
||||
>
|
||||
<Icon :name="t.icon" class="size-4" />
|
||||
{{ t.label }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Uploader</FieldLabel>
|
||||
<Popover v-model:open="uploaderOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" role="combobox" :aria-expanded="uploaderOpen" class="w-full justify-between font-normal">
|
||||
<span class="flex items-center gap-1.5 truncate">
|
||||
<Icon name="ph:user-fill" class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span :style="selectedUser?.color ? { color: selectedUser.color } : undefined" class="truncate">
|
||||
{{ selectedUser?.name ?? 'Alle Uploader' }}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="ph:caret-up-down-fill" class="size-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-64 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Nutzer suchen…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Kein Nutzer gefunden.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem value="alle" class="gap-2" @select="selectUser(null)">
|
||||
<Icon name="ph:check-fill" :class="cn('size-4', !uploaderId ? 'opacity-100' : 'opacity-0')" />
|
||||
Alle Uploader
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
v-for="u in uploaders"
|
||||
:key="u.id"
|
||||
:value="u.name"
|
||||
class="gap-2"
|
||||
@select="selectUser(u)"
|
||||
>
|
||||
<Icon name="ph:check-fill" :class="cn('size-4', String(u.id) === uploaderId ? 'opacity-100' : 'opacity-0')" />
|
||||
<NuxtImg
|
||||
v-if="u.avatarUrl"
|
||||
:src="u.avatarUrl"
|
||||
:alt="u.name"
|
||||
width="20"
|
||||
height="20"
|
||||
fit="cover"
|
||||
class="size-5 rounded-sm"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-5 items-center justify-center rounded-sm text-[10px] font-medium"
|
||||
:style="u.color ? { backgroundColor: u.color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
|
||||
>{{ u.name.slice(0, 2).toUpperCase() }}</span>
|
||||
<span :style="u.color ? { color: u.color } : undefined">{{ u.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Zeitraum</FieldLabel>
|
||||
<Select v-model="daysModel">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Alle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Gesamter Zeitraum</SelectItem>
|
||||
<SelectItem v-for="d in DAY_OPTIONS" :key="d.value" :value="d.value">{{ d.label }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button variant="ghost" size="sm" :disabled="!activeCount" class="self-start" @click="clearFilters">
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<span v-if="tagFilter" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
|
||||
<Icon name="ph:tag-fill" class="size-3.5" />
|
||||
{{ tagFilter }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Tag-Filter entfernen"
|
||||
@click="setQuery({ tag: undefined })"
|
||||
>
|
||||
<Icon name="ph:x-fill" class="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
<span v-for="t in selectedTypes" :key="t" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
|
||||
<Icon :name="typeIcon(t)" class="size-3.5" />
|
||||
{{ typeLabel(t) }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground transition-colors hover:text-foreground"
|
||||
:aria-label="`Typ-Filter ${typeLabel(t)} entfernen`"
|
||||
@click="selectedTypes = selectedTypes.filter(v => v !== t)"
|
||||
>
|
||||
<Icon name="ph:x-fill" class="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="uploaderId" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
|
||||
<Icon name="ph:user-fill" class="size-3.5" />
|
||||
{{ selectedUser?.name ?? '…' }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Uploader-Filter entfernen"
|
||||
@click="setQuery({ uploader: undefined })"
|
||||
>
|
||||
<Icon name="ph:x-fill" class="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="daysOption" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
|
||||
<Icon name="ph:calendar-fill" class="size-3.5" />
|
||||
{{ daysOption.label }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Zeitraum-Filter entfernen"
|
||||
@click="setQuery({ days: undefined })"
|
||||
>
|
||||
<Icon name="ph:x-fill" class="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import FileDropzone from '@/components/FileDropzone.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
const { user } = useAuth()
|
||||
const open = ref(false)
|
||||
const file = ref<File | null>(null)
|
||||
const url = ref('')
|
||||
const errorMessage = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function onSubmit() {
|
||||
if (loading.value || (!file.value && !url.value.trim())) return
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
if (url.value.trim()) {
|
||||
await $fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: { url: url.value.trim() },
|
||||
})
|
||||
}
|
||||
else {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.value!)
|
||||
await $fetch('/api/uploads', { method: 'POST', body: formData })
|
||||
}
|
||||
file.value = null
|
||||
url.value = ''
|
||||
open.value = false
|
||||
await refreshNuxtData('uploads')
|
||||
await navigateTo('/')
|
||||
}
|
||||
catch (err) {
|
||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
||||
errorMessage.value = e.data?.statusMessage ?? e.data?.message ?? 'Upload fehlgeschlagen'
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost">
|
||||
<Icon name="ph:cloud-arrow-up-fill" class="size-4" />
|
||||
Hochladen
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-160">
|
||||
<template v-if="user">
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Hochladen</PopoverTitle>
|
||||
<PopoverDescription>Datei oder URL — Bilder, Videos, Audio (MP3/FLAC), YouTube- und Twitch-Links.</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div class="space-y-1.5">
|
||||
<Label>Datei</Label>
|
||||
<FileDropzone v-model="file" :disabled="!!url.trim()" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="upload-url">oder URL</Label>
|
||||
<Input id="upload-url" v-model="url" type="url" placeholder="https://…" :disabled="!!file" />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Direkte Bild-/Video-URLs werden gespeichert, YouTube- und Twitch-Links eingebettet.
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="text-sm text-destructive">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<Button type="submit" class="w-full" :disabled="loading || (!file && !url.trim())">
|
||||
{{ loading ? 'Lädt hoch…' : 'Hochladen' }}
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
<NuxtLink to="/login" class="text-primary underline" @click="open = false">
|
||||
Melde dich an
|
||||
</NuxtLink>, um Dateien hochzuladen.
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const props = defineProps<{ uploadId: string }>()
|
||||
const emit = defineEmits<{ subscriptionChange: [uploadId: string, enabled: boolean] }>()
|
||||
|
||||
const { user } = useAuth()
|
||||
const { reactionsFor, toggle } = useUploadReactions()
|
||||
|
||||
const busy = ref(false)
|
||||
const pending = ref<string | null>(null)
|
||||
const reactions = computed(() => reactionsFor(props.uploadId))
|
||||
|
||||
const BURST_LINES = Array.from({ length: 12 }, (_, i) => ({
|
||||
angle: i * 30,
|
||||
long: i % 2 === 0,
|
||||
}))
|
||||
const burstEmoji = ref<string | null>(null)
|
||||
const burstNonce = ref(0)
|
||||
|
||||
async function pick(emoji: string) {
|
||||
if (!user.value) {
|
||||
toast.error('Melde dich an, um zu reagieren.')
|
||||
return
|
||||
}
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
pending.value = emoji
|
||||
burstEmoji.value = emoji
|
||||
burstNonce.value++
|
||||
try {
|
||||
const uploadId = props.uploadId
|
||||
const result = await toggle(uploadId, emoji)
|
||||
emit('subscriptionChange', uploadId, result.isSubscribed)
|
||||
}
|
||||
catch {
|
||||
toast.error('Reaktion fehlgeschlagen')
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
pending.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="reactions.length" class="flex flex-wrap items-center gap-2">
|
||||
<Tooltip v-for="r in reactions" :key="r.emoji">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1 transition-colors"
|
||||
:class="cn(
|
||||
r.me
|
||||
? 'border-foreground/40 bg-secondary hover:border-foreground/60'
|
||||
: 'border-border bg-muted/40 hover:border-foreground/30',
|
||||
pending === r.emoji && 'animate-pulse',
|
||||
)"
|
||||
@click="pick(r.emoji)"
|
||||
>
|
||||
<span v-if="burstEmoji === r.emoji" :key="burstNonce" class="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||
<i
|
||||
v-for="l in BURST_LINES"
|
||||
:key="l.angle"
|
||||
class="burst-line"
|
||||
:style="{ '--a': `${l.angle}deg`, '--h': l.long ? '9px' : '6px', '--d': l.long ? '-26px' : '-19px' }"
|
||||
/>
|
||||
</span>
|
||||
<span :key="burstNonce" class="text-xl leading-none" :class="burstEmoji === r.emoji && 'emoji-pop'">{{ r.emoji }}</span>
|
||||
<span class="text-sm font-medium tabular-nums" :class="r.me ? 'text-foreground' : 'text-muted-foreground'">{{ r.count }}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ r.names.join(', ') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.burst-line {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: var(--h);
|
||||
margin-left: -1.5px;
|
||||
margin-top: -3px;
|
||||
border-radius: 1.5px;
|
||||
background: var(--color-primary);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.emoji-pop {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.burst-line {
|
||||
animation: burst-line 0.55s cubic-bezier(0.15, 0.85, 0.35, 1) forwards;
|
||||
}
|
||||
.emoji-pop {
|
||||
animation: emoji-pop 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes burst-line {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: rotate(var(--a)) translateY(-4px) scaleY(0.3);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: rotate(var(--a)) translateY(calc(var(--d) * 0.7)) scaleY(1.15);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: rotate(var(--a)) translateY(var(--d)) scaleY(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emoji-pop {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(1.35); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
interface TileUpload {
|
||||
id: string
|
||||
type: 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link'
|
||||
url: string
|
||||
thumbUrl?: string | null
|
||||
userId?: number
|
||||
userName?: string
|
||||
userColor?: string | null
|
||||
userAvatarUrl?: string | null
|
||||
commentCount?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{ upload: TileUpload }>()
|
||||
|
||||
const { unreadUploadIds } = useNotifications()
|
||||
const { uniform } = useGridMode()
|
||||
const lastViewedId = useLastViewedUpload()
|
||||
const pendingMedia = usePendingUploadMedia()
|
||||
const hasNotification = computed(() => unreadUploadIds.value.has(props.upload.id))
|
||||
|
||||
// LQIP: winzige geblurrede Variante sofort, echtes Thumb fadet nach.
|
||||
// Nur für Quellen, die IPX verarbeiten kann (lokal oder gewhitelistete Domains)
|
||||
const thumbSrc = computed(() => props.upload.thumbUrl ?? (props.upload.type === 'image' ? props.upload.url : null))
|
||||
const canPlaceholder = computed(() => {
|
||||
const s = thumbSrc.value
|
||||
return !!s && (s.startsWith('/') || s.startsWith('https://i.ytimg.com/') || s.startsWith('https://static-cdn.jtvnw.net/'))
|
||||
})
|
||||
|
||||
const indicator = useLoadingIndicator()
|
||||
const router = useRouter()
|
||||
const navigating = ref(false)
|
||||
|
||||
const spanClass = computed(() => {
|
||||
if (uniform.value) return ''
|
||||
const count = props.upload.commentCount ?? 0
|
||||
if (count >= 7) return 'col-span-2 row-span-2 sm:col-span-3 sm:row-span-3'
|
||||
if (count >= 1) return 'col-span-2 row-span-2'
|
||||
return ''
|
||||
})
|
||||
|
||||
// Klick auf ein Thumbnail:
|
||||
// 1. Ladeindikator oben sofort starten
|
||||
// 2. Thread-Daten + Bild/Video vorladen (min. 600 ms, damit der Indikator sichtbar läuft)
|
||||
// 3. Wenn geladen: viewTransitionName setzen und via startViewTransition navigieren
|
||||
async function openUpload(e: MouseEvent) {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
e.preventDefault()
|
||||
if (navigating.value) return
|
||||
navigating.value = true
|
||||
|
||||
indicator.start()
|
||||
|
||||
// Der angeklickte Tile bekommt sofort den view-transition-name — der NuxtLink
|
||||
// navigiert eigenständig, also muss der Name gesetzt sein, bevor der Browser
|
||||
// den alten Zustand für die Transition fotografiert
|
||||
lastViewedId.value = props.upload.id
|
||||
// Bekannte Mediendaten, damit die Thread-Seite sofort ein benanntes
|
||||
// Element rendern kann (View-Transition-Ziel vor Eintreffen der Daten)
|
||||
pendingMedia.value = {
|
||||
id: props.upload.id,
|
||||
type: props.upload.type,
|
||||
url: props.upload.url,
|
||||
thumbUrl: props.upload.thumbUrl ?? null,
|
||||
}
|
||||
|
||||
try {
|
||||
const threadFetch = $fetch(`/api/uploads/${props.upload.id}`)
|
||||
const mediaPreload = (async () => {
|
||||
if (props.upload.type === 'image' && props.upload.url) {
|
||||
const img = new Image()
|
||||
img.src = props.upload.url
|
||||
try {
|
||||
await Promise.race([img.decode(), new Promise(r => setTimeout(r, 2500))])
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
else if (props.upload.type === 'video' && props.upload.url) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const v = document.createElement('video')
|
||||
v.preload = 'auto'
|
||||
v.muted = true
|
||||
v.onloadeddata = () => resolve()
|
||||
v.onerror = () => resolve()
|
||||
v.src = props.upload.url
|
||||
setTimeout(resolve, 2500)
|
||||
})
|
||||
}
|
||||
})()
|
||||
const minDelay = new Promise(r => setTimeout(r, 600))
|
||||
|
||||
await Promise.all([threadFetch, mediaPreload, minDelay])
|
||||
}
|
||||
catch {
|
||||
indicator.finish({ error: true })
|
||||
navigating.value = false
|
||||
toast.error('Thread konnte nicht geladen werden')
|
||||
return
|
||||
}
|
||||
|
||||
// Die eigentliche Navigation macht der NuxtLink bereits beim Klick —
|
||||
// dieser Push ist nur noch Fallback und bei gleicher Route ein No-Op
|
||||
await router.push(`/thread/${props.upload.id}`)
|
||||
navigating.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="`/thread/${upload.id}`"
|
||||
class="upload-tile group block"
|
||||
:class="[spanClass, navigating ? 'opacity-80' : '']"
|
||||
@click="openUpload"
|
||||
>
|
||||
<div
|
||||
class="upload-tile-media relative h-full w-full overflow-hidden rounded-md"
|
||||
:style="lastViewedId === upload.id ? 'view-transition-name: upload-media' : undefined"
|
||||
>
|
||||
<NuxtImg
|
||||
v-if="thumbSrc"
|
||||
:src="thumbSrc"
|
||||
:alt="upload.userName ?? 'Upload'"
|
||||
width="400"
|
||||
height="400"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
:placeholder="canPlaceholder || undefined"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<video
|
||||
v-else-if="upload.type === 'video'"
|
||||
:src="upload.url"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex h-full w-full items-center justify-center bg-muted">
|
||||
<Icon name="ph:music-note-fill" v-if="upload.type === 'audio'" class="size-8 text-muted-foreground" />
|
||||
<Icon name="ph:link-fill" v-else class="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<span
|
||||
v-if="hasNotification"
|
||||
class="absolute top-1.5 right-1.5 z-10 flex size-6 items-center justify-center rounded-full bg-accent text-accent-foreground shadow"
|
||||
>
|
||||
<Icon name="ph:bell-fill" class="size-3.5" />
|
||||
</span>
|
||||
<div class="absolute inset-0 bg-primary/30 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
<div class="absolute inset-0 flex items-end bg-gradient-to-t from-black/70 via-black/20 to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div class="flex w-full items-end justify-between gap-2 text-xs text-white">
|
||||
<UserHoverCard
|
||||
v-if="upload.userId != null"
|
||||
:user-id="upload.userId"
|
||||
:user-name="upload.userName ?? ''"
|
||||
:user-color="upload.userColor"
|
||||
:user-avatar-url="upload.userAvatarUrl"
|
||||
class="min-w-0"
|
||||
>
|
||||
<span class="truncate">{{ upload.userName }}</span>
|
||||
</UserHoverCard>
|
||||
<span v-else class="truncate">{{ upload.userName }}</span>
|
||||
<span v-if="upload.commentCount !== undefined" class="flex shrink-0 items-center gap-1">
|
||||
<Icon name="ph:chat-fill" class="size-3.5" />
|
||||
{{ upload.commentCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import type { CardBorderEffectKey } from '~~/shared/utils/cardBorderEffects'
|
||||
import { isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
|
||||
import type { CardEffectKey } from '~~/shared/utils/cardEffects'
|
||||
import { isCardEffect } from '~~/shared/utils/cardEffects'
|
||||
import CardBackgroundEffect from '@/components/effects/CardBackgroundEffect.vue'
|
||||
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
|
||||
import CardVignette from '@/components/effects/CardVignette.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
userId: number
|
||||
userName: string
|
||||
userColor?: string | null
|
||||
userAvatarUrl?: string | null
|
||||
}>()
|
||||
|
||||
const { summaries, ensureSummary } = useUserSummaries()
|
||||
const summary = computed(() => summaries.value[props.userId])
|
||||
const avatar = computed(() => summary.value?.avatarUrl ?? props.userAvatarUrl ?? null)
|
||||
const color = computed(() => summary.value?.color ?? props.userColor ?? null)
|
||||
const effect = computed<CardEffectKey | null>(() => {
|
||||
const key = summary.value?.cardEffect
|
||||
return key && isCardEffect(key) ? key : null
|
||||
})
|
||||
const borderEffect = computed<CardBorderEffectKey | null>(() => {
|
||||
const key = summary.value?.cardBorderEffect
|
||||
return key && isCardBorderEffect(key) ? key : null
|
||||
})
|
||||
|
||||
function onOpen(open: boolean) {
|
||||
if (open) ensureSummary(props.userId)
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCard :open-delay="200" :close-delay="150" @update:open="onOpen">
|
||||
<HoverCardTrigger as-child>
|
||||
<slot />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="relative w-72 overflow-hidden rounded-md">
|
||||
<div v-if="effect" class="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [clip-path:inset(0_round_var(--radius-md))]">
|
||||
<CardBackgroundEffect :effect="effect" :color="summary?.cardEffectColor ?? color ?? '#6366f1'" />
|
||||
<div class="absolute inset-0 bg-popover/60" />
|
||||
<CardVignette v-if="summary?.cardVignette" />
|
||||
</div>
|
||||
<div class="relative flex items-start gap-3">
|
||||
<NuxtImg
|
||||
v-if="avatar"
|
||||
:src="avatar"
|
||||
:alt="userName"
|
||||
width="56"
|
||||
height="56"
|
||||
fit="cover"
|
||||
class="size-14 shrink-0 rounded-md"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-14 shrink-0 items-center justify-center rounded-md text-lg font-medium"
|
||||
:style="color ? { backgroundColor: color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
|
||||
>{{ userName.slice(0, 2).toUpperCase() }}</span>
|
||||
<div class="min-w-0">
|
||||
<NuxtLink
|
||||
:to="`/user/${userId}`"
|
||||
class="block truncate text-base font-medium hover:underline"
|
||||
:style="color ? { color } : undefined"
|
||||
>{{ summary?.name ?? userName }}</NuxtLink>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
#{{ userId }}
|
||||
</p>
|
||||
<p v-if="summary" class="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Icon name="ph:calendar-fill" class="size-3" />
|
||||
seit {{ formatDate(summary.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="summary" class="relative mt-3 flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="ph:cloud-arrow-up-fill" class="size-3.5" />
|
||||
{{ summary.stats.uploads }} Uploads
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon name="ph:chat-fill" class="size-3.5" />
|
||||
{{ summary.stats.comments }} Kommentare
|
||||
</span>
|
||||
</div>
|
||||
<CardBorderEffect v-if="borderEffect" :effect="borderEffect" :color="summary?.cardBorderEffectColor ?? color ?? '#6366f1'" />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import type Plyr from 'plyr'
|
||||
import 'plyr/dist/plyr.css'
|
||||
|
||||
defineProps<{
|
||||
src: string
|
||||
}>()
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const aspect = ref<number | null>(null)
|
||||
let player: Plyr | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
if (!videoEl.value) return
|
||||
const { default: PlyrClass } = await import('plyr')
|
||||
player = new PlyrClass(videoEl.value, {
|
||||
disableContextMenu: false,
|
||||
storage: { enabled: true, key: '4brett-player' },
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
player?.destroy()
|
||||
player = null
|
||||
})
|
||||
|
||||
function onLoadedMetadata() {
|
||||
const v = videoEl.value
|
||||
if (v?.videoWidth && v.videoHeight) {
|
||||
aspect.value = v.videoWidth / v.videoHeight
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="video-player mx-auto w-full max-w-4xl"
|
||||
:style="aspect ? { maxWidth: `min(100%, 56rem, calc(80vh * ${aspect}))` } : undefined"
|
||||
>
|
||||
<video
|
||||
ref="videoEl"
|
||||
:src="src"
|
||||
playsinline
|
||||
controls
|
||||
class="rounded-md"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.video-player {
|
||||
--plyr-color-main: var(--accent);
|
||||
--plyr-border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { useMediaControls } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
src: string
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const { playing, currentTime, duration, volume, muted } = useMediaControls(audioRef)
|
||||
|
||||
function toggle() {
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function seek(event: Event) {
|
||||
currentTime.value = Number((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function formatTime(sec: number) {
|
||||
if (!Number.isFinite(sec)) return '0:00'
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60).toString().padStart(2, '0')
|
||||
return `${m}:${s}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-md items-center gap-2.5 rounded-md border bg-muted/50 px-3 py-2"
|
||||
@click.stop
|
||||
>
|
||||
<audio ref="audioRef" :src="src" preload="metadata" class="hidden" />
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||
:aria-label="playing ? 'Pause' : 'Abspielen'"
|
||||
@click="toggle"
|
||||
>
|
||||
<Icon name="ph:pause-fill" v-if="playing" class="size-4" />
|
||||
<Icon name="ph:play-fill" v-else class="size-4" />
|
||||
</button>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(currentTime) }}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="Number.isFinite(duration) ? duration : 0"
|
||||
step="0.1"
|
||||
:value="currentTime"
|
||||
aria-label="Wiedergabeposition"
|
||||
class="min-w-0 flex-1 cursor-pointer accent-primary"
|
||||
@input="seek"
|
||||
>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(duration) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||
:aria-label="muted ? 'Ton an' : 'Stumm'"
|
||||
@click="muted = !muted"
|
||||
>
|
||||
<Icon name="ph:speaker-x-fill" v-if="muted" class="size-3.5" />
|
||||
<Icon name="ph:speaker-high-fill" v-else class="size-3.5" />
|
||||
</button>
|
||||
<input
|
||||
v-model.number="volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
aria-label="Lautstärke"
|
||||
class="w-14 min-w-0 shrink-0 cursor-pointer accent-primary"
|
||||
:class="muted && 'opacity-40'"
|
||||
>
|
||||
<a
|
||||
:href="src"
|
||||
:download="name"
|
||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="Herunterladen"
|
||||
title="Herunterladen"
|
||||
>
|
||||
<Icon name="ph:download-fill" class="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { CardEffectKey } from '~~/shared/utils/cardEffects'
|
||||
import EffectAcidSquares from '@/components/effects/EffectAcidSquares.vue'
|
||||
import EffectAurora from '@/components/effects/EffectAurora.vue'
|
||||
import EffectCrtWarp from '@/components/effects/EffectCrtWarp.vue'
|
||||
import EffectDither from '@/components/effects/EffectDither.vue'
|
||||
import EffectGradientWaves from '@/components/effects/EffectGradientWaves.vue'
|
||||
import EffectLetterGlitch from '@/components/effects/EffectLetterGlitch.vue'
|
||||
import EffectLineWaves from '@/components/effects/EffectLineWaves.vue'
|
||||
import EffectParticles from '@/components/effects/EffectParticles.vue'
|
||||
import EffectPlasma from '@/components/effects/EffectPlasma.vue'
|
||||
import EffectScanner from '@/components/effects/EffectScanner.vue'
|
||||
import EffectShapeGrid from '@/components/effects/EffectShapeGrid.vue'
|
||||
import EffectSilk from '@/components/effects/EffectSilk.vue'
|
||||
import EffectSoftAurora from '@/components/effects/EffectSoftAurora.vue'
|
||||
import EffectWaves from '@/components/effects/EffectWaves.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
effect: CardEffectKey
|
||||
color: string
|
||||
}>()
|
||||
|
||||
const COMPONENTS = {
|
||||
'gradient-waves': EffectGradientWaves,
|
||||
'aurora': EffectAurora,
|
||||
'particles': EffectParticles,
|
||||
'letter-glitch': EffectLetterGlitch,
|
||||
'waves': EffectWaves,
|
||||
'scanner': EffectScanner,
|
||||
'acid-squares': EffectAcidSquares,
|
||||
'crt-warp': EffectCrtWarp,
|
||||
'dither': EffectDither,
|
||||
'soft-aurora': EffectSoftAurora,
|
||||
'silk': EffectSilk,
|
||||
'shape-grid': EffectShapeGrid,
|
||||
'plasma': EffectPlasma,
|
||||
'line-waves': EffectLineWaves,
|
||||
} as const
|
||||
|
||||
const component = computed(() => COMPONENTS[props.effect] ?? null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="component" v-if="component" :color="color" />
|
||||
</template>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CardBorderEffectKey } from '~~/shared/utils/cardBorderEffects'
|
||||
import { hexToRgbTuple } from '@/lib/color'
|
||||
|
||||
// Electric-Variante portiert aus Vue Bits (DavidHDev/vue-bits, ElectricBorder)
|
||||
const props = withDefaults(defineProps<{
|
||||
effect: CardBorderEffectKey
|
||||
color?: string
|
||||
}>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
|
||||
function random(x: number): number {
|
||||
return (Math.sin(x * 12.9898) * 43758.5453) % 1
|
||||
}
|
||||
|
||||
function noise2D(x: number, y: number): number {
|
||||
const i = Math.floor(x)
|
||||
const j = Math.floor(y)
|
||||
const fx = x - i
|
||||
const fy = y - j
|
||||
const a = random(i + j * 57)
|
||||
const b = random(i + 1 + j * 57)
|
||||
const c = random(i + (j + 1) * 57)
|
||||
const d = random(i + 1 + (j + 1) * 57)
|
||||
const ux = fx * fx * (3 - 2 * fx)
|
||||
const uy = fy * fy * (3 - 2 * fy)
|
||||
return a * (1 - ux) * (1 - uy) + b * ux * (1 - uy) + c * (1 - ux) * uy + d * ux * uy
|
||||
}
|
||||
|
||||
function octavedNoise(x: number, time: number, seed: number): number {
|
||||
let y = 0
|
||||
let amplitude = 0.12
|
||||
let frequency = 10
|
||||
for (let i = 0; i < 10; i++) {
|
||||
y += amplitude * noise2D(frequency * x + seed * 100, time * frequency * 0.3)
|
||||
frequency *= 1.6
|
||||
amplitude *= 0.7
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
function roundedRectPoint(t: number, w: number, h: number, r: number): { x: number, y: number } {
|
||||
const sw = w - 2 * r
|
||||
const sh = h - 2 * r
|
||||
const arc = (Math.PI * r) / 2
|
||||
const total = 2 * sw + 2 * sh + 4 * arc
|
||||
let d = t * total
|
||||
|
||||
if (d <= sw) return { x: r + d, y: 0 }
|
||||
d -= sw
|
||||
if (d <= arc) {
|
||||
const a = -Math.PI / 2 + (d / arc) * (Math.PI / 2)
|
||||
return { x: w - r + r * Math.cos(a), y: r + r * Math.sin(a) }
|
||||
}
|
||||
d -= arc
|
||||
if (d <= sh) return { x: w, y: r + d }
|
||||
d -= sh
|
||||
if (d <= arc) {
|
||||
const a = (d / arc) * (Math.PI / 2)
|
||||
return { x: w - r + r * Math.cos(a), y: h - r + r * Math.sin(a) }
|
||||
}
|
||||
d -= arc
|
||||
if (d <= sw) return { x: w - r - d, y: h }
|
||||
d -= sw
|
||||
if (d <= arc) {
|
||||
const a = Math.PI / 2 + (d / arc) * (Math.PI / 2)
|
||||
return { x: r + r * Math.cos(a), y: h - r + r * Math.sin(a) }
|
||||
}
|
||||
d -= arc
|
||||
if (d <= sh) return { x: 0, y: h - r - d }
|
||||
d -= sh
|
||||
const a = Math.PI + (d / arc) * (Math.PI / 2)
|
||||
return { x: r + r * Math.cos(a), y: r + r * Math.sin(a) }
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (props.effect !== 'electric') return
|
||||
const canvas = canvasRef.value
|
||||
const container = containerRef.value
|
||||
if (!canvas || !container) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let width = 0
|
||||
let height = 0
|
||||
let radius = 0
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const updateSize = () => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
width = rect.width
|
||||
height = rect.height
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
radius = parseFloat(getComputedStyle(container).borderRadius) || 0
|
||||
}
|
||||
updateSize()
|
||||
const ro = new ResizeObserver(updateSize)
|
||||
ro.observe(container)
|
||||
|
||||
let raf = 0
|
||||
let time = 0
|
||||
let last = 0
|
||||
|
||||
const draw = (now: number) => {
|
||||
time += ((now - last) / 1000) * 1.2
|
||||
last = now
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.strokeStyle = props.color
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
const inset = 1
|
||||
const w = width - inset * 2
|
||||
const h = height - inset * 2
|
||||
const r = Math.max(0, radius - inset)
|
||||
const perimeter = 2 * (w + h) + 2 * Math.PI * r
|
||||
const samples = Math.floor(perimeter / 2)
|
||||
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const p = i / samples
|
||||
const pt = roundedRectPoint(p, w, h, r)
|
||||
const nx = octavedNoise(p * 8, time, 0) * 14
|
||||
const ny = octavedNoise(p * 8, time, 1) * 14
|
||||
if (i === 0) ctx.moveTo(inset + pt.x + nx, inset + pt.y + ny)
|
||||
else ctx.lineTo(inset + pt.x + nx, inset + pt.y + ny)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.stroke()
|
||||
raf = requestAnimationFrame(draw)
|
||||
}
|
||||
raf = requestAnimationFrame(draw)
|
||||
|
||||
cleanup = () => {
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup?.()
|
||||
cleanup = null
|
||||
})
|
||||
|
||||
const rgb = computed(() => hexToRgbTuple(props.color).map(v => Math.round(v * 255)).join(' '))
|
||||
const cssVars = computed<CSSProperties>(() => ({ '--be-color': `rgb(${rgb.value})` }) as CSSProperties)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="pointer-events-none absolute inset-0 rounded-[inherit]" :style="cssVars">
|
||||
<canvas v-if="effect === 'electric'" ref="canvasRef" class="absolute inset-0" />
|
||||
<div v-else-if="effect === 'beam'" class="border-beam absolute inset-0 rounded-[inherit]" />
|
||||
<div v-else-if="effect === 'pulse'" class="border-pulse absolute inset-0 rounded-[inherit]" />
|
||||
<div v-else-if="effect === 'rainbow'" class="border-rainbow absolute inset-0 rounded-[inherit]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@property --beam-angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
.border-beam {
|
||||
border: 2px solid transparent;
|
||||
background: conic-gradient(from var(--beam-angle), transparent 60%, var(--be-color) 80%, transparent 100%) border-box;
|
||||
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
animation: be-rotate 3s linear infinite;
|
||||
}
|
||||
|
||||
.border-pulse {
|
||||
border: 2px solid var(--be-color);
|
||||
box-shadow: 0 0 12px 1px var(--be-color);
|
||||
animation: be-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.border-rainbow {
|
||||
border: 2px solid transparent;
|
||||
background: conic-gradient(from var(--beam-angle), var(--be-color), #ffffff, var(--be-color)) border-box;
|
||||
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
animation: be-rotate 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes be-rotate {
|
||||
to { --beam-angle: 360deg; }
|
||||
}
|
||||
|
||||
@keyframes be-pulse {
|
||||
0%, 100% { opacity: 0.35; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
// Optionale Vignette über Karten-Hintergrundeffekten: dunkelt die Ränder
|
||||
// zur Surface-Farbe hin ab, damit darüberliegender Text lesbar bleibt.
|
||||
// `surface` muss die CSS-Farbe der Kartenfläche sein (popover/card/background),
|
||||
// damit die Kante optisch in die Karte übergeht.
|
||||
withDefaults(defineProps<{ surface?: string }>(), {
|
||||
surface: 'var(--color-popover)',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
:style="{ background: `radial-gradient(ellipse at center, transparent 25%, ${surface} 92%)` }"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { hexToRgbTuple, shadeHex } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop,
|
||||
// Blur-Post-Pass entfernt (blur: 0 war Standard)
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const VERT = `#version 300 es
|
||||
in vec2 position;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
uniform float iTime;
|
||||
uniform float uSpeed;
|
||||
uniform float uWaveDepth;
|
||||
uniform float uZoom;
|
||||
uniform float uDensity;
|
||||
uniform float uSpread;
|
||||
uniform float uStepSize;
|
||||
uniform float uGlow;
|
||||
uniform float uExposure;
|
||||
uniform float uColorShift;
|
||||
uniform float uContrast;
|
||||
uniform float uBrightness;
|
||||
uniform float uOpacity;
|
||||
uniform float uSteps;
|
||||
uniform vec3 uColor1;
|
||||
uniform vec3 uColor2;
|
||||
uniform vec3 uColor3;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uMouseStrength;
|
||||
uniform float uMouseRadius;
|
||||
uniform float uMouseActive;
|
||||
uniform float uGrainIntensity;
|
||||
uniform vec2 uCardRes;
|
||||
uniform float uRadius;
|
||||
out vec4 fragColor;
|
||||
|
||||
float cardMask(vec2 fc) {
|
||||
vec2 p = fc - uCardRes * 0.5;
|
||||
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
return 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 frag = gl_FragCoord.xy;
|
||||
float zoom = max(uZoom, 0.05);
|
||||
// Pixelkonsistent: Referenzhöhe 180px, damit die Zellen in flachen
|
||||
// Containern ihre reale Pixelgröße behalten statt zu stauchen
|
||||
vec2 ndc = (2.0 * frag - uCardRes.xy) / 180.0;
|
||||
vec2 dir = ndc * (0.5 / zoom);
|
||||
|
||||
vec2 mouseNdc = uMouse * (uCardRes / 360.0);
|
||||
float mr = max(uMouseRadius, 0.01);
|
||||
vec2 md = ndc - mouseNdc;
|
||||
float dent = exp(-dot(md, md) / (mr * mr)) * (3.0 * uMouseStrength * uMouseActive);
|
||||
|
||||
float travel = sin(iTime * uSpeed) * uWaveDepth;
|
||||
float density = max(uDensity, 1.0);
|
||||
float spread = clamp(uSpread, 0.05, 0.6);
|
||||
float stepSize = max(uStepSize, 0.0005);
|
||||
float glowGain = max(uGlow, 0.0);
|
||||
|
||||
vec3 tOffset = vec3(0.0, dent, travel);
|
||||
vec3 p = vec3(0.0);
|
||||
float s = 0.0;
|
||||
float glow = 0.0;
|
||||
|
||||
for (int i = 0; i < 64; i++) {
|
||||
if (float(i) >= uSteps) break;
|
||||
p += vec3(dir * s, s);
|
||||
vec3 q = p + tOffset;
|
||||
s += density - length(q.xz) + length(ceil(q).xy);
|
||||
s = stepSize + abs(s) * spread;
|
||||
glow += glowGain / s;
|
||||
}
|
||||
|
||||
float e = glow / max(uExposure, 1.0);
|
||||
float shimmer = 0.5 + 0.5 * dot(cos(iTime * uColorShift + p), vec3(0.3333));
|
||||
float v = tanh(e * uBrightness * mix(0.7, 1.05, shimmer));
|
||||
v = clamp((v - 0.5) * uContrast + 0.5, 0.0, 1.0);
|
||||
|
||||
vec3 col = mix(uColor1, uColor2, smoothstep(0.0, 0.55, v));
|
||||
col = mix(col, uColor3, smoothstep(0.55, 1.0, v));
|
||||
col *= v;
|
||||
|
||||
float a = clamp(v, 0.0, 1.0) * uOpacity;
|
||||
vec3 outRgb = col;
|
||||
if (a > 0.01) {
|
||||
float gv = (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233)) + iTime) * 43758.5453) - 0.5) * uGrainIntensity;
|
||||
outRgb = clamp(outRgb + gv, 0.0, 1.0);
|
||||
a = clamp(a + gv, 0.0, 1.0);
|
||||
}
|
||||
|
||||
a *= cardMask(frag);
|
||||
fragColor = vec4(outRgb * a, a);
|
||||
}
|
||||
`
|
||||
|
||||
const ctnDom = ref<HTMLDivElement | null>(null)
|
||||
|
||||
useCardShader(ctnDom, {
|
||||
vertex: VERT,
|
||||
fragment: FRAG,
|
||||
mouse: true,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
uSpeed: { value: 0.7 },
|
||||
uWaveDepth: { value: 1 },
|
||||
uZoom: { value: 1.3 },
|
||||
uDensity: { value: 10.0 },
|
||||
uSpread: { value: 0.3 },
|
||||
uStepSize: { value: 0.002 },
|
||||
uGlow: { value: 1.0 },
|
||||
uExposure: { value: 2700 },
|
||||
uColorShift: { value: 0 },
|
||||
uContrast: { value: 1 },
|
||||
uBrightness: { value: 1.0 },
|
||||
uOpacity: { value: 1.0 },
|
||||
uSteps: { value: 32 },
|
||||
uColor1: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, -0.35))) },
|
||||
uColor2: { value: new Float32Array(hexToRgbTuple(props.color)) },
|
||||
uColor3: { value: new Float32Array([1, 1, 1]) },
|
||||
uMouse: { value: new Float32Array([0, 0]) },
|
||||
uMouseStrength: { value: 0.1 },
|
||||
uMouseRadius: { value: 0.35 },
|
||||
uMouseActive: { value: 0 },
|
||||
uGrainIntensity: { value: 0.05 },
|
||||
},
|
||||
color: () => props.color,
|
||||
colorUniforms: { uColor1: -0.35, uColor2: 0 },
|
||||
frame: ({ t, program, mouse }) => {
|
||||
const u = program.uniforms
|
||||
u.iTime.value = t * 0.001
|
||||
const m = u.uMouse.value as Float32Array
|
||||
m[0] = mouse.x * 2 - 1
|
||||
m[1] = mouse.y * 2 - 1
|
||||
u.uMouseActive.value = mouse.active
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { Color, Mesh, Program, Renderer, Triangle } from 'ogl'
|
||||
import { shadeHex } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const VERT = `#version 300 es
|
||||
in vec2 position;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform float uTime;
|
||||
uniform float uAmplitude;
|
||||
uniform vec3 uColorStops[3];
|
||||
uniform vec2 uResolution;
|
||||
uniform float uBlend;
|
||||
uniform float uRadius;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
vec3 permute(vec3 x) {
|
||||
return mod(((x * 34.0) + 1.0) * x, 289.0);
|
||||
}
|
||||
|
||||
float snoise(vec2 v){
|
||||
const vec4 C = vec4(
|
||||
0.211324865405187, 0.366025403784439,
|
||||
-0.577350269189626, 0.024390243902439
|
||||
);
|
||||
vec2 i = floor(v + dot(v, C.yy));
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
i = mod(i, 289.0);
|
||||
|
||||
vec3 p = permute(
|
||||
permute(i.y + vec3(0.0, i1.y, 1.0))
|
||||
+ i.x + vec3(0.0, i1.x, 1.0)
|
||||
);
|
||||
|
||||
vec3 m = max(
|
||||
0.5 - vec3(
|
||||
dot(x0, x0),
|
||||
dot(x12.xy, x12.xy),
|
||||
dot(x12.zw, x12.zw)
|
||||
),
|
||||
0.0
|
||||
);
|
||||
m = m * m;
|
||||
m = m * m;
|
||||
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
|
||||
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
struct ColorStop {
|
||||
vec3 color;
|
||||
float position;
|
||||
};
|
||||
|
||||
#define COLOR_RAMP(colors, factor, finalColor) { \\
|
||||
int index = 0; \\
|
||||
for (int i = 0; i < 2; i++) { \\
|
||||
ColorStop currentColor = colors[i]; \\
|
||||
bool isInBetween = currentColor.position <= factor; \\
|
||||
index = int(mix(float(index), float(i), float(isInBetween))); \\
|
||||
} \\
|
||||
ColorStop currentColor = colors[index]; \\
|
||||
ColorStop nextColor = colors[index + 1]; \\
|
||||
float range = nextColor.position - currentColor.position; \\
|
||||
float lerpFactor = (factor - currentColor.position) / range; \\
|
||||
finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \\
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / uResolution;
|
||||
|
||||
ColorStop colors[3];
|
||||
colors[0] = ColorStop(uColorStops[0], 0.0);
|
||||
colors[1] = ColorStop(uColorStops[1], 0.5);
|
||||
colors[2] = ColorStop(uColorStops[2], 1.0);
|
||||
|
||||
vec3 rampColor;
|
||||
COLOR_RAMP(colors, uv.x, rampColor);
|
||||
|
||||
// Noise x-Koordinate pixelkonsistent (~2 Einheiten pro 320px), damit
|
||||
// die Wellen in schmalen/breiten Karten nicht gestaucht/gestreckt wirken
|
||||
float height = snoise(vec2(gl_FragCoord.x / 160.0 + uTime * 0.1, uTime * 0.25)) * 0.5 * uAmplitude;
|
||||
height = exp(height);
|
||||
height = (uv.y * 2.0 - height + 0.2);
|
||||
float intensity = 0.6 * height;
|
||||
|
||||
float midPoint = 0.20;
|
||||
float auroraAlpha = smoothstep(midPoint - uBlend * 0.5, midPoint + uBlend * 0.5, intensity);
|
||||
|
||||
vec3 auroraColor = intensity * rampColor;
|
||||
|
||||
// Rounded-Rect-Maske direkt im Shader — zuverlässig auch wenn der
|
||||
// Compositor den Canvas-Layer nicht an CSS-Clips bindet (Waterfox)
|
||||
vec2 p = gl_FragCoord.xy - uResolution * 0.5;
|
||||
vec2 hb = uResolution * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
float rm = 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
auroraAlpha *= rm;
|
||||
|
||||
fragColor = vec4(auroraColor * auroraAlpha, auroraAlpha);
|
||||
}
|
||||
`
|
||||
|
||||
const ctnDom = ref<HTMLDivElement | null>(null)
|
||||
|
||||
let animateId = 0
|
||||
let teardown: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
const ctn = ctnDom.value
|
||||
if (!ctn) return
|
||||
|
||||
const renderer = new Renderer({ alpha: true, premultipliedAlpha: true, antialias: true })
|
||||
const gl = renderer.gl
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
gl.enable(gl.BLEND)
|
||||
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
|
||||
// WebGL-Canvas bleibt detached: Waterfox/Firefox zeichnet WebGL-Layer nach
|
||||
// Scrollen teils an veralteter Position und ignoriert CSS-Clips — stattdessen
|
||||
// wird pro Frame in einen normalen 2D-Canvas geblittet
|
||||
const outCanvas = document.createElement('canvas')
|
||||
outCanvas.className = 'absolute inset-0 size-full rounded-[inherit]'
|
||||
const outCtx = outCanvas.getContext('2d')
|
||||
ctn.appendChild(outCanvas)
|
||||
|
||||
const geometry = new Triangle(gl)
|
||||
if (geometry.attributes.uv) {
|
||||
delete geometry.attributes.uv
|
||||
}
|
||||
|
||||
const colorStops = [shadeHex(props.color, -0.5), props.color, shadeHex(props.color, 0.4)]
|
||||
.map((hex) => {
|
||||
const c = new Color(hex)
|
||||
return [c.r, c.g, c.b]
|
||||
})
|
||||
|
||||
const program = new Program(gl, {
|
||||
vertex: VERT,
|
||||
fragment: FRAG,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uAmplitude: { value: 1.0 },
|
||||
uColorStops: { value: colorStops },
|
||||
uResolution: { value: [ctn.offsetWidth, ctn.offsetHeight] },
|
||||
uBlend: { value: 0.5 },
|
||||
uRadius: { value: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program })
|
||||
|
||||
// Farbwechsel live übernehmen (Settings-Vorschau)
|
||||
watch(() => props.color, (c) => {
|
||||
program.uniforms.uColorStops!.value = [shadeHex(c, -0.5), c, shadeHex(c, 0.4)]
|
||||
.map((hex) => {
|
||||
const col = new Color(hex)
|
||||
return [col.r, col.g, col.b]
|
||||
})
|
||||
})
|
||||
|
||||
const resizeHandler = () => {
|
||||
renderer.setSize(ctn.offsetWidth, ctn.offsetHeight)
|
||||
program.uniforms.uResolution.value = [gl.drawingBufferWidth, gl.drawingBufferHeight]
|
||||
const cssRadius = parseFloat(getComputedStyle(ctn).borderTopLeftRadius) || 0
|
||||
program.uniforms.uRadius.value = ctn.offsetWidth > 0
|
||||
? cssRadius * (gl.drawingBufferWidth / ctn.offsetWidth)
|
||||
: 0
|
||||
outCanvas.width = gl.drawingBufferWidth
|
||||
outCanvas.height = gl.drawingBufferHeight
|
||||
}
|
||||
const ro = new ResizeObserver(resizeHandler)
|
||||
ro.observe(ctn)
|
||||
resizeHandler()
|
||||
|
||||
const update = (t: number) => {
|
||||
animateId = requestAnimationFrame(update)
|
||||
program.uniforms.uTime.value = t * 0.01 * 0.1
|
||||
renderer.render({ scene: mesh })
|
||||
outCtx?.clearRect(0, 0, outCanvas.width, outCanvas.height)
|
||||
outCtx?.drawImage(gl.canvas, 0, 0, outCanvas.width, outCanvas.height)
|
||||
}
|
||||
animateId = requestAnimationFrame(update)
|
||||
|
||||
teardown = () => {
|
||||
cancelAnimationFrame(animateId)
|
||||
ro.disconnect()
|
||||
if (outCanvas.parentNode === ctn) ctn.removeChild(outCanvas)
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
teardown?.()
|
||||
teardown = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { hexToRgbTuple, shadeHex } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), von three.js auf ogl umgeschrieben,
|
||||
// vereinfacht auf color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 uv;
|
||||
attribute vec2 position;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `
|
||||
precision highp float;
|
||||
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform float uSpeed;
|
||||
uniform vec3 uColor;
|
||||
uniform vec3 uBackgroundColor;
|
||||
uniform float uCurvature;
|
||||
uniform float uScanlineStrength;
|
||||
uniform float uScanlineFrequency;
|
||||
uniform float uWaveAmplitude;
|
||||
uniform float uWaveFrequency;
|
||||
uniform float uBloom;
|
||||
uniform float uBloomRadius;
|
||||
uniform float uNoise;
|
||||
uniform float uVignette;
|
||||
uniform float uBrightness;
|
||||
uniform float uPixelation;
|
||||
uniform float uRgbShift;
|
||||
uniform vec2 uPointer;
|
||||
uniform float uMouseStrength;
|
||||
uniform vec2 uCardRes;
|
||||
uniform float uRadius;
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
vec2 crtCurve(vec2 uv, float radius) {
|
||||
vec2 p = (uv - 0.5) * 2.0;
|
||||
float safeRadius = max(radius, 1.415);
|
||||
float cornerScale = safeRadius / sqrt(max(safeRadius * safeRadius - 2.0, 0.001));
|
||||
p = safeRadius * p / sqrt(max(safeRadius * safeRadius - dot(p, p), 0.001));
|
||||
p /= cornerScale;
|
||||
return p * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
float referencePlasma(vec2 uv, float t) {
|
||||
float frequencyScale = max(uWaveFrequency / 2.2, 0.001);
|
||||
uv = (uv - 0.5) * frequencyScale + 0.5;
|
||||
|
||||
float scanline = 0.5 - 0.5 * cos(uv.y * 3.14159265 * uScanlineFrequency);
|
||||
scanline = mix(1.0, scanline, uScanlineStrength);
|
||||
|
||||
// Pixelkonsistente Zellen (~16px) statt fixem 80x24-Raster,
|
||||
// damit das CRT-Pixeln in flachen Containern nicht staucht
|
||||
vec2 cells = max(round(uCardRes / 16.0), vec2(4.0));
|
||||
uv *= cells;
|
||||
uv = ceil(uv);
|
||||
uv /= cells;
|
||||
|
||||
float amplitude = uWaveAmplitude / 0.28;
|
||||
float field = 0.0;
|
||||
field += 0.7 * sin(0.5 * uv.x + t / 5.0);
|
||||
field += 3.0 * sin(1.6 * uv.y + t / 5.0);
|
||||
field += sin(10.0 * (uv.y * sin(t / 2.0) + uv.x * cos(t / 5.0)) + t / 2.0);
|
||||
|
||||
float cx = uv.x + 0.5 * sin(t / 2.0);
|
||||
float cy = uv.y + 0.5 * cos(t / 4.0);
|
||||
field += 0.4 * sin(sqrt(100.0 * cx * cx + 100.0 * cy * cy + 1.0) + t);
|
||||
field += 0.9 * sin(sqrt(75.0 * cx * cx + 25.0 * cy * cy + 1.0) + t);
|
||||
field -= 1.4 * sin(sqrt(256.0 * cx * cx + 25.0 * cy * cy + 1.0) + t);
|
||||
field += 0.3 * sin(0.5 * uv.y + uv.x + sin(t));
|
||||
|
||||
return scanline * floor(3.0 * (0.5 + 0.499 * sin(field * amplitude))) / 3.0;
|
||||
}
|
||||
|
||||
float cardMask(vec2 fc) {
|
||||
vec2 p = fc - uCardRes * 0.5;
|
||||
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
return 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUv;
|
||||
if (uPixelation > 1.001) {
|
||||
vec2 cells = max(uCardRes / uPixelation, vec2(1.0));
|
||||
uv = (floor(uv * cells) + 0.5) / cells;
|
||||
}
|
||||
|
||||
float curveRadius = 1.1 + 0.42 / max(uCurvature, 0.001);
|
||||
curveRadius *= exp(-uPointer.y * uMouseStrength * 0.4);
|
||||
vec2 curvedUv = crtCurve(uv, curveRadius);
|
||||
curvedUv.x -= uPointer.x * uMouseStrength * 0.035;
|
||||
|
||||
float t = uTime * uSpeed;
|
||||
float signal = referencePlasma(curvedUv, t);
|
||||
float radius = 0.01 * uBloomRadius;
|
||||
float glow = signal * 0.2;
|
||||
glow += referencePlasma(curvedUv + vec2(radius, 0.0), t) * 0.12;
|
||||
glow += referencePlasma(curvedUv - vec2(radius, 0.0), t) * 0.12;
|
||||
glow += referencePlasma(curvedUv + vec2(0.0, radius), t) * 0.12;
|
||||
glow += referencePlasma(curvedUv - vec2(0.0, radius), t) * 0.12;
|
||||
glow += referencePlasma(curvedUv + vec2(radius), t) * 0.08;
|
||||
glow += referencePlasma(curvedUv - vec2(radius), t) * 0.08;
|
||||
glow += referencePlasma(curvedUv + vec2(radius, -radius), t) * 0.08;
|
||||
glow += referencePlasma(curvedUv + vec2(-radius, radius), t) * 0.08;
|
||||
|
||||
float redSignal = referencePlasma(curvedUv + vec2(uRgbShift, 0.0), t);
|
||||
float blueSignal = referencePlasma(curvedUv - vec2(uRgbShift, 0.0), t);
|
||||
vec3 channelSignal = vec3(redSignal, signal, blueSignal);
|
||||
vec3 waveColor = uColor * (0.3 + signal * 0.7 + glow * uBloom * 0.65);
|
||||
waveColor += (channelSignal - signal) * 0.42;
|
||||
|
||||
float edge = clamp(1.0 - dot(vUv - 0.5, vUv - 0.5) * 2.0, 0.0, 1.0);
|
||||
float edgeFade = mix(1.0, smoothstep(0.0, 1.0, edge), uVignette);
|
||||
float waveMask = clamp(signal * 0.82 + glow * 0.52, 0.0, 1.0) * edgeFade;
|
||||
|
||||
waveColor = max(waveColor * uBrightness, vec3(0.0));
|
||||
float a = clamp(waveMask, 0.0, 1.0) * cardMask(gl_FragCoord.xy);
|
||||
vec3 col = waveColor;
|
||||
if (a > 0.01) {
|
||||
float grain = (hash21(gl_FragCoord.xy + vec2(fract(uTime) * 173.0)) - 0.5) * uNoise;
|
||||
col = max(col + grain, vec3(0.0));
|
||||
}
|
||||
gl_FragColor = vec4(col * a, a);
|
||||
}
|
||||
`
|
||||
|
||||
const ctnDom = ref<HTMLDivElement | null>(null)
|
||||
|
||||
let elapsed = 0
|
||||
let lastT = 0
|
||||
|
||||
useCardShader(ctnDom, {
|
||||
vertex: VERT,
|
||||
fragment: FRAG,
|
||||
mouse: true,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uSpeed: { value: 0.5 },
|
||||
uColor: { value: new Float32Array(hexToRgbTuple(props.color)) },
|
||||
uBackgroundColor: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, -0.93))) },
|
||||
uCurvature: { value: 0.25 },
|
||||
uScanlineStrength: { value: 0.25 },
|
||||
uScanlineFrequency: { value: 200 },
|
||||
uWaveAmplitude: { value: 0.3 },
|
||||
uWaveFrequency: { value: 2.5 },
|
||||
uBloom: { value: 1.5 },
|
||||
uBloomRadius: { value: 1 },
|
||||
uNoise: { value: 0.1 },
|
||||
uVignette: { value: 0 },
|
||||
uBrightness: { value: 1.25 },
|
||||
uPixelation: { value: 1 },
|
||||
uRgbShift: { value: 0.015 },
|
||||
uPointer: { value: new Float32Array([0, 0]) },
|
||||
uMouseStrength: { value: 0.5 },
|
||||
},
|
||||
color: () => props.color,
|
||||
colorUniforms: { uColor: 0, uBackgroundColor: -0.93 },
|
||||
frame: ({ t, program, mouse }) => {
|
||||
// fps-Cap ~30 wie im Original (CRT-Flackern)
|
||||
const u = program.uniforms
|
||||
const delta = Math.min((t - lastT) * 0.001, 0.1)
|
||||
lastT = t
|
||||
elapsed += delta
|
||||
u.uTime.value = elapsed
|
||||
const p = u.uPointer.value as Float32Array
|
||||
p[0] = (mouse.x * 2 - 1) * mouse.active
|
||||
p[1] = (mouse.y * 2 - 1) * mouse.active
|
||||
// Scanline-Dichte in Pixel-Pitch (eine Linie alle ~6 Buffer-Pixel)
|
||||
// statt fixer Zeilenzahl — flache Karten sonst subpixel-gestaucht
|
||||
const res = u.uCardRes!.value as Float32Array
|
||||
u.uScanlineFrequency!.value = res[1] / 6
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { hexToRgbTuple } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 uv;
|
||||
attribute vec2 position;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `
|
||||
precision highp float;
|
||||
|
||||
uniform float time;
|
||||
uniform float waveSpeed;
|
||||
uniform float waveFrequency;
|
||||
uniform float waveAmplitude;
|
||||
uniform vec3 waveColor;
|
||||
uniform vec2 mousePos;
|
||||
uniform float mouseActive;
|
||||
uniform float mouseRadius;
|
||||
uniform float colorNum;
|
||||
uniform float pixelSize;
|
||||
uniform vec2 uCardRes;
|
||||
uniform float uRadius;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
vec4 mod289(vec4 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
|
||||
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
|
||||
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
|
||||
vec2 fade(vec2 t) { return t*t*t*(t*(t*6.0-15.0)+10.0); }
|
||||
|
||||
float cnoise(vec2 P) {
|
||||
vec4 Pi = floor(P.xyxy) + vec4(0.0,0.0,1.0,1.0);
|
||||
vec4 Pf = fract(P.xyxy) - vec4(0.0,0.0,1.0,1.0);
|
||||
Pi = mod289(Pi);
|
||||
vec4 ix = Pi.xzxz;
|
||||
vec4 iy = Pi.yyww;
|
||||
vec4 fx = Pf.xzxz;
|
||||
vec4 fy = Pf.yyww;
|
||||
vec4 i = permute(permute(ix) + iy);
|
||||
vec4 gx = fract(i * (1.0/41.0)) * 2.0 - 1.0;
|
||||
vec4 gy = abs(gx) - 0.5;
|
||||
vec4 tx = floor(gx + 0.5);
|
||||
gx = gx - tx;
|
||||
vec2 g00 = vec2(gx.x, gy.x);
|
||||
vec2 g10 = vec2(gx.y, gy.y);
|
||||
vec2 g01 = vec2(gx.z, gy.z);
|
||||
vec2 g11 = vec2(gx.w, gy.w);
|
||||
vec4 norm = taylorInvSqrt(vec4(dot(g00,g00), dot(g01,g01), dot(g10,g10), dot(g11,g11)));
|
||||
g00 *= norm.x; g01 *= norm.y; g10 *= norm.z; g11 *= norm.w;
|
||||
float n00 = dot(g00, vec2(fx.x, fy.x));
|
||||
float n10 = dot(g10, vec2(fx.y, fy.y));
|
||||
float n01 = dot(g01, vec2(fx.z, fy.z));
|
||||
float n11 = dot(g11, vec2(fx.w, fy.w));
|
||||
vec2 fade_xy = fade(Pf.xy);
|
||||
float n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x);
|
||||
return 2.3 * mix(n_x.x, n_x.y, fade_xy.y);
|
||||
}
|
||||
|
||||
const int OCTAVES = 8;
|
||||
float fbm(vec2 p) {
|
||||
float value = 0.0;
|
||||
float amp = 1.0;
|
||||
float freq = waveFrequency;
|
||||
for (int i = 0; i < OCTAVES; i++) {
|
||||
value += amp * abs(cnoise(p));
|
||||
p *= freq;
|
||||
amp *= waveAmplitude;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
float pattern(vec2 p) {
|
||||
vec2 p2 = p - time * waveSpeed;
|
||||
return fbm(p - fbm(p + fbm(p2)));
|
||||
}
|
||||
|
||||
float getBayerValue(int x, int y) {
|
||||
if (y == 0) {
|
||||
if (x == 0) return 0.0/64.0;
|
||||
if (x == 1) return 48.0/64.0;
|
||||
if (x == 2) return 12.0/64.0;
|
||||
if (x == 3) return 60.0/64.0;
|
||||
if (x == 4) return 3.0/64.0;
|
||||
if (x == 5) return 51.0/64.0;
|
||||
if (x == 6) return 15.0/64.0;
|
||||
if (x == 7) return 63.0/64.0;
|
||||
} else if (y == 1) {
|
||||
if (x == 0) return 32.0/64.0;
|
||||
if (x == 1) return 16.0/64.0;
|
||||
if (x == 2) return 44.0/64.0;
|
||||
if (x == 3) return 28.0/64.0;
|
||||
if (x == 4) return 35.0/64.0;
|
||||
if (x == 5) return 19.0/64.0;
|
||||
if (x == 6) return 47.0/64.0;
|
||||
if (x == 7) return 31.0/64.0;
|
||||
} else if (y == 2) {
|
||||
if (x == 0) return 8.0/64.0;
|
||||
if (x == 1) return 56.0/64.0;
|
||||
if (x == 2) return 4.0/64.0;
|
||||
if (x == 3) return 52.0/64.0;
|
||||
if (x == 4) return 11.0/64.0;
|
||||
if (x == 5) return 59.0/64.0;
|
||||
if (x == 6) return 7.0/64.0;
|
||||
if (x == 7) return 55.0/64.0;
|
||||
} else if (y == 3) {
|
||||
if (x == 0) return 40.0/64.0;
|
||||
if (x == 1) return 24.0/64.0;
|
||||
if (x == 2) return 36.0/64.0;
|
||||
if (x == 3) return 20.0/64.0;
|
||||
if (x == 4) return 43.0/64.0;
|
||||
if (x == 5) return 27.0/64.0;
|
||||
if (x == 6) return 39.0/64.0;
|
||||
if (x == 7) return 23.0/64.0;
|
||||
} else if (y == 4) {
|
||||
if (x == 0) return 2.0/64.0;
|
||||
if (x == 1) return 50.0/64.0;
|
||||
if (x == 2) return 14.0/64.0;
|
||||
if (x == 3) return 62.0/64.0;
|
||||
if (x == 4) return 1.0/64.0;
|
||||
if (x == 5) return 49.0/64.0;
|
||||
if (x == 6) return 13.0/64.0;
|
||||
if (x == 7) return 61.0/64.0;
|
||||
} else if (y == 5) {
|
||||
if (x == 0) return 34.0/64.0;
|
||||
if (x == 1) return 18.0/64.0;
|
||||
if (x == 2) return 46.0/64.0;
|
||||
if (x == 3) return 30.0/64.0;
|
||||
if (x == 4) return 33.0/64.0;
|
||||
if (x == 5) return 17.0/64.0;
|
||||
if (x == 6) return 45.0/64.0;
|
||||
if (x == 7) return 29.0/64.0;
|
||||
} else if (y == 6) {
|
||||
if (x == 0) return 10.0/64.0;
|
||||
if (x == 1) return 58.0/64.0;
|
||||
if (x == 2) return 6.0/64.0;
|
||||
if (x == 3) return 54.0/64.0;
|
||||
if (x == 4) return 9.0/64.0;
|
||||
if (x == 5) return 57.0/64.0;
|
||||
if (x == 6) return 5.0/64.0;
|
||||
if (x == 7) return 53.0/64.0;
|
||||
} else if (y == 7) {
|
||||
if (x == 0) return 42.0/64.0;
|
||||
if (x == 1) return 26.0/64.0;
|
||||
if (x == 2) return 38.0/64.0;
|
||||
if (x == 3) return 22.0/64.0;
|
||||
if (x == 4) return 41.0/64.0;
|
||||
if (x == 5) return 25.0/64.0;
|
||||
if (x == 6) return 37.0/64.0;
|
||||
if (x == 7) return 21.0/64.0;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
vec3 dither(vec2 uv, vec3 color) {
|
||||
vec2 scaledCoord = floor(uv * uCardRes / pixelSize);
|
||||
int x = int(mod(scaledCoord.x, 8.0));
|
||||
int y = int(mod(scaledCoord.y, 8.0));
|
||||
float threshold = getBayerValue(x, y) - 0.25;
|
||||
float step = 1.0 / (colorNum - 1.0);
|
||||
color += threshold * step;
|
||||
float bias = 0.2;
|
||||
color = clamp(color - bias, 0.0, 1.0);
|
||||
return floor(color * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
|
||||
}
|
||||
|
||||
float cardMask(vec2 fc) {
|
||||
vec2 p = fc - uCardRes * 0.5;
|
||||
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
return 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / uCardRes.xy;
|
||||
// Pixelkonsistent: auf Referenzhöhe 180px normieren,
|
||||
// damit das Dither-Muster in flachen Containern nicht staucht
|
||||
vec2 centeredUv = (uv - 0.5) * (uCardRes / 180.0);
|
||||
|
||||
float f = pattern(centeredUv);
|
||||
|
||||
vec2 mouseNDC = ((mousePos / uCardRes - 0.5) * vec2(1.0, -1.0)) * (uCardRes / 180.0);
|
||||
float dist = length(centeredUv - mouseNDC);
|
||||
float effect = (1.0 - smoothstep(0.0, mouseRadius, dist)) * mouseActive;
|
||||
f -= 0.5 * effect;
|
||||
|
||||
vec3 col = mix(vec3(0.0), waveColor, f);
|
||||
col = dither(uv, col);
|
||||
|
||||
float m = cardMask(gl_FragCoord.xy);
|
||||
float a = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0) * m;
|
||||
gl_FragColor = vec4(col * a, a);
|
||||
}
|
||||
`
|
||||
|
||||
const ctnDom = ref<HTMLDivElement | null>(null)
|
||||
|
||||
useCardShader(ctnDom, {
|
||||
vertex: VERT,
|
||||
fragment: FRAG,
|
||||
mouse: true,
|
||||
uniforms: {
|
||||
time: { value: 0 },
|
||||
waveSpeed: { value: 0.05 },
|
||||
waveFrequency: { value: 3 },
|
||||
waveAmplitude: { value: 0.3 },
|
||||
waveColor: { value: hexToRgbTuple(props.color) },
|
||||
mousePos: { value: new Float32Array([0, 0]) },
|
||||
mouseActive: { value: 0 },
|
||||
mouseRadius: { value: 1 },
|
||||
colorNum: { value: 4 },
|
||||
pixelSize: { value: 2 },
|
||||
},
|
||||
color: () => props.color,
|
||||
colorUniforms: { waveColor: 0 },
|
||||
frame: ({ t, program, mouse }) => {
|
||||
const u = program.uniforms
|
||||
u.time.value = t * 0.001
|
||||
const res = u.uCardRes.value as Float32Array
|
||||
const m = u.mousePos.value as Float32Array
|
||||
m[0] = mouse.x * res[0]
|
||||
m[1] = (1 - mouse.y) * res[1]
|
||||
u.mouseActive.value = mouse.active
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import { Mesh, Program, Renderer, Triangle } from 'ogl'
|
||||
import { hexToRgbTuple, shadeHex } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const vertex = `#version 300 es
|
||||
in vec2 position;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const fragment = `#version 300 es
|
||||
precision highp float;
|
||||
uniform vec2 iResolution;
|
||||
uniform float iTime;
|
||||
uniform float uSpeed;
|
||||
uniform float uAmplitude;
|
||||
uniform float uWaveScale;
|
||||
uniform float uWaveRatio;
|
||||
uniform float uSwell;
|
||||
uniform float uTurbulence;
|
||||
uniform float uTilt;
|
||||
uniform float uZoom;
|
||||
uniform float uHeight;
|
||||
uniform float uFogDepth;
|
||||
uniform float uSteps;
|
||||
uniform float uBrightness;
|
||||
uniform float uOpacity;
|
||||
uniform float uGrain;
|
||||
uniform float uGrainIntensity;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uParallax;
|
||||
uniform bool uEnableMouse;
|
||||
uniform vec3 uHorizonColor;
|
||||
uniform vec3 uWaveColor;
|
||||
uniform vec3 uCrestColor;
|
||||
uniform float uRadius;
|
||||
out vec4 fragColor;
|
||||
|
||||
const float MAX_DIST = 20000.0;
|
||||
|
||||
float hash21(vec2 p) {
|
||||
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
||||
p3 += dot(p3, p3.yzx + 33.33);
|
||||
return fract((p3.x + p3.y) * p3.z);
|
||||
}
|
||||
|
||||
float plasma(vec3 r, vec2 freq, vec4 tc) {
|
||||
float mx = r.x + tc.x;
|
||||
mx += uSwell * sin((r.y + mx) / 20.0 + tc.y);
|
||||
float my = r.y - tc.z;
|
||||
my += uTurbulence * cos(r.x / 23.0 + tc.w);
|
||||
return r.z - (sin(mx * freq.x) * uAmplitude + sin(my * freq.y) * uAmplitude + uHeight);
|
||||
}
|
||||
|
||||
float raymarch(vec3 pos, vec3 dir, vec2 freq, vec4 tc) {
|
||||
float dist = 0.0;
|
||||
for (int i = 0; i < 128; i++) {
|
||||
if (float(i) >= uSteps) break;
|
||||
float dscene = plasma(pos + dist * dir, freq, tc);
|
||||
if (abs(dscene) < 0.1) break;
|
||||
dist += 0.9 * dscene;
|
||||
if (!(abs(dist) < MAX_DIST)) return MAX_DIST;
|
||||
}
|
||||
return dist;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float T = iTime * uSpeed;
|
||||
vec2 freq = vec2(uWaveScale / 7.0, (uWaveScale * uWaveRatio) / 3.0);
|
||||
vec4 tc = vec4(T / 0.130, T / 0.810, T / 0.200, T / 0.710);
|
||||
float c, s;
|
||||
float vfov = (3.14159 / 2.3) / max(uZoom, 0.05);
|
||||
vec3 cam = vec3(0.0, 0.0, 30.0);
|
||||
vec2 uv = (gl_FragCoord.xy / iResolution.xy) - 0.5;
|
||||
uv.x *= iResolution.x / iResolution.y;
|
||||
uv.y *= -1.0;
|
||||
|
||||
vec3 dir = vec3(0.0, 0.0, -1.0);
|
||||
float ulen = length(uv);
|
||||
float xrot = vfov * ulen;
|
||||
c = cos(xrot); s = sin(xrot);
|
||||
dir = mat3(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c) * dir;
|
||||
vec2 nuv = ulen > 1e-5 ? uv / ulen : vec2(1.0, 0.0);
|
||||
c = nuv.x; s = nuv.y;
|
||||
dir = mat3(c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0) * dir;
|
||||
c = cos(uTilt); s = sin(uTilt);
|
||||
dir = mat3(c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c) * dir;
|
||||
|
||||
if (uEnableMouse) {
|
||||
float yaw = (uMouse.x - 0.5) * uParallax * 0.4;
|
||||
float pitch = (uMouse.y - 0.5) * uParallax * 0.4;
|
||||
c = cos(yaw); s = sin(yaw);
|
||||
dir = mat3(c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c) * dir;
|
||||
c = cos(pitch); s = sin(pitch);
|
||||
dir = mat3(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c) * dir;
|
||||
}
|
||||
|
||||
float dist = raymarch(cam, dir, freq, tc);
|
||||
vec3 pos = cam + dist * dir;
|
||||
|
||||
float t = clamp(uFogDepth / max(dist, 0.001), 0.0, 1.0);
|
||||
vec3 body = mix(uWaveColor, uCrestColor, clamp(pos.z * 0.08 + 0.5, 0.0, 1.0));
|
||||
vec3 col = mix(uHorizonColor, body, t);
|
||||
col *= uBrightness;
|
||||
col = clamp(col, 0.0, 1.0);
|
||||
|
||||
float alpha = clamp(t, 0.0, 1.0) * uOpacity;
|
||||
if (uGrain > 0.5) {
|
||||
float g = hash21(gl_FragCoord.xy + mod(iTime, 64.0) * 11.0);
|
||||
alpha += (g - 0.5) * uGrainIntensity;
|
||||
}
|
||||
alpha = clamp(alpha, 0.0, 1.0);
|
||||
|
||||
// Rounded-Rect-Maske im Shader (Compositor-sicher, z. B. Waterfox)
|
||||
vec2 p = gl_FragCoord.xy - iResolution * 0.5;
|
||||
vec2 hb = iResolution * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
alpha *= 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
|
||||
fragColor = vec4(col * alpha, alpha);
|
||||
}
|
||||
`
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
let teardown: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
|
||||
const renderer = new Renderer({
|
||||
webgl: 2,
|
||||
alpha: true,
|
||||
premultipliedAlpha: true,
|
||||
antialias: false,
|
||||
dpr: Math.min(window.devicePixelRatio || 1, 1.5),
|
||||
})
|
||||
|
||||
const gl = renderer.gl
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
// WebGL-Canvas bleibt detached; Anzeige über 2D-Blit-Canvas — siehe EffectAurora
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
canvas.style.display = 'block'
|
||||
canvas.style.borderRadius = 'inherit'
|
||||
const ctx2d = canvas.getContext('2d')
|
||||
container.appendChild(canvas)
|
||||
|
||||
const geometry = new Triangle(gl)
|
||||
const program = new Program(gl, {
|
||||
vertex,
|
||||
fragment,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
iResolution: { value: new Float32Array([1, 1]) },
|
||||
uSpeed: { value: 0.4 },
|
||||
uAmplitude: { value: 2.5 },
|
||||
uWaveScale: { value: 0.6 },
|
||||
uWaveRatio: { value: 0.9 },
|
||||
uSwell: { value: 35 },
|
||||
uTurbulence: { value: 20 },
|
||||
uTilt: { value: 1.11 },
|
||||
uZoom: { value: 1.0 },
|
||||
uHeight: { value: 5.5 },
|
||||
uFogDepth: { value: 15 },
|
||||
uSteps: { value: 40.0 }, // 'low' — kleine Card, muss nicht schwer raymarch-en
|
||||
uBrightness: { value: 1.0 },
|
||||
uOpacity: { value: 0.9 },
|
||||
uGrain: { value: 1.0 },
|
||||
uGrainIntensity: { value: 0.05 },
|
||||
uMouse: { value: new Float32Array([0.5, 0.5]) },
|
||||
uParallax: { value: 0.5 },
|
||||
uEnableMouse: { value: false },
|
||||
uHorizonColor: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, -0.55))) },
|
||||
uWaveColor: { value: new Float32Array(hexToRgbTuple(props.color)) },
|
||||
uCrestColor: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, 0.55))) },
|
||||
uRadius: { value: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program })
|
||||
|
||||
// Farbwechsel live übernehmen (Settings-Vorschau)
|
||||
watch(() => props.color, (c) => {
|
||||
const u = program.uniforms
|
||||
u.uHorizonColor!.value = new Float32Array(hexToRgbTuple(shadeHex(c, -0.55)))
|
||||
u.uWaveColor!.value = new Float32Array(hexToRgbTuple(c))
|
||||
u.uCrestColor!.value = new Float32Array(hexToRgbTuple(shadeHex(c, 0.55)))
|
||||
})
|
||||
|
||||
const setSize = () => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const w = Math.max(1, Math.floor(rect.width))
|
||||
const h = Math.max(1, Math.floor(rect.height))
|
||||
renderer.setSize(w, h)
|
||||
const res = program.uniforms.iResolution.value as Float32Array
|
||||
res[0] = gl.drawingBufferWidth
|
||||
res[1] = gl.drawingBufferHeight
|
||||
const cssRadius = parseFloat(getComputedStyle(container).borderTopLeftRadius) || 0
|
||||
program.uniforms.uRadius.value = w > 0 ? cssRadius * (gl.drawingBufferWidth / w) : 0
|
||||
canvas.width = gl.drawingBufferWidth
|
||||
canvas.height = gl.drawingBufferHeight
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(setSize)
|
||||
ro.observe(container)
|
||||
setSize()
|
||||
|
||||
let raf = 0
|
||||
const t0 = performance.now()
|
||||
const loop = (t: number) => {
|
||||
program.uniforms.iTime.value = (t - t0) * 0.001
|
||||
renderer.render({ scene: mesh })
|
||||
ctx2d?.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx2d?.drawImage(gl.canvas, 0, 0, canvas.width, canvas.height)
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
raf = requestAnimationFrame(loop)
|
||||
|
||||
teardown = () => {
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
try {
|
||||
container.removeChild(canvas)
|
||||
}
|
||||
catch { /* bereits detached */ }
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
teardown?.()
|
||||
teardown = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { hexToRgbTuple, mixHex, shadeHex } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#61dca3',
|
||||
})
|
||||
|
||||
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
|
||||
const fontSize = 14
|
||||
const charWidth = 9
|
||||
const charHeight = 18
|
||||
const lettersAndSymbols = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>?0123456789'
|
||||
|
||||
const glitchColors = computed(() => [
|
||||
shadeHex(props.color, -0.5),
|
||||
props.color,
|
||||
shadeHex(props.color, 0.35),
|
||||
])
|
||||
|
||||
interface GlitchLetter {
|
||||
char: string
|
||||
color: string
|
||||
targetColor: string
|
||||
colorProgress: number
|
||||
}
|
||||
|
||||
let letters: GlitchLetter[] = []
|
||||
let grid = { columns: 0, rows: 0 }
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let animationId = 0
|
||||
let lastGlitchTime = 0
|
||||
let ro: ResizeObserver | null = null
|
||||
let cornerRadius = 0
|
||||
|
||||
const getRandomChar = () => lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)]!
|
||||
const getRandomColor = () => glitchColors.value[Math.floor(Math.random() * glitchColors.value.length)]!
|
||||
|
||||
// Farbwechsel live übernehmen (Settings-Vorschau)
|
||||
watch(() => props.color, () => {
|
||||
for (const l of letters) {
|
||||
l.color = getRandomColor()
|
||||
l.targetColor = getRandomColor()
|
||||
l.colorProgress = 1
|
||||
}
|
||||
draw()
|
||||
})
|
||||
|
||||
function resizeCanvas() {
|
||||
const canvas = canvasRef.value
|
||||
const container = containerRef.value
|
||||
if (!canvas || !container) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const width = container.offsetWidth
|
||||
const height = container.offsetHeight
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
ctx?.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
cornerRadius = parseFloat(getComputedStyle(container).borderTopLeftRadius) || 0
|
||||
|
||||
grid = {
|
||||
columns: Math.ceil(width / charWidth),
|
||||
rows: Math.ceil(height / charHeight),
|
||||
}
|
||||
letters = Array.from({ length: grid.columns * grid.rows }, () => ({
|
||||
char: getRandomChar(),
|
||||
color: getRandomColor(),
|
||||
targetColor: getRandomColor(),
|
||||
colorProgress: 1,
|
||||
}))
|
||||
draw()
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const canvas = canvasRef.value
|
||||
if (!ctx || !canvas || !letters.length) return
|
||||
const { width, height } = canvas.getBoundingClientRect()
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
// Rounded-Rect-Clip direkt im Canvas — zuverlässig auch wenn der
|
||||
// Compositor den Layer nicht an CSS-Clips bindet (Waterfox)
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(0, 0, width, height, cornerRadius)
|
||||
ctx.clip()
|
||||
ctx.font = `${fontSize}px monospace`
|
||||
ctx.textBaseline = 'top'
|
||||
|
||||
letters.forEach((letter, index) => {
|
||||
const x = (index % grid.columns) * charWidth
|
||||
const y = Math.floor(index / grid.columns) * charHeight
|
||||
ctx!.fillStyle = letter.color
|
||||
ctx!.fillText(letter.char, x, y)
|
||||
})
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function animate() {
|
||||
const now = Date.now()
|
||||
if (now - lastGlitchTime >= 60) {
|
||||
const updateCount = Math.max(1, Math.floor(letters.length * 0.05))
|
||||
for (let i = 0; i < updateCount; i++) {
|
||||
const index = Math.floor(Math.random() * letters.length)
|
||||
const letter = letters[index]
|
||||
if (!letter) continue
|
||||
letter.char = getRandomChar()
|
||||
letter.targetColor = getRandomColor()
|
||||
letter.colorProgress = 0
|
||||
}
|
||||
lastGlitchTime = now
|
||||
}
|
||||
|
||||
let needsRedraw = false
|
||||
for (const letter of letters) {
|
||||
if (letter.colorProgress < 1) {
|
||||
letter.colorProgress = Math.min(1, letter.colorProgress + 0.05)
|
||||
letter.color = mixHex(letter.color, letter.targetColor, letter.colorProgress)
|
||||
needsRedraw = true
|
||||
}
|
||||
}
|
||||
if (needsRedraw || now - lastGlitchTime < 70) draw()
|
||||
|
||||
animationId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
ctx = canvas.getContext('2d')
|
||||
resizeCanvas()
|
||||
animate()
|
||||
ro = new ResizeObserver(resizeCanvas)
|
||||
if (containerRef.value) ro.observe(containerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(animationId)
|
||||
ro?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative size-full overflow-hidden rounded-[inherit] opacity-40">
|
||||
<canvas ref="canvasRef" class="absolute inset-0 size-full rounded-[inherit]" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { hexToRgbTuple, shadeHex } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 uv;
|
||||
attribute vec2 position;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `
|
||||
precision highp float;
|
||||
|
||||
uniform float uTime;
|
||||
uniform float uSpeed;
|
||||
uniform float uInnerLines;
|
||||
uniform float uOuterLines;
|
||||
uniform float uWarpIntensity;
|
||||
uniform float uRotation;
|
||||
uniform float uEdgeFadeWidth;
|
||||
uniform float uColorCycleSpeed;
|
||||
uniform float uBrightness;
|
||||
uniform vec3 uColor1;
|
||||
uniform vec3 uColor2;
|
||||
uniform vec3 uColor3;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uMouseActive;
|
||||
uniform float uMouseInfluence;
|
||||
uniform vec2 uCardRes;
|
||||
uniform float uRadius;
|
||||
|
||||
#define HALF_PI 1.5707963
|
||||
|
||||
float hashF(float n) {
|
||||
return fract(sin(n * 127.1) * 43758.5453123);
|
||||
}
|
||||
|
||||
float smoothNoise(float x) {
|
||||
float i = floor(x);
|
||||
float f = fract(x);
|
||||
float u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(hashF(i), hashF(i + 1.0), u);
|
||||
}
|
||||
|
||||
float displaceA(float coord, float t) {
|
||||
float result = sin(coord * 2.123) * 0.2;
|
||||
result += sin(coord * 3.234 + t * 4.345) * 0.1;
|
||||
result += sin(coord * 0.589 + t * 0.934) * 0.5;
|
||||
return result;
|
||||
}
|
||||
|
||||
float displaceB(float coord, float t) {
|
||||
float result = sin(coord * 1.345) * 0.3;
|
||||
result += sin(coord * 2.734 + t * 3.345) * 0.2;
|
||||
result += sin(coord * 0.189 + t * 0.934) * 0.3;
|
||||
return result;
|
||||
}
|
||||
|
||||
vec2 rotate2D(vec2 p, float angle) {
|
||||
float c = cos(angle);
|
||||
float s = sin(angle);
|
||||
return vec2(p.x * c - p.y * s, p.x * s + p.y * c);
|
||||
}
|
||||
|
||||
float cardMask(vec2 fc) {
|
||||
vec2 p = fc - uCardRes * 0.5;
|
||||
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
return 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Pixelkonsistent: Koordinaten auf eine Referenzhöhe von 180px normieren,
|
||||
// damit die Liniendichte in flachen Containern nicht zusammengestaucht wird
|
||||
vec2 coords = (gl_FragCoord.xy * 2.0 - uCardRes.xy) / 180.0;
|
||||
coords = rotate2D(coords, uRotation);
|
||||
|
||||
float halfT = uTime * uSpeed * 0.5;
|
||||
float fullT = uTime * uSpeed;
|
||||
|
||||
vec2 mPos = rotate2D((uMouse * 2.0 - 1.0) * (uCardRes / 360.0), uRotation);
|
||||
float mDist = length(coords - mPos);
|
||||
float mouseWarp = uMouseInfluence * exp(-mDist * mDist * 4.0) * uMouseActive;
|
||||
|
||||
float warpAx = coords.x + displaceA(coords.y, halfT) * uWarpIntensity + mouseWarp;
|
||||
float warpAy = coords.y - displaceA(coords.x * cos(fullT) * 1.235, halfT) * uWarpIntensity;
|
||||
float warpBx = coords.x + displaceB(coords.y, halfT) * uWarpIntensity + mouseWarp;
|
||||
float warpBy = coords.y - displaceB(coords.x * sin(fullT) * 1.235, halfT) * uWarpIntensity;
|
||||
|
||||
vec2 fieldA = vec2(warpAx, warpAy);
|
||||
vec2 fieldB = vec2(warpBx, warpBy);
|
||||
vec2 blended = mix(fieldA, fieldB, mix(fieldA, fieldB, 0.5));
|
||||
|
||||
float fadeTop = smoothstep(uEdgeFadeWidth, uEdgeFadeWidth + 0.4, blended.y);
|
||||
float fadeBottom = smoothstep(-uEdgeFadeWidth, -(uEdgeFadeWidth + 0.4), blended.y);
|
||||
float vMask = 1.0 - max(fadeTop, fadeBottom);
|
||||
|
||||
float tileCount = mix(uOuterLines, uInnerLines, vMask);
|
||||
float scaledY = blended.y * tileCount;
|
||||
float nY = smoothNoise(abs(scaledY));
|
||||
|
||||
float ridge = pow(
|
||||
step(abs(nY - blended.x) * 2.0, HALF_PI) * cos(2.0 * (nY - blended.x)),
|
||||
5.0
|
||||
);
|
||||
|
||||
float waveProfile = max(0.0, (max(fract(scaledY), fract(-scaledY)) - 0.55) / 0.45);
|
||||
float lines = pow(waveProfile, 2.0) + pow(waveProfile, 4.0);
|
||||
|
||||
float pattern = vMask * lines;
|
||||
|
||||
float cycleT = fullT * uColorCycleSpeed;
|
||||
float rChannel = (pattern + lines * ridge) * (cos(blended.y + cycleT * 0.234) * 0.5 + 1.0);
|
||||
float gChannel = (pattern + vMask * ridge) * (sin(blended.x + cycleT * 1.745) * 0.5 + 1.0);
|
||||
float bChannel = (pattern + lines * ridge) * (cos(blended.x + cycleT * 0.534) * 0.5 + 1.0);
|
||||
|
||||
vec3 col = (rChannel * uColor1 + gChannel * uColor2 + bChannel * uColor3) * uBrightness;
|
||||
float alpha = clamp(length(col), 0.0, 1.0) * cardMask(gl_FragCoord.xy);
|
||||
|
||||
gl_FragColor = vec4(col * alpha, alpha);
|
||||
}
|
||||
`
|
||||
|
||||
const ctnDom = ref<HTMLDivElement | null>(null)
|
||||
|
||||
useCardShader(ctnDom, {
|
||||
vertex: VERT,
|
||||
fragment: FRAG,
|
||||
mouse: true,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uSpeed: { value: 0.3 },
|
||||
uInnerLines: { value: 32 },
|
||||
uOuterLines: { value: 36 },
|
||||
uWarpIntensity: { value: 1 },
|
||||
uRotation: { value: -45 * Math.PI / 180 },
|
||||
uEdgeFadeWidth: { value: 0 },
|
||||
uColorCycleSpeed: { value: 1 },
|
||||
uBrightness: { value: 0.35 },
|
||||
uColor1: { value: new Float32Array(hexToRgbTuple(props.color)) },
|
||||
uColor2: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, 0.35))) },
|
||||
uColor3: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, -0.25))) },
|
||||
uMouse: { value: new Float32Array([0.5, 0.5]) },
|
||||
uMouseActive: { value: 0 },
|
||||
uMouseInfluence: { value: 2.0 },
|
||||
},
|
||||
color: () => props.color,
|
||||
colorUniforms: { uColor1: 0, uColor2: 0.35, uColor3: -0.25 },
|
||||
frame: ({ t, program, mouse }) => {
|
||||
const u = program.uniforms
|
||||
u.uTime.value = t * 0.001
|
||||
const m = u.uMouse.value as Float32Array
|
||||
m[0] = mouse.x
|
||||
m[1] = mouse.y
|
||||
u.uMouseActive.value = mouse.active
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { Camera, Geometry, Mesh, Program, Renderer } from 'ogl'
|
||||
import { hexToRgbTuple, shadeHex } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
|
||||
const vertex = /* glsl */ `
|
||||
attribute vec3 position;
|
||||
attribute vec4 random;
|
||||
attribute vec3 color;
|
||||
|
||||
uniform mat4 modelMatrix;
|
||||
uniform mat4 viewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
uniform float uTime;
|
||||
uniform float uSpread;
|
||||
uniform float uBaseSize;
|
||||
uniform float uSizeRandomness;
|
||||
|
||||
varying vec4 vRandom;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
vRandom = random;
|
||||
vColor = color;
|
||||
|
||||
vec3 pos = position * uSpread;
|
||||
pos.z *= 10.0;
|
||||
|
||||
vec4 mPos = modelMatrix * vec4(pos, 1.0);
|
||||
float t = uTime;
|
||||
mPos.x += sin(t * random.z + 6.28 * random.w) * mix(0.1, 1.5, random.x);
|
||||
mPos.y += sin(t * random.y + 6.28 * random.x) * mix(0.1, 1.5, random.w);
|
||||
mPos.z += sin(t * random.w + 6.28 * random.y) * mix(0.1, 1.5, random.z);
|
||||
|
||||
vec4 mvPos = viewMatrix * mPos;
|
||||
gl_PointSize = (uBaseSize * (1.0 + uSizeRandomness * (random.x - 0.5))) / length(mvPos.xyz);
|
||||
gl_Position = projectionMatrix * mvPos;
|
||||
}
|
||||
`
|
||||
|
||||
const fragment = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform float uTime;
|
||||
uniform vec2 uResolution;
|
||||
uniform float uRadius;
|
||||
varying vec4 vRandom;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_PointCoord.xy;
|
||||
float d = length(uv - vec2(0.5));
|
||||
float circle = smoothstep(0.5, 0.4, d) * 0.8;
|
||||
|
||||
// Rounded-Rect-Maske im Shader (Compositor-sicher, z. B. Waterfox)
|
||||
vec2 p = gl_FragCoord.xy - uResolution * 0.5;
|
||||
vec2 hb = uResolution * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
circle *= 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
|
||||
gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), circle);
|
||||
}
|
||||
`
|
||||
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
|
||||
const renderer = new Renderer({ dpr: 1, depth: false, alpha: true })
|
||||
const gl = renderer.gl
|
||||
// WebGL-Canvas bleibt detached; Anzeige über 2D-Blit-Canvas — siehe EffectAurora
|
||||
const outCanvas = document.createElement('canvas')
|
||||
outCanvas.className = 'absolute inset-0 size-full rounded-[inherit]'
|
||||
const outCtx = outCanvas.getContext('2d')
|
||||
container.appendChild(outCanvas)
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
|
||||
const camera = new Camera(gl, { fov: 15 })
|
||||
camera.position.set(0, 0, 20)
|
||||
|
||||
const resize = () => {
|
||||
renderer.setSize(container.clientWidth, container.clientHeight)
|
||||
camera.perspective({ aspect: gl.canvas.width / gl.canvas.height })
|
||||
const res = program.uniforms.uResolution.value as Float32Array
|
||||
res[0] = gl.drawingBufferWidth
|
||||
res[1] = gl.drawingBufferHeight
|
||||
const cssRadius = parseFloat(getComputedStyle(container).borderTopLeftRadius) || 0
|
||||
program.uniforms.uRadius.value = container.clientWidth > 0
|
||||
? cssRadius * (gl.drawingBufferWidth / container.clientWidth)
|
||||
: 0
|
||||
outCanvas.width = gl.drawingBufferWidth
|
||||
outCanvas.height = gl.drawingBufferHeight
|
||||
}
|
||||
const ro = new ResizeObserver(resize)
|
||||
|
||||
const count = 120
|
||||
const positions = new Float32Array(count * 3)
|
||||
const randoms = new Float32Array(count * 4)
|
||||
const colors = new Float32Array(count * 3)
|
||||
const palette = [props.color, shadeHex(props.color, 0.4), '#ffffff']
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let x = 0
|
||||
let y = 0
|
||||
let z = 0
|
||||
let len = 0
|
||||
do {
|
||||
x = Math.random() * 2 - 1
|
||||
y = Math.random() * 2 - 1
|
||||
z = Math.random() * 2 - 1
|
||||
len = x * x + y * y + z * z
|
||||
} while (len > 1 || len === 0)
|
||||
const r = Math.cbrt(Math.random())
|
||||
positions.set([x * r, y * r, z * r], i * 3)
|
||||
randoms.set([Math.random(), Math.random(), Math.random(), Math.random()], i * 4)
|
||||
colors.set(hexToRgbTuple(palette[Math.floor(Math.random() * palette.length)]!), i * 3)
|
||||
}
|
||||
|
||||
const geometry = new Geometry(gl, {
|
||||
position: { size: 3, data: positions },
|
||||
random: { size: 4, data: randoms },
|
||||
color: { size: 3, data: colors },
|
||||
})
|
||||
|
||||
const program = new Program(gl, {
|
||||
vertex,
|
||||
fragment,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uSpread: { value: 10 },
|
||||
uBaseSize: { value: 100 },
|
||||
uSizeRandomness: { value: 1 },
|
||||
uResolution: { value: new Float32Array([1, 1]) },
|
||||
uRadius: { value: 0 },
|
||||
},
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
})
|
||||
|
||||
const particles = new Mesh(gl, { mode: gl.POINTS, geometry, program })
|
||||
|
||||
// Farbwechsel live übernehmen (Settings-Vorschau)
|
||||
watch(() => props.color, (c) => {
|
||||
const p = [c, shadeHex(c, 0.4), '#ffffff']
|
||||
for (let i = 0; i < count; i++) {
|
||||
colors.set(hexToRgbTuple(p[Math.floor(Math.random() * p.length)]!), i * 3)
|
||||
}
|
||||
geometry.attributes.color!.needsUpdate = true
|
||||
})
|
||||
|
||||
ro.observe(container)
|
||||
resize()
|
||||
|
||||
let animationFrameId = 0
|
||||
let lastTime = performance.now()
|
||||
let elapsed = 0
|
||||
|
||||
const update = (t: number) => {
|
||||
animationFrameId = requestAnimationFrame(update)
|
||||
const delta = t - lastTime
|
||||
lastTime = t
|
||||
elapsed += delta * 0.1
|
||||
program.uniforms.uTime.value = elapsed * 0.001
|
||||
particles.rotation.x = Math.sin(elapsed * 0.0002) * 0.1
|
||||
particles.rotation.y = Math.cos(elapsed * 0.0005) * 0.15
|
||||
particles.rotation.z += 0.001
|
||||
renderer.render({ scene: particles, camera })
|
||||
outCtx?.clearRect(0, 0, outCanvas.width, outCanvas.height)
|
||||
outCtx?.drawImage(gl.canvas, 0, 0, outCanvas.width, outCanvas.height)
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(update)
|
||||
|
||||
cleanup = () => {
|
||||
ro.disconnect()
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
if (container.contains(outCanvas)) container.removeChild(outCanvas)
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup?.()
|
||||
cleanup = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { hexToRgbTuple } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const VERT = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 position;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
uniform float iTime;
|
||||
uniform vec3 uCustomColor;
|
||||
uniform float uSpeed;
|
||||
uniform float uScale;
|
||||
uniform float uOpacity;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uMouseActive;
|
||||
uniform float uIterations;
|
||||
uniform float uStepScale;
|
||||
uniform vec2 uCardRes;
|
||||
uniform float uRadius;
|
||||
out vec4 fragColor;
|
||||
|
||||
void mainImage(out vec4 o, vec2 C) {
|
||||
// Pixelkonsistent: Koordinatenrahmen auf Referenzhöhe 180px normieren —
|
||||
// r.y ist die Brennweite und würde in flachen Containern die
|
||||
// Perspektive extrem verzerren
|
||||
float pxScale = 180.0 / uCardRes.y;
|
||||
vec2 r = uCardRes * pxScale;
|
||||
C *= pxScale;
|
||||
vec2 center = r * 0.5;
|
||||
C = (C - center) / uScale + center;
|
||||
|
||||
vec2 mouseOffset = (uMouse * pxScale - center) * 0.0002;
|
||||
C += mouseOffset * length(C - center) * uMouseActive;
|
||||
|
||||
float i, d, z, T = iTime * uSpeed;
|
||||
vec3 O, p, S;
|
||||
|
||||
for (vec2 Q; ++i < 60.; O += o.w/d*o.xyz) {
|
||||
p = z*normalize(vec3(C-.5*r,r.y));
|
||||
p.z -= 4.;
|
||||
S = p;
|
||||
d = p.y-T;
|
||||
|
||||
p.x += .4*(1.+p.y)*sin(d + p.x*0.1)*cos(.34*d + p.x*0.05);
|
||||
Q = p.xz *= mat2(cos(p.y+vec4(0,11,33,0)-T));
|
||||
z+= d = (abs(sqrt(length(Q*Q)) - .25*(5.+S.y))/3.+8e-4)*uStepScale;
|
||||
o = 1.+sin(S.y+p.z*.5+S.z-length(S-p)+vec4(2,1,0,8));
|
||||
if (i >= uIterations) break;
|
||||
}
|
||||
|
||||
o.xyz = tanh(O/1e4);
|
||||
}
|
||||
|
||||
bool finite1(float x){ return !(isnan(x) || isinf(x)); }
|
||||
vec3 sanitize(vec3 c){
|
||||
return vec3(
|
||||
finite1(c.r) ? c.r : 0.0,
|
||||
finite1(c.g) ? c.g : 0.0,
|
||||
finite1(c.b) ? c.b : 0.0
|
||||
);
|
||||
}
|
||||
|
||||
float cardMask(vec2 fc) {
|
||||
vec2 p = fc - uCardRes * 0.5;
|
||||
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
return 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 o = vec4(0.0);
|
||||
mainImage(o, gl_FragCoord.xy);
|
||||
vec3 rgb = sanitize(o.rgb);
|
||||
|
||||
float intensity = (rgb.r + rgb.g + rgb.b) / 3.0;
|
||||
vec3 finalColor = intensity * uCustomColor;
|
||||
|
||||
float a = length(rgb) * uOpacity * cardMask(gl_FragCoord.xy);
|
||||
fragColor = vec4(finalColor * a, a);
|
||||
}`
|
||||
|
||||
const ctnDom = ref<HTMLDivElement | null>(null)
|
||||
|
||||
let elapsed = 0
|
||||
let lastT = 0
|
||||
|
||||
useCardShader(ctnDom, {
|
||||
vertex: VERT,
|
||||
fragment: FRAG,
|
||||
mouse: true,
|
||||
renderScale: 0.55,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
uCustomColor: { value: new Float32Array(hexToRgbTuple(props.color)) },
|
||||
uSpeed: { value: 0.4 },
|
||||
uScale: { value: 1 },
|
||||
uOpacity: { value: 1 },
|
||||
uMouse: { value: new Float32Array([0, 0]) },
|
||||
uMouseActive: { value: 0 },
|
||||
uIterations: { value: 48 },
|
||||
uStepScale: { value: 60 / 48 },
|
||||
},
|
||||
color: () => props.color,
|
||||
colorUniforms: { uCustomColor: 0 },
|
||||
frame: ({ t, program, mouse }) => {
|
||||
const u = program.uniforms
|
||||
// Zeit akkumulieren, damit Pausen (Tab-Wechsel) nicht springen
|
||||
const delta = Math.min((t - lastT) * 0.001, 0.1)
|
||||
lastT = t
|
||||
elapsed += delta
|
||||
u.iTime.value = elapsed
|
||||
const res = u.uCardRes.value as Float32Array
|
||||
const m = u.uMouse.value as Float32Array
|
||||
m[0] = mouse.x * res[0]
|
||||
m[1] = (1 - mouse.y) * res[1]
|
||||
u.uMouseActive.value = mouse.active
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { hexToRgbTuple, shadeHex } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const VERT = `#version 300 es
|
||||
in vec2 position;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
uniform float iTime;
|
||||
uniform float uSpeed;
|
||||
uniform float uSweepSpeed;
|
||||
uniform float uSweepWidth;
|
||||
uniform float uSweepFalloff;
|
||||
uniform float uScale;
|
||||
uniform float uFrequency;
|
||||
uniform float uRipple;
|
||||
uniform float uBandDensity;
|
||||
uniform float uLineSharpness;
|
||||
uniform float uGlow;
|
||||
uniform float uColorSpread;
|
||||
uniform float uBrightness;
|
||||
uniform float uContrast;
|
||||
uniform float uSoftness;
|
||||
uniform float uVignette;
|
||||
uniform float uOpacity;
|
||||
uniform float uScanline;
|
||||
uniform float uGrain;
|
||||
uniform float uGrainIntensity;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uMouseRadius;
|
||||
uniform float uMouseStrength;
|
||||
uniform float uMouseActive;
|
||||
uniform vec3 uColor1;
|
||||
uniform vec3 uColor2;
|
||||
uniform vec3 uColor3;
|
||||
uniform vec2 uCardRes;
|
||||
uniform float uRadius;
|
||||
out vec4 fragColor;
|
||||
|
||||
const float TAU = 6.2831853;
|
||||
|
||||
float signalField(vec2 p, float t) {
|
||||
float w = sin(p.x * 1.3 + t * 0.7);
|
||||
w += sin(p.y * 1.7 - t * 0.52) * 0.8;
|
||||
w += sin((p.x + p.y) * 0.9 + t * 0.91) * 0.6;
|
||||
w += sin((p.x - p.y) * 1.53 - t * 0.63) * 0.42;
|
||||
return w * 0.35;
|
||||
}
|
||||
|
||||
vec3 palette(float f) {
|
||||
f = clamp(f, 0.0, 1.0);
|
||||
f = pow(f, uContrast);
|
||||
vec3 c = mix(uColor1, uColor2, smoothstep(0.08, 0.6, f));
|
||||
return mix(c, uColor3, smoothstep(0.68, 1.0, f));
|
||||
}
|
||||
|
||||
float scanBand(float x, float aa, float sharp) {
|
||||
float v = mix(0.5, 0.5 + 0.5 * cos(x * TAU), aa);
|
||||
return pow(v, sharp);
|
||||
}
|
||||
|
||||
float cardMask(vec2 fc) {
|
||||
vec2 p = fc - uCardRes * 0.5;
|
||||
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
return 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Pixelkonsistent: auf Referenzhöhe 180px normieren,
|
||||
// damit Bänder/Wellen in flachen Containern nicht stauchen
|
||||
vec2 uv0 = (gl_FragCoord.xy * 2.0 - uCardRes.xy) / 180.0;
|
||||
vec2 p = uv0 / max(uScale, 0.001);
|
||||
|
||||
float t = iTime * uSpeed;
|
||||
|
||||
vec2 mUv = (uMouse * 2.0 - 1.0) * (uCardRes / 360.0);
|
||||
vec2 md = uv0 - mUv;
|
||||
float r = max(uMouseRadius, 0.001);
|
||||
float mouseBoost = exp(-dot(md, md) / (r * r)) * uMouseStrength * uMouseActive;
|
||||
|
||||
float axis = p.y;
|
||||
|
||||
float sig = signalField(p * uFrequency, t);
|
||||
float coord = axis + sig * uRipple;
|
||||
|
||||
float phase = coord / max(uSweepWidth, 0.05) - t * uSweepSpeed;
|
||||
float sweep = pow(0.5 + 0.5 * cos(phase * TAU), max(uSweepFalloff, 0.1));
|
||||
|
||||
float lc = coord * uBandDensity;
|
||||
float aa = 1.0 / (1.0 + uSoftness * fwidth(lc) * 3.0);
|
||||
aa = clamp(aa * (1.0 + mouseBoost * 0.6), 0.0, 1.0);
|
||||
|
||||
float bodyBase = clamp(0.5 + 0.5 * sig, 0.0, 1.0);
|
||||
float body = bodyBase * bodyBase * uGlow * sweep;
|
||||
|
||||
float sharp = max(uLineSharpness, 0.1);
|
||||
float split = uColorSpread * 0.16;
|
||||
float fr = clamp(scanBand(lc + split, aa, sharp) * sweep + body, 0.0, 1.0);
|
||||
float fg = clamp(scanBand(lc, aa, sharp) * sweep + body, 0.0, 1.0);
|
||||
float fb = clamp(scanBand(lc - split, aa, sharp) * sweep + body, 0.0, 1.0);
|
||||
|
||||
vec3 col = vec3(palette(fr).r, palette(fg).g, palette(fb).b);
|
||||
|
||||
float inten = (fr + fg + fb) * 0.3333333 * uBrightness;
|
||||
inten *= 1.0 + mouseBoost * 0.9;
|
||||
inten *= 1.0 - 0.18 * (0.5 + 0.5 * cos(gl_FragCoord.y * 1.7));
|
||||
|
||||
if (inten > 0.01) {
|
||||
float g = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233)) + iTime) * 43758.5453);
|
||||
inten += (g - 0.5) * uGrainIntensity;
|
||||
}
|
||||
|
||||
inten *= clamp(1.0 - uVignette * smoothstep(0.55, 1.65, length(uv0)), 0.0, 1.0);
|
||||
inten = clamp(inten, 0.0, 1.0);
|
||||
|
||||
float a = clamp(inten * uOpacity, 0.0, 1.0) * cardMask(gl_FragCoord.xy);
|
||||
fragColor = vec4(clamp(col, 0.0, 1.0) * a, a);
|
||||
}
|
||||
`
|
||||
|
||||
const ctnDom = ref<HTMLDivElement | null>(null)
|
||||
|
||||
useCardShader(ctnDom, {
|
||||
vertex: VERT,
|
||||
fragment: FRAG,
|
||||
mouse: true,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
uSpeed: { value: 0.5 },
|
||||
uSweepSpeed: { value: 0.25 },
|
||||
uSweepWidth: { value: 1.6 },
|
||||
uSweepFalloff: { value: 6 },
|
||||
uScale: { value: 1.5 },
|
||||
uFrequency: { value: 2 },
|
||||
uRipple: { value: 0.22 },
|
||||
uBandDensity: { value: 11 },
|
||||
uLineSharpness: { value: 5.5 },
|
||||
uGlow: { value: 0.22 },
|
||||
uColorSpread: { value: 0.7 },
|
||||
uBrightness: { value: 1.0 },
|
||||
uContrast: { value: 1.15 },
|
||||
uSoftness: { value: 1.4 },
|
||||
uVignette: { value: 0.45 },
|
||||
uOpacity: { value: 1.0 },
|
||||
uScanline: { value: 1.0 },
|
||||
uGrain: { value: 1.0 },
|
||||
uGrainIntensity: { value: 0.05 },
|
||||
uMouse: { value: new Float32Array([0.5, 0.5]) },
|
||||
uMouseRadius: { value: 0.5 },
|
||||
uMouseStrength: { value: 0.5 },
|
||||
uMouseActive: { value: 0 },
|
||||
uColor1: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, -0.55))) },
|
||||
uColor2: { value: new Float32Array(hexToRgbTuple(props.color)) },
|
||||
uColor3: { value: new Float32Array([1, 1, 1]) },
|
||||
},
|
||||
color: () => props.color,
|
||||
colorUniforms: { uColor1: -0.55, uColor2: 0 },
|
||||
frame: ({ t, program, mouse }) => {
|
||||
const u = program.uniforms
|
||||
u.iTime.value = t * 0.001
|
||||
const m = u.uMouse.value as Float32Array
|
||||
m[0] = mouse.x
|
||||
m[1] = mouse.y
|
||||
u.uMouseActive.value = mouse.active
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup lang="ts">
|
||||
import { hexToRgbTuple, shadeHex } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop,
|
||||
// Scroll-Grid + Hover-Fill/Trail auf 2D-Canvas
|
||||
const props = withDefaults(defineProps<{
|
||||
color?: string
|
||||
shape?: 'square' | 'hexagon' | 'circle' | 'triangle'
|
||||
}>(), {
|
||||
color: '#6366f1',
|
||||
shape: 'square',
|
||||
})
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const squareSize = 30
|
||||
const speed = 0.6
|
||||
const hoverTrailAmount = 10
|
||||
let borderColor = ''
|
||||
let hoverFill = ''
|
||||
let edgeRgba = ''
|
||||
const updateColors = () => {
|
||||
const [r, g, b] = hexToRgbTuple(props.color)
|
||||
borderColor = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 0.3)`
|
||||
hoverFill = props.color
|
||||
const edgeColor = hexToRgbTuple(shadeHex(props.color, -0.85))
|
||||
edgeRgba = `rgba(${Math.round(edgeColor[0] * 255)}, ${Math.round(edgeColor[1] * 255)}, ${Math.round(edgeColor[2] * 255)}, 0.9)`
|
||||
}
|
||||
updateColors()
|
||||
// Farbwechsel live übernehmen (Settings-Vorschau)
|
||||
watch(() => props.color, updateColors)
|
||||
|
||||
const isHex = props.shape === 'hexagon'
|
||||
const isTri = props.shape === 'triangle'
|
||||
const hexHoriz = squareSize * 1.5
|
||||
const hexVert = squareSize * Math.sqrt(3)
|
||||
|
||||
const offset = { x: 0, y: 0 }
|
||||
let hovered: { x: number, y: number } | null = null
|
||||
const trail: { x: number, y: number }[] = []
|
||||
const opacities = new Map<string, number>()
|
||||
|
||||
const resizeCanvas = () => {
|
||||
canvas.width = canvas.offsetWidth
|
||||
canvas.height = canvas.offsetHeight
|
||||
}
|
||||
|
||||
const drawHex = (cx: number, cy: number, size: number) => {
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i
|
||||
const vx = cx + size * Math.cos(angle)
|
||||
const vy = cy + size * Math.sin(angle)
|
||||
if (i === 0) ctx.moveTo(vx, vy)
|
||||
else ctx.lineTo(vx, vy)
|
||||
}
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
const drawCircle = (cx: number, cy: number, size: number) => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, size / 2, 0, Math.PI * 2)
|
||||
}
|
||||
|
||||
const drawTriangle = (cx: number, cy: number, size: number, flip: boolean) => {
|
||||
ctx.beginPath()
|
||||
if (flip) {
|
||||
ctx.moveTo(cx, cy + size / 2)
|
||||
ctx.lineTo(cx + size / 2, cy - size / 2)
|
||||
ctx.lineTo(cx - size / 2, cy - size / 2)
|
||||
}
|
||||
else {
|
||||
ctx.moveTo(cx, cy - size / 2)
|
||||
ctx.lineTo(cx + size / 2, cy + size / 2)
|
||||
ctx.lineTo(cx - size / 2, cy + size / 2)
|
||||
}
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
const drawGrid = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const cell = (key: string, draw: () => void) => {
|
||||
const alpha = opacities.get(key)
|
||||
ctx.strokeStyle = borderColor
|
||||
ctx.globalAlpha = 0.55
|
||||
if (alpha) {
|
||||
ctx.globalAlpha = alpha
|
||||
ctx.fillStyle = hoverFill
|
||||
draw()
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = 1
|
||||
draw()
|
||||
ctx.stroke()
|
||||
}
|
||||
else {
|
||||
draw()
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
if (isHex) {
|
||||
const colShift = Math.floor(offset.x / hexHoriz)
|
||||
const offsetX = ((offset.x % hexHoriz) + hexHoriz) % hexHoriz
|
||||
const offsetY = ((offset.y % hexVert) + hexVert) % hexVert
|
||||
const cols = Math.ceil(canvas.width / hexHoriz) + 3
|
||||
const rows = Math.ceil(canvas.height / hexVert) + 3
|
||||
for (let col = -2; col < cols; col++) {
|
||||
for (let row = -2; row < rows; row++) {
|
||||
const cx = col * hexHoriz + offsetX
|
||||
const cy = row * hexVert + ((col + colShift) % 2 !== 0 ? hexVert / 2 : 0) + offsetY
|
||||
cell(`${col},${row}`, () => drawHex(cx, cy, squareSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (isTri) {
|
||||
const halfW = squareSize / 2
|
||||
const colShift = Math.floor(offset.x / halfW)
|
||||
const rowShift = Math.floor(offset.y / squareSize)
|
||||
const offsetX = ((offset.x % halfW) + halfW) % halfW
|
||||
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
|
||||
const cols = Math.ceil(canvas.width / halfW) + 4
|
||||
const rows = Math.ceil(canvas.height / squareSize) + 4
|
||||
for (let col = -2; col < cols; col++) {
|
||||
for (let row = -2; row < rows; row++) {
|
||||
const cx = col * halfW + offsetX
|
||||
const cy = row * squareSize + squareSize / 2 + offsetY
|
||||
const flip = (((col + colShift + row + rowShift) % 2) + 2) % 2 !== 0
|
||||
cell(`${col},${row}`, () => drawTriangle(cx, cy, squareSize, flip))
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (props.shape === 'circle') {
|
||||
const offsetX = ((offset.x % squareSize) + squareSize) % squareSize
|
||||
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
|
||||
const cols = Math.ceil(canvas.width / squareSize) + 3
|
||||
const rows = Math.ceil(canvas.height / squareSize) + 3
|
||||
for (let col = -2; col < cols; col++) {
|
||||
for (let row = -2; row < rows; row++) {
|
||||
const cx = col * squareSize + squareSize / 2 + offsetX
|
||||
const cy = row * squareSize + squareSize / 2 + offsetY
|
||||
cell(`${col},${row}`, () => drawCircle(cx, cy, squareSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
const offsetX = ((offset.x % squareSize) + squareSize) % squareSize
|
||||
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
|
||||
const cols = Math.ceil(canvas.width / squareSize) + 3
|
||||
const rows = Math.ceil(canvas.height / squareSize) + 3
|
||||
for (let col = -2; col < cols; col++) {
|
||||
for (let row = -2; row < rows; row++) {
|
||||
const sx = col * squareSize + offsetX
|
||||
const sy = row * squareSize + offsetY
|
||||
cell(`${col},${row}`, () => {
|
||||
ctx.beginPath()
|
||||
ctx.rect(sx, sy, squareSize, squareSize)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dunkle Rand-Vignette, aus der Effektfarbe abgeleitet
|
||||
const gradient = ctx.createRadialGradient(
|
||||
canvas.width / 2, canvas.height / 2, 0,
|
||||
canvas.width / 2, canvas.height / 2,
|
||||
Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2,
|
||||
)
|
||||
gradient.addColorStop(0, 'rgba(0, 0, 0, 0)')
|
||||
gradient.addColorStop(1, edgeRgba)
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
const updateCellOpacities = () => {
|
||||
const targets = new Map<string, number>()
|
||||
if (hovered) targets.set(`${hovered.x},${hovered.y}`, 1)
|
||||
for (let i = 0; i < trail.length; i++) {
|
||||
const t = trail[i]
|
||||
const key = `${t.x},${t.y}`
|
||||
if (!targets.has(key)) targets.set(key, (trail.length - i) / (trail.length + 1))
|
||||
}
|
||||
for (const [key] of targets) {
|
||||
if (!opacities.has(key)) opacities.set(key, 0)
|
||||
}
|
||||
for (const [key, opacity] of opacities) {
|
||||
const next = opacity + ((targets.get(key) || 0) - opacity) * 0.15
|
||||
if (next < 0.005) opacities.delete(key)
|
||||
else opacities.set(key, next)
|
||||
}
|
||||
}
|
||||
|
||||
let raf = 0
|
||||
let visible = true
|
||||
const updateAnimation = () => {
|
||||
if (!visible || document.hidden) {
|
||||
raf = 0
|
||||
return
|
||||
}
|
||||
raf = requestAnimationFrame(updateAnimation)
|
||||
|
||||
const wrapX = isHex ? hexHoriz * 2 : squareSize
|
||||
const wrapY = isHex ? hexVert : isTri ? squareSize * 2 : squareSize
|
||||
// diagonal
|
||||
offset.x = (offset.x - speed + wrapX) % wrapX
|
||||
offset.y = (offset.y - speed + wrapY) % wrapY
|
||||
|
||||
updateCellOpacities()
|
||||
drawGrid()
|
||||
}
|
||||
|
||||
const updateHovered = (col: number, row: number) => {
|
||||
if (!hovered || hovered.x !== col || hovered.y !== row) {
|
||||
if (hovered) {
|
||||
trail.unshift({ ...hovered })
|
||||
if (trail.length > hoverTrailAmount) trail.length = hoverTrailAmount
|
||||
}
|
||||
hovered = { x: col, y: row }
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (event.clientX < rect.left || event.clientX > rect.right
|
||||
|| event.clientY < rect.top || event.clientY > rect.bottom) {
|
||||
if (hovered) {
|
||||
trail.unshift({ ...hovered })
|
||||
if (trail.length > hoverTrailAmount) trail.length = hoverTrailAmount
|
||||
hovered = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const mouseX = event.clientX - rect.left
|
||||
const mouseY = event.clientY - rect.top
|
||||
|
||||
if (isHex) {
|
||||
const colShift = Math.floor(offset.x / hexHoriz)
|
||||
const offsetX = ((offset.x % hexHoriz) + hexHoriz) % hexHoriz
|
||||
const offsetY = ((offset.y % hexVert) + hexVert) % hexVert
|
||||
const adjustedX = mouseX - offsetX
|
||||
const adjustedY = mouseY - offsetY
|
||||
const col = Math.round(adjustedX / hexHoriz)
|
||||
const rowOffset = (col + colShift) % 2 !== 0 ? hexVert / 2 : 0
|
||||
const row = Math.round((adjustedY - rowOffset) / hexVert)
|
||||
updateHovered(col, row)
|
||||
}
|
||||
else if (isTri) {
|
||||
const halfW = squareSize / 2
|
||||
const offsetX = ((offset.x % halfW) + halfW) % halfW
|
||||
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
|
||||
const col = Math.round((mouseX - offsetX) / halfW)
|
||||
const row = Math.floor((mouseY - offsetY) / squareSize)
|
||||
updateHovered(col, row)
|
||||
}
|
||||
else {
|
||||
const offsetX = ((offset.x % squareSize) + squareSize) % squareSize
|
||||
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
|
||||
const rounded = props.shape === 'circle' ? Math.round : Math.floor
|
||||
const col = rounded((mouseX - offsetX) / squareSize)
|
||||
const row = rounded((mouseY - offsetY) / squareSize)
|
||||
updateHovered(col, row)
|
||||
}
|
||||
}
|
||||
|
||||
const io = new IntersectionObserver(([entry]) => {
|
||||
visible = entry?.isIntersecting ?? true
|
||||
if (visible && !raf) raf = requestAnimationFrame(updateAnimation)
|
||||
})
|
||||
io.observe(canvas)
|
||||
|
||||
const ro = new ResizeObserver(resizeCanvas)
|
||||
ro.observe(canvas)
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: true })
|
||||
|
||||
resizeCanvas()
|
||||
raf = requestAnimationFrame(updateAnimation)
|
||||
|
||||
cleanup = () => {
|
||||
cancelAnimationFrame(raf)
|
||||
io.disconnect()
|
||||
ro.disconnect()
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup?.()
|
||||
cleanup = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvasRef" class="absolute inset-0 block size-full rounded-[inherit] border-none" />
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { hexToRgbTuple } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), Plane+Camera auf Fullscreen-Triangle
|
||||
// vereinfacht, nur color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 uv;
|
||||
attribute vec2 position;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `
|
||||
precision highp float;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
uniform float uSpeed;
|
||||
uniform float uScale;
|
||||
uniform float uRotation;
|
||||
uniform float uNoiseIntensity;
|
||||
uniform vec2 uCardRes;
|
||||
uniform float uRadius;
|
||||
|
||||
const float e = 2.71828182845904523536;
|
||||
|
||||
float noise(vec2 texCoord) {
|
||||
float G = e;
|
||||
vec2 r = (G * sin(G * texCoord));
|
||||
return fract(r.x * r.y * (1.0 + texCoord.x));
|
||||
}
|
||||
|
||||
vec2 rotateUvs(vec2 uv, float angle) {
|
||||
float c = cos(angle);
|
||||
float s = sin(angle);
|
||||
mat2 rot = mat2(c, -s, s, c);
|
||||
return rot * uv;
|
||||
}
|
||||
|
||||
float cardMask(vec2 fc) {
|
||||
vec2 p = fc - uCardRes * 0.5;
|
||||
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
return 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float rnd = noise(gl_FragCoord.xy);
|
||||
// Pixelkonsistent: Muster-Koordinaten auf eine Referenzgröße von 180px
|
||||
// normieren, damit flache Container den Effekt nicht vertikal stauchen
|
||||
vec2 suv = vUv * (uCardRes / 180.0);
|
||||
vec2 uv = rotateUvs(suv * uScale, uRotation);
|
||||
vec2 tex = uv * uScale;
|
||||
float tOffset = uSpeed * uTime;
|
||||
|
||||
tex.y += 0.03 * sin(8.0 * tex.x - tOffset);
|
||||
|
||||
float pattern = 0.6 +
|
||||
0.4 * sin(5.0 * (tex.x + tex.y +
|
||||
cos(3.0 * tex.x + 5.0 * tex.y) +
|
||||
0.02 * tOffset) +
|
||||
sin(20.0 * (tex.x + tex.y - 0.1 * tOffset)));
|
||||
|
||||
vec4 col = vec4(uColor, 1.0) * vec4(pattern) - rnd / 15.0 * uNoiseIntensity;
|
||||
|
||||
float m = cardMask(gl_FragCoord.xy);
|
||||
float a = clamp(pattern * 0.45, 0.0, 1.0) * m;
|
||||
gl_FragColor = vec4(col.rgb * a, a);
|
||||
}
|
||||
`
|
||||
|
||||
const ctnDom = ref<HTMLDivElement | null>(null)
|
||||
|
||||
let elapsed = 0
|
||||
let lastT = 0
|
||||
|
||||
useCardShader(ctnDom, {
|
||||
vertex: VERT,
|
||||
fragment: FRAG,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new Float32Array(hexToRgbTuple(props.color)) },
|
||||
uSpeed: { value: 5 },
|
||||
uScale: { value: 1 },
|
||||
uNoiseIntensity: { value: 1.5 },
|
||||
uRotation: { value: 0 },
|
||||
},
|
||||
color: () => props.color,
|
||||
colorUniforms: { uColor: 0 },
|
||||
frame: ({ t, program }) => {
|
||||
const delta = Math.min((t - lastT) * 0.001, 0.1)
|
||||
lastT = t
|
||||
elapsed += 0.1 * delta
|
||||
program.uniforms.uTime.value = elapsed
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { hexToRgbTuple } from '@/lib/color'
|
||||
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 uv;
|
||||
attribute vec2 position;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG = `
|
||||
precision highp float;
|
||||
|
||||
uniform float uTime;
|
||||
uniform float uSpeed;
|
||||
uniform float uScale;
|
||||
uniform float uBrightness;
|
||||
uniform vec3 uColor1;
|
||||
uniform vec3 uColor2;
|
||||
uniform float uNoiseFreq;
|
||||
uniform float uNoiseAmp;
|
||||
uniform float uBandHeight;
|
||||
uniform float uBandSpread;
|
||||
uniform float uOctaveDecay;
|
||||
uniform float uLayerOffset;
|
||||
uniform float uColorSpeed;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uMouseInfluence;
|
||||
uniform vec2 uCardRes;
|
||||
uniform float uRadius;
|
||||
|
||||
#define TAU 6.28318
|
||||
|
||||
vec3 gradientHash(vec3 p) {
|
||||
p = vec3(
|
||||
dot(p, vec3(127.1, 311.7, 234.6)),
|
||||
dot(p, vec3(269.5, 183.3, 198.3)),
|
||||
dot(p, vec3(169.5, 283.3, 156.9))
|
||||
);
|
||||
vec3 h = fract(sin(p) * 43758.5453123);
|
||||
float phi = acos(2.0 * h.x - 1.0);
|
||||
float theta = TAU * h.y;
|
||||
return vec3(cos(theta) * sin(phi), sin(theta) * cos(phi), cos(phi));
|
||||
}
|
||||
|
||||
float quinticSmooth(float t) {
|
||||
float t2 = t * t;
|
||||
float t3 = t * t2;
|
||||
return 6.0 * t3 * t2 - 15.0 * t2 * t2 + 10.0 * t3;
|
||||
}
|
||||
|
||||
vec3 cosineGradient(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
|
||||
return a + b * cos(TAU * (c * t + d));
|
||||
}
|
||||
|
||||
float perlin3D(float amplitude, float frequency, float px, float py, float pz) {
|
||||
float x = px * frequency;
|
||||
float y = py * frequency;
|
||||
|
||||
float fx = floor(x); float fy = floor(y); float fz = floor(pz);
|
||||
float cx = ceil(x); float cy = ceil(y); float cz = ceil(pz);
|
||||
|
||||
vec3 g000 = gradientHash(vec3(fx, fy, fz));
|
||||
vec3 g100 = gradientHash(vec3(cx, fy, fz));
|
||||
vec3 g010 = gradientHash(vec3(fx, cy, fz));
|
||||
vec3 g110 = gradientHash(vec3(cx, cy, fz));
|
||||
vec3 g001 = gradientHash(vec3(fx, fy, cz));
|
||||
vec3 g101 = gradientHash(vec3(cx, fy, cz));
|
||||
vec3 g011 = gradientHash(vec3(fx, cy, cz));
|
||||
vec3 g111 = gradientHash(vec3(cx, cy, cz));
|
||||
|
||||
float d000 = dot(g000, vec3(x - fx, y - fy, pz - fz));
|
||||
float d100 = dot(g100, vec3(x - cx, y - fy, pz - fz));
|
||||
float d010 = dot(g010, vec3(x - fx, y - cy, pz - fz));
|
||||
float d110 = dot(g110, vec3(x - cx, y - cy, pz - fz));
|
||||
float d001 = dot(g001, vec3(x - fx, y - fy, pz - cz));
|
||||
float d101 = dot(g101, vec3(x - cx, y - fy, pz - cz));
|
||||
float d011 = dot(g011, vec3(x - fx, y - cy, pz - cz));
|
||||
float d111 = dot(g111, vec3(x - cx, y - cy, pz - cz));
|
||||
|
||||
float sx = quinticSmooth(x - fx);
|
||||
float sy = quinticSmooth(y - fy);
|
||||
float sz = quinticSmooth(pz - fz);
|
||||
|
||||
float lx00 = mix(d000, d100, sx);
|
||||
float lx10 = mix(d010, d110, sx);
|
||||
float lx01 = mix(d001, d101, sx);
|
||||
float lx11 = mix(d011, d111, sx);
|
||||
|
||||
float ly0 = mix(lx00, lx10, sy);
|
||||
float ly1 = mix(lx01, lx11, sy);
|
||||
|
||||
return amplitude * mix(ly0, ly1, sz);
|
||||
}
|
||||
|
||||
float auroraGlow(float t, vec2 shift) {
|
||||
// Pixelkonsistent: Referenzhöhe 180px — die Noise-/Band-Struktur behält
|
||||
// in flachen Containern ihre reale Größe statt zu stauchen
|
||||
vec2 uv = gl_FragCoord.xy / 180.0;
|
||||
uv += shift;
|
||||
|
||||
float noiseVal = 0.0;
|
||||
float freq = uNoiseFreq;
|
||||
float amp = uNoiseAmp;
|
||||
vec2 samplePos = uv * uScale;
|
||||
|
||||
for (float i = 0.0; i < 3.0; i += 1.0) {
|
||||
noiseVal += perlin3D(amp, freq, samplePos.x, samplePos.y, t);
|
||||
amp *= uOctaveDecay;
|
||||
freq *= 2.0;
|
||||
}
|
||||
|
||||
float yBand = uv.y * 10.0 - uBandHeight * 10.0;
|
||||
return 0.3 * max(exp(uBandSpread * (1.0 - 1.1 * abs(noiseVal + yBand))), 0.0);
|
||||
}
|
||||
|
||||
float cardMask(vec2 fc) {
|
||||
vec2 p = fc - uCardRes * 0.5;
|
||||
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
|
||||
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
|
||||
return 1.0 - smoothstep(-0.75, 0.75, rd);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / uCardRes.xy;
|
||||
float t = uSpeed * 0.4 * uTime;
|
||||
|
||||
vec2 shift = (uMouse - 0.5) * uMouseInfluence;
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
col += 0.99 * auroraGlow(t, shift) * cosineGradient(uv.x + uTime * uSpeed * 0.2 * uColorSpeed, vec3(0.5), vec3(0.5), vec3(1.0), vec3(0.3, 0.20, 0.20)) * uColor1;
|
||||
col += 0.99 * auroraGlow(t + uLayerOffset, shift) * cosineGradient(uv.x + uTime * uSpeed * 0.1 * uColorSpeed, vec3(0.5), vec3(0.5), vec3(2.0, 1.0, 0.0), vec3(0.5, 0.20, 0.25)) * uColor2;
|
||||
|
||||
col *= uBrightness;
|
||||
float alpha = clamp(length(col), 0.0, 1.0) * cardMask(gl_FragCoord.xy);
|
||||
gl_FragColor = vec4(col * alpha, alpha);
|
||||
}
|
||||
`
|
||||
|
||||
const ctnDom = ref<HTMLDivElement | null>(null)
|
||||
|
||||
useCardShader(ctnDom, {
|
||||
vertex: VERT,
|
||||
fragment: FRAG,
|
||||
mouse: true,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uSpeed: { value: 0.6 },
|
||||
uScale: { value: 1.5 },
|
||||
uBrightness: { value: 1.0 },
|
||||
uColor1: { value: new Float32Array([0.97, 0.97, 0.97]) },
|
||||
uColor2: { value: new Float32Array(hexToRgbTuple(props.color)) },
|
||||
uNoiseFreq: { value: 2.5 },
|
||||
uNoiseAmp: { value: 1.0 },
|
||||
uBandHeight: { value: 0.5 },
|
||||
uBandSpread: { value: 1.0 },
|
||||
uOctaveDecay: { value: 0.1 },
|
||||
uLayerOffset: { value: 0 },
|
||||
uColorSpeed: { value: 1.0 },
|
||||
uMouse: { value: new Float32Array([0.5, 0.5]) },
|
||||
uMouseInfluence: { value: 0.25 },
|
||||
},
|
||||
color: () => props.color,
|
||||
colorUniforms: { uColor2: 0 },
|
||||
frame: ({ t, program, mouse }) => {
|
||||
const u = program.uniforms
|
||||
u.uTime.value = t * 0.001
|
||||
const m = u.uMouse.value as Float32Array
|
||||
m[0] = mouse.x
|
||||
m[1] = mouse.y
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
|
||||
</template>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop,
|
||||
// ohne Cursor-Interaktion (Hover-Card ist zu klein dafür)
|
||||
const props = withDefaults(defineProps<{ color?: string }>(), {
|
||||
color: '#6366f1',
|
||||
})
|
||||
|
||||
class Grad {
|
||||
constructor(
|
||||
public x: number,
|
||||
public y: number,
|
||||
public z: number,
|
||||
) {}
|
||||
|
||||
dot2(x: number, y: number): number {
|
||||
return this.x * x + this.y * y
|
||||
}
|
||||
}
|
||||
|
||||
class Noise {
|
||||
grad3 = [
|
||||
new Grad(1, 1, 0), new Grad(-1, 1, 0), new Grad(1, -1, 0), new Grad(-1, -1, 0),
|
||||
new Grad(1, 0, 1), new Grad(-1, 0, 1), new Grad(1, 0, -1), new Grad(-1, 0, -1),
|
||||
new Grad(0, 1, 1), new Grad(0, -1, 1), new Grad(0, 1, -1), new Grad(0, -1, -1),
|
||||
]
|
||||
|
||||
p = [
|
||||
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240,
|
||||
21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88,
|
||||
237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83,
|
||||
111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216,
|
||||
80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186,
|
||||
3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58,
|
||||
17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
|
||||
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193,
|
||||
238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
|
||||
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128,
|
||||
195, 78, 66, 215, 61, 156, 180,
|
||||
]
|
||||
|
||||
perm = new Array(512)
|
||||
gradP = new Array(512)
|
||||
|
||||
constructor(seed = 0) {
|
||||
this.seed(seed)
|
||||
}
|
||||
|
||||
seed(seed: number) {
|
||||
if (seed > 0 && seed < 1) seed *= 65536
|
||||
seed = Math.floor(seed)
|
||||
if (seed < 256) seed |= seed << 8
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const v = i & 1 ? this.p[i]! ^ (seed & 255) : this.p[i]! ^ ((seed >> 8) & 255)
|
||||
this.perm[i] = this.perm[i + 256] = v
|
||||
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12]
|
||||
}
|
||||
}
|
||||
|
||||
fade(t: number): number {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10)
|
||||
}
|
||||
|
||||
lerp(a: number, b: number, t: number): number {
|
||||
return (1 - t) * a + t * b
|
||||
}
|
||||
|
||||
perlin2(x: number, y: number): number {
|
||||
let X = Math.floor(x)
|
||||
let Y = Math.floor(y)
|
||||
x -= X
|
||||
y -= Y
|
||||
X &= 255
|
||||
Y &= 255
|
||||
|
||||
const n00 = this.gradP[X + this.perm[Y]!]!.dot2(x, y)
|
||||
const n01 = this.gradP[X + this.perm[Y + 1]!]!.dot2(x, y - 1)
|
||||
const n10 = this.gradP[X + 1 + this.perm[Y]!]!.dot2(x - 1, y)
|
||||
const n11 = this.gradP[X + 1 + this.perm[Y + 1]!]!.dot2(x - 1, y - 1)
|
||||
const u = this.fade(x)
|
||||
|
||||
return this.lerp(this.lerp(n00, n10, u), this.lerp(n01, n11, u), this.fade(y))
|
||||
}
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
wave: { x: number, y: number }
|
||||
}
|
||||
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
|
||||
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let bounding = { width: 0, height: 0 }
|
||||
let cornerRadius = 0
|
||||
let noise: Noise | null = null
|
||||
let lines: Point[][] = []
|
||||
let frameId: number | null = null
|
||||
|
||||
const xGap = 10
|
||||
const yGap = 26
|
||||
|
||||
function setSize() {
|
||||
const container = containerRef.value
|
||||
const canvas = canvasRef.value
|
||||
if (!container || !canvas) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
bounding = { width: rect.width, height: rect.height }
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
ctx?.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
cornerRadius = parseFloat(getComputedStyle(container).borderTopLeftRadius) || 0
|
||||
}
|
||||
|
||||
function setLines() {
|
||||
const { width, height } = bounding
|
||||
lines = []
|
||||
const oWidth = width + 200
|
||||
const oHeight = height + 30
|
||||
const totalLines = Math.ceil(oWidth / xGap)
|
||||
const totalPoints = Math.ceil(oHeight / yGap)
|
||||
const xStart = (width - xGap * totalLines) / 2
|
||||
const yStart = (height - yGap * totalPoints) / 2
|
||||
|
||||
for (let i = 0; i <= totalLines; i++) {
|
||||
const pts: Point[] = []
|
||||
for (let j = 0; j <= totalPoints; j++) {
|
||||
pts.push({ x: xStart + xGap * i, y: yStart + yGap * j, wave: { x: 0, y: 0 } })
|
||||
}
|
||||
lines.push(pts)
|
||||
}
|
||||
}
|
||||
|
||||
function tick(t: number) {
|
||||
if (!ctx || !noise) return
|
||||
const { width, height } = bounding
|
||||
|
||||
for (const pts of lines) {
|
||||
for (const p of pts) {
|
||||
const move = noise.perlin2((p.x + t * 0.0125) * 0.002, (p.y + t * 0.005) * 0.0015) * 12
|
||||
p.wave.x = Math.cos(move) * 32
|
||||
p.wave.y = Math.sin(move) * 16
|
||||
}
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
// Rounded-Rect-Clip direkt im Canvas — zuverlässig auch wenn der
|
||||
// Compositor den Layer nicht an CSS-Clips bindet (Waterfox)
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(0, 0, width, height, cornerRadius)
|
||||
ctx.clip()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = props.color
|
||||
for (const points of lines) {
|
||||
let p1 = points[0]!
|
||||
ctx.moveTo(p1.x + p1.wave.x, p1.y + p1.wave.y)
|
||||
for (const p of points) {
|
||||
ctx.lineTo(Math.round((p.x + p.wave.x) * 10) / 10, Math.round((p.y + p.wave.y) * 10) / 10)
|
||||
}
|
||||
p1 = points[points.length - 1]!
|
||||
ctx.moveTo(p1.x + p1.wave.x, p1.y + p1.wave.y)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
frameId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
const container = containerRef.value
|
||||
if (!canvas || !container) return
|
||||
|
||||
ctx = canvas.getContext('2d')
|
||||
noise = new Noise(Math.random())
|
||||
setSize()
|
||||
setLines()
|
||||
frameId = requestAnimationFrame(tick)
|
||||
|
||||
ro = new ResizeObserver(() => {
|
||||
setSize()
|
||||
setLines()
|
||||
})
|
||||
ro.observe(container)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ro?.disconnect()
|
||||
if (frameId !== null) cancelAnimationFrame(frameId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="relative size-full overflow-hidden rounded-[inherit] opacity-50">
|
||||
<canvas ref="canvasRef" class="block size-full rounded-[inherit]" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
AccordionRoot,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<AccordionRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="accordion"
|
||||
v-bind="forwarded"
|
||||
:class="cn('overflow-hidden rounded-2xl border flex w-full flex-col', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { AccordionContent } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
data-slot="accordion-content"
|
||||
v-bind="delegatedProps"
|
||||
class="data-open:animate-accordion-down data-closed:animate-accordion-up px-4 text-sm overflow-hidden"
|
||||
>
|
||||
<div
|
||||
:class="cn(
|
||||
'pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { AccordionItem, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem
|
||||
v-slot="slotProps"
|
||||
data-slot="accordion-item"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('data-open:bg-muted/50 not-last:border-b', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</AccordionItem>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionTriggerProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
AccordionHeader,
|
||||
AccordionTrigger,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
data-slot="accordion-trigger"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'**:data-[slot=accordion-trigger-icon]:text-muted-foreground gap-6 p-4 text-left text-sm font-medium hover:underline **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<slot name="icon">
|
||||
<Icon name="ph:caret-down-fill" data-slot="accordion-trigger-icon" class="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<Icon name="ph:caret-up-fill" data-slot="accordion-trigger-icon" class="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as Accordion } from './Accordion.vue'
|
||||
export { default as AccordionContent } from './AccordionContent.vue'
|
||||
export { default as AccordionItem } from './AccordionItem.vue'
|
||||
export { default as AccordionTrigger } from './AccordionTrigger.vue'
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { AlertVariants } from '.'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { alertVariants } from '.'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: AlertVariants['variant']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert"
|
||||
:class="cn(alertVariants({ variant }), props.class)"
|
||||
role="alert"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
:class="cn('absolute top-2.5 right-3', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
:class="cn('text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
:class="cn('font-medium group-has-[>svg]/alert:col-start-2 cn-font-heading [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Alert } from './Alert.vue'
|
||||
export { default as AlertAction } from './AlertAction.vue'
|
||||
export { default as AlertDescription } from './AlertDescription.vue'
|
||||
export { default as AlertTitle } from './AlertTitle.vue'
|
||||
|
||||
export const alertVariants = cva('grid gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*=size-])]:size-4 group/alert relative w-full', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive: 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { AvatarVariants } from '.'
|
||||
import { AvatarRoot } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { avatarVariants } from '.'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
size?: AvatarVariants['size']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarRoot
|
||||
data-slot="avatar"
|
||||
:data-size="size ?? 'default'"
|
||||
:class="cn(avatarVariants({ size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AvatarRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
:class="cn(
|
||||
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
|
||||
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
|
||||
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
|
||||
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvatarFallbackProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { AvatarFallback } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarFallback
|
||||
data-slot="avatar-fallback"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-muted text-muted-foreground rounded-sm flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AvatarFallback>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
:class="cn(
|
||||
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
:class="cn(
|
||||
'bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvatarImageProps } from 'reka-ui'
|
||||
import { AvatarImage } from 'reka-ui'
|
||||
|
||||
const props = defineProps<AvatarImageProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarImage
|
||||
data-slot="avatar-image"
|
||||
v-bind="props"
|
||||
class="rounded-sm aspect-square size-full object-cover"
|
||||
>
|
||||
<slot />
|
||||
</AvatarImage>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Avatar } from './Avatar.vue'
|
||||
export { default as AvatarBadge } from './AvatarBadge.vue'
|
||||
export { default as AvatarFallback } from './AvatarFallback.vue'
|
||||
export { default as AvatarGroup } from './AvatarGroup.vue'
|
||||
export { default as AvatarGroupCount } from './AvatarGroupCount.vue'
|
||||
export { default as AvatarImage } from './AvatarImage.vue'
|
||||
|
||||
export const avatarVariants = cva(
|
||||
'size-8 rounded-sm after:rounded-sm data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: '',
|
||||
default: '',
|
||||
lg: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AvatarVariants = VariantProps<typeof avatarVariants>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { BadgeVariants } from '.'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { badgeVariants } from '.'
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Badge } from './Badge.vue'
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'h-5 gap-1 rounded-2xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
data-slot="breadcrumb"
|
||||
:class="cn('', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
:class="cn('size-5 [&>svg]:size-4 flex items-center justify-center', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<Icon name="ph:dots-three-fill" />
|
||||
</slot>
|
||||
<span class="sr-only">More</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
:class="cn('gap-1.5 inline-flex items-center', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
as: 'a',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="breadcrumb-link"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn('hover:text-foreground transition-colors', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
:class="cn('text-muted-foreground gap-1.5 text-sm sm:gap-2.5 flex flex-wrap items-center wrap-break-word', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ol>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
:class="cn('text-foreground font-normal', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
:class="cn('[&>svg]:size-3.5', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<Icon name="ph:caret-right-fill" class="cn-rtl-flip" />
|
||||
</slot>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as Breadcrumb } from './Breadcrumb.vue'
|
||||
export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue'
|
||||
export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
|
||||
export { default as BreadcrumbLink } from './BreadcrumbLink.vue'
|
||||
export { default as BreadcrumbList } from './BreadcrumbList.vue'
|
||||
export { default as BreadcrumbPage } from './BreadcrumbPage.vue'
|
||||
export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue'
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px cursor-pointer [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
outline: 'border-border bg-background dark:bg-transparent hover:bg-muted hover:text-foreground dark:hover:bg-input/30 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
'default': 'h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
|
||||
'xs': 'h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*=size-])]:size-3',
|
||||
'sm': 'h-7 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
'lg': 'h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
|
||||
'icon': 'size-8',
|
||||
'icon-xs': 'size-6 [&_svg:not([class*=size-])]:size-3',
|
||||
'icon-sm': 'size-7',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
size?: 'default' | 'sm'
|
||||
}>(), {
|
||||
size: 'default',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:data-size="size"
|
||||
:class="cn('bg-card text-card-foreground ring-foreground/5 dark:ring-foreground/10 gap-(--card-spacing) overflow-hidden rounded-[min(var(--radius-4xl),24px)] py-(--card-spacing) text-sm shadow-sm ring-1 [--card-spacing:--spacing(5)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)] group/card flex flex-col', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-content"
|
||||
:class="cn('px-(--card-spacing)', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('rounded-b-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-t]:pt-(--card-spacing) flex items-center', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="cn('gap-1.5 rounded-t-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-title"
|
||||
:class="cn('text-base font-medium cn-font-heading', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as CardAction } from './CardAction.vue'
|
||||
export { default as CardContent } from './CardContent.vue'
|
||||
export { default as CardDescription } from './CardDescription.vue'
|
||||
export { default as CardFooter } from './CardFooter.vue'
|
||||
export { default as CardHeader } from './CardHeader.vue'
|
||||
export { default as CardTitle } from './CardTitle.vue'
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ChartConfig } from '.'
|
||||
import { useId } from 'reka-ui'
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { provideChartContext } from '.'
|
||||
import ChartStyle from './ChartStyle.vue'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
id?: HTMLAttributes['id']
|
||||
class?: HTMLAttributes['class']
|
||||
config: ChartConfig
|
||||
cursor?: boolean
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
default: {
|
||||
id: string
|
||||
config: ChartConfig
|
||||
}
|
||||
}>()
|
||||
|
||||
const { config } = toRefs(props)
|
||||
const uniqueId = useId()
|
||||
const chartId = computed(() => `chart-${props.id || uniqueId.replace(/:/g, '')}`)
|
||||
|
||||
provideChartContext({
|
||||
id: uniqueId,
|
||||
config,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="chart"
|
||||
:data-chart="chartId"
|
||||
:class="cn(
|
||||
`cn-chart [&_.tick_text]:!fill-muted-foreground [&_.tick_line]:!stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex flex-col aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden [&_[data-vis-xy-container]]:h-full [&_[data-vis-single-container]]:h-full h-full [&_[data-vis-xy-container]]:w-full [&_[data-vis-single-container]]:w-full w-full `,
|
||||
props.class,
|
||||
)"
|
||||
:style="{
|
||||
'--vis-tooltip-padding': '0px',
|
||||
'--vis-tooltip-background-color': 'transparent',
|
||||
'--vis-tooltip-border-color': 'transparent',
|
||||
'--vis-tooltip-text-color': 'none',
|
||||
'--vis-tooltip-shadow-color': 'none',
|
||||
'--vis-tooltip-backdrop-filter': 'none',
|
||||
'--vis-crosshair-circle-stroke-color': '#0000',
|
||||
'--vis-crosshair-line-stroke-width': cursor ? '1px' : '0px',
|
||||
'--vis-font-family': 'var(--font-sans)',
|
||||
}"
|
||||
>
|
||||
<slot :id="uniqueId" :config="config" />
|
||||
<ChartStyle :id="chartId" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useChart } from '.'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
verticalAlign?: 'bottom' | 'top'
|
||||
// payload?: any[]
|
||||
class?: HTMLAttributes['class']
|
||||
}>(), {
|
||||
verticalAlign: 'bottom',
|
||||
})
|
||||
|
||||
const { id, config } = useChart()
|
||||
|
||||
const payload = computed(() => Object.entries(config.value).map(([key, value]) => {
|
||||
return {
|
||||
key: props.nameKey || key,
|
||||
itemConfig: config.value[key],
|
||||
}
|
||||
}))
|
||||
|
||||
const containerSelector = ref('')
|
||||
onMounted(() => {
|
||||
containerSelector.value = `[data-chart="chart-${id}"]>[data-vis-xy-container]`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="containerSelector"
|
||||
:class="cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<div
|
||||
v-for="{ key, itemConfig } in payload"
|
||||
:key="key"
|
||||
:class="cn(
|
||||
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
|
||||
)"
|
||||
>
|
||||
<component :is="itemConfig?.icon" v-if="itemConfig?.icon" />
|
||||
<div
|
||||
v-else
|
||||
class="h-2 w-2 shrink-0 rounded-xs"
|
||||
:style="{
|
||||
backgroundColor: itemConfig?.color,
|
||||
}"
|
||||
/>
|
||||
|
||||
{{ itemConfig?.label }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import { THEMES, useChart } from '.'
|
||||
|
||||
defineProps<{
|
||||
id?: HTMLAttributes['id']
|
||||
}>()
|
||||
|
||||
const { config } = useChart()
|
||||
|
||||
const colorConfig = computed(() => {
|
||||
return Object.entries(config.value).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
v-if="colorConfig.length"
|
||||
as="style"
|
||||
>
|
||||
{{ Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color
|
||||
= itemConfig.theme?.[theme as keyof typeof itemConfig.theme]
|
||||
|| itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n") }}
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ChartConfig } from '.'
|
||||
import { computed } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: 'line' | 'dot' | 'dashed'
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
labelFormatter?: (d: number | Date) => string
|
||||
payload?: Record<string, any>
|
||||
config?: ChartConfig
|
||||
class?: HTMLAttributes['class']
|
||||
color?: string
|
||||
x?: number | Date
|
||||
}>(), {
|
||||
payload: () => ({}),
|
||||
config: () => ({}),
|
||||
indicator: 'dot',
|
||||
})
|
||||
|
||||
// TODO: currently we use `createElement` and `render` to render the
|
||||
// const chartContext = useChart(null)
|
||||
|
||||
const payload = computed(() => {
|
||||
return Object.entries(props.payload).map(([key, value]) => {
|
||||
// const key = `${props.nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = props.config[key]
|
||||
const indicatorColor = props.config[key]?.color ?? props.payload.fill
|
||||
|
||||
return { key, value, itemConfig, indicatorColor }
|
||||
}).filter(i => i.itemConfig)
|
||||
})
|
||||
|
||||
const nestLabel = computed(() => Object.keys(props.payload).length === 1 && props.indicator !== 'dot')
|
||||
const tooltipLabel = computed(() => {
|
||||
if (props.hideLabel)
|
||||
return null
|
||||
if (props.labelFormatter && props.x !== undefined) {
|
||||
return props.labelFormatter(props.x)
|
||||
}
|
||||
return props.labelKey ? props.config[props.labelKey]?.label || props.payload[props.labelKey] : props.x
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn(
|
||||
'bg-popover text-popover-foreground ring-foreground/5 dark:ring-foreground/10 gap-1.5 rounded-xl px-2.5 py-1.5 text-xs shadow-lg ring-1 grid min-w-32 items-start',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot>
|
||||
<div v-if="!nestLabel && tooltipLabel" class="font-medium">
|
||||
{{ tooltipLabel }}
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<div
|
||||
v-for="{ value, itemConfig, indicatorColor, key } in payload"
|
||||
:key="key"
|
||||
:class="
|
||||
cn('[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||
indicator === 'dot' && 'items-center')"
|
||||
>
|
||||
<component :is="itemConfig.icon" v-if="itemConfig?.icon" />
|
||||
<template v-else-if="!hideIndicator">
|
||||
<div
|
||||
:class="cn(
|
||||
'shrink-0 rounded-xs border-(--color-border) bg-(--color-bg)',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
},
|
||||
)"
|
||||
:style="{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div :class="cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')">
|
||||
<div class="grid gap-1.5">
|
||||
<div v-if="nestLabel" class="font-medium">
|
||||
{{ tooltipLabel }}
|
||||
</div>
|
||||
<span class="text-muted-foreground">
|
||||
{{ itemConfig?.label || value }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="value" class="text-foreground font-mono font-medium tabular-nums">
|
||||
{{ value.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Component, Ref } from 'vue'
|
||||
import { createContext } from 'reka-ui'
|
||||
|
||||
export { default as ChartContainer } from './ChartContainer.vue'
|
||||
export { default as ChartLegendContent } from './ChartLegendContent.vue'
|
||||
export { default as ChartTooltipContent } from './ChartTooltipContent.vue'
|
||||
export { componentToString } from './utils'
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
export const THEMES = { light: '', dark: '.dark' } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: string | Component
|
||||
icon?: string | Component
|
||||
} & (
|
||||
| { color?: string, theme?: never }
|
||||
| { color?: never, theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
interface ChartContextProps {
|
||||
id: string
|
||||
config: Ref<ChartConfig>
|
||||
}
|
||||
|
||||
export const [useChart, provideChartContext] = createContext<ChartContextProps>('Chart')
|
||||
|
||||
export { VisCrosshair as ChartCrosshair, VisTooltip as ChartTooltip } from '@unovis/vue'
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ChartConfig } from '.'
|
||||
import { isClient } from '@vueuse/core'
|
||||
import { useId } from 'reka-ui'
|
||||
import { h, render } from 'vue'
|
||||
|
||||
// Simple cache using a Map to store serialized object keys
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
// Convert object to a consistent string key
|
||||
function serializeKey(key: Record<string, any>): string {
|
||||
return JSON.stringify(key, Object.keys(key).sort())
|
||||
}
|
||||
|
||||
interface Constructor<P = any> {
|
||||
__isFragment?: never
|
||||
__isTeleport?: never
|
||||
__isSuspense?: never
|
||||
new (...args: any[]): {
|
||||
$props: P
|
||||
}
|
||||
}
|
||||
|
||||
export function componentToString<P>(config: ChartConfig, component: Constructor<P>, props?: P) {
|
||||
if (!isClient)
|
||||
return
|
||||
|
||||
// This function will be called once during mount lifecycle
|
||||
const id = useId()
|
||||
|
||||
// https://unovis.dev/docs/auxiliary/Crosshair#component-props
|
||||
return (_data: any, x: number | Date) => {
|
||||
const data = 'data' in _data ? _data.data : _data
|
||||
const serializedKey = `${id}-${serializeKey(data)}`
|
||||
const cachedContent = cache.get(serializedKey)
|
||||
if (cachedContent)
|
||||
return cachedContent
|
||||
|
||||
const vnode = h<unknown>(component, { ...props, payload: data, config, x })
|
||||
const div = document.createElement('div')
|
||||
render(vnode, div)
|
||||
cache.set(serializedKey, div.innerHTML)
|
||||
return div.innerHTML
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="checkbox"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-input/90 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary border-transparent aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[5px] border transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)"
|
||||
>
|
||||
<CheckboxIndicator
|
||||
data-slot="checkbox-indicator"
|
||||
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||
>
|
||||
<slot v-bind="slotProps">
|
||||
<Icon name="ph:check-bold" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from './Checkbox.vue'
|
||||
@@ -0,0 +1,453 @@
|
||||
<script setup lang="ts">
|
||||
import type { HexColor, HslaColor, HsvaColor, RgbaColor } from "@uiw/color-convert";
|
||||
import {
|
||||
hexToHsva,
|
||||
hslaToHsva,
|
||||
hsvaToHex,
|
||||
hsvaToHsla,
|
||||
hsvaToRgba,
|
||||
rgbaToHsva,
|
||||
} from "@uiw/color-convert";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import ContrastRatio from "./ContrastRatio.vue";
|
||||
import ObjectColorInput from "./ObjectColorInput.vue";
|
||||
|
||||
export interface ColorPickerValue {
|
||||
hex: string;
|
||||
hsl: HslaColor;
|
||||
hsla: HslaColor;
|
||||
rgb: RgbaColor;
|
||||
rgba: RgbaColor;
|
||||
}
|
||||
|
||||
export interface ColorPickerProps {
|
||||
value?: `#${string}` | HsvaColor | HslaColor | RgbaColor;
|
||||
type?: "hsl" | "hsla" | "rgb" | "rgba" | "hex";
|
||||
swatches?: HexColor[];
|
||||
hideContrastRatio?: boolean;
|
||||
hideDefaultSwatches?: boolean;
|
||||
class?: string;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ColorPickerProps>(), {
|
||||
type: "hsl",
|
||||
swatches: () => [],
|
||||
hideContrastRatio: false,
|
||||
hideDefaultSwatches: false,
|
||||
open: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"value-change": [value: ColorPickerValue];
|
||||
"update:open": [value: boolean];
|
||||
}>();
|
||||
|
||||
const colorType = ref(props.type);
|
||||
const isDropdownOpen = ref(false);
|
||||
const isOpen = ref(props.open);
|
||||
|
||||
const colorOptions = [
|
||||
{ value: "hex", label: "HEX" },
|
||||
{ value: "hsl", label: "HSL" },
|
||||
{ value: "hsla", label: "HSLA" },
|
||||
{ value: "rgb", label: "RGB" },
|
||||
{ value: "rgba", label: "RGBA" },
|
||||
];
|
||||
|
||||
function getColorAsHsva(color: ColorPickerProps["value"]): HsvaColor {
|
||||
if (!color) return { h: 0, s: 0, v: 0, a: 1 };
|
||||
|
||||
if (typeof color === "string") {
|
||||
return hexToHsva(color);
|
||||
} else if ("h" in color && "s" in color && "v" in color) {
|
||||
return color;
|
||||
} else if ("r" in color) {
|
||||
return rgbaToHsva(color);
|
||||
} else {
|
||||
return hslaToHsva(color);
|
||||
}
|
||||
}
|
||||
|
||||
const colorHsv = ref<HsvaColor>(getColorAsHsva(props.value));
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
colorHsv.value = getColorAsHsva(newValue);
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(newValue) => {
|
||||
isOpen.value = newValue;
|
||||
},
|
||||
);
|
||||
|
||||
watch(isOpen, (newValue) => {
|
||||
emit("update:open", newValue);
|
||||
});
|
||||
|
||||
const hexValue = computed(() => hsvaToHex(colorHsv.value));
|
||||
const hslValue = computed(() => hsvaToHsla(colorHsv.value));
|
||||
const rgbValue = computed(() => hsvaToRgba(colorHsv.value));
|
||||
|
||||
const defaultSwatches = ["#F8371A", "#F97C1B", "#FAC81C", "#3FD0B6", "#2CADF6", "#6462FC"];
|
||||
const sortedSwatches = computed(() => {
|
||||
const allSwatches = [...defaultSwatches, ...props.swatches];
|
||||
return allSwatches.sort((a, b) => hexToHsva(a).h - hexToHsva(b).h);
|
||||
});
|
||||
|
||||
const isDraggingSaturation = ref(false);
|
||||
const isDraggingHue = ref(false);
|
||||
const isDraggingAlpha = ref(false);
|
||||
const saturationRef = ref<HTMLElement>();
|
||||
const hueRef = ref<HTMLElement>();
|
||||
const alphaRef = ref<HTMLElement>();
|
||||
|
||||
function handleValueChange(color: HsvaColor) {
|
||||
colorHsv.value = color;
|
||||
|
||||
const hslColor = hsvaToHsla(color);
|
||||
const rgbColor = hsvaToRgba(color);
|
||||
|
||||
emit("value-change", {
|
||||
hex: hsvaToHex(color),
|
||||
hsl: {
|
||||
...hslColor,
|
||||
a: Math.round(hslColor.a * 100) / 100,
|
||||
},
|
||||
hsla: {
|
||||
...hslColor,
|
||||
a: Math.round(hslColor.a * 100) / 100,
|
||||
},
|
||||
rgb: {
|
||||
...rgbColor,
|
||||
a: Math.round(rgbColor.a * 100) / 100,
|
||||
},
|
||||
rgba: {
|
||||
...rgbColor,
|
||||
a: Math.round(rgbColor.a * 100) / 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateFromHex(value: string | number) {
|
||||
try {
|
||||
const newColor = hexToHsva(String(value));
|
||||
handleValueChange(newColor);
|
||||
} catch (error) {
|
||||
// Invalid hex color, ignore
|
||||
}
|
||||
}
|
||||
|
||||
function updateFromObject(value: HslaColor | RgbaColor) {
|
||||
try {
|
||||
const newColor = "r" in value ? rgbaToHsva(value) : hslaToHsva(value);
|
||||
handleValueChange(newColor);
|
||||
} catch (error) {
|
||||
// Invalid color object, ignore
|
||||
}
|
||||
}
|
||||
|
||||
function setColorFromHex(hex: string) {
|
||||
handleValueChange(hexToHsva(hex));
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
isDropdownOpen.value = !isDropdownOpen.value;
|
||||
}
|
||||
|
||||
function selectColorType(value: string) {
|
||||
colorType.value = value as "hsl" | "hsla" | "rgb" | "rgba" | "hex";
|
||||
isDropdownOpen.value = false;
|
||||
}
|
||||
|
||||
function startSaturationDrag(event: MouseEvent | TouchEvent) {
|
||||
event.preventDefault();
|
||||
isDraggingSaturation.value = true;
|
||||
updateSaturation(event);
|
||||
|
||||
function handleMove(e: MouseEvent | TouchEvent) {
|
||||
if (isDraggingSaturation.value) {
|
||||
updateSaturation(e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnd() {
|
||||
isDraggingSaturation.value = false;
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleEnd);
|
||||
document.removeEventListener("touchmove", handleMove);
|
||||
document.removeEventListener("touchend", handleEnd);
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleEnd);
|
||||
document.addEventListener("touchmove", handleMove);
|
||||
document.addEventListener("touchend", handleEnd);
|
||||
}
|
||||
|
||||
function updateSaturation(event: MouseEvent | TouchEvent) {
|
||||
if (!saturationRef.value) return;
|
||||
|
||||
const rect = saturationRef.value.getBoundingClientRect();
|
||||
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
|
||||
const clientY = "touches" in event ? event.touches[0].clientY : event.clientY;
|
||||
|
||||
const relativeX = (clientX - rect.left) / rect.width;
|
||||
const relativeY = (clientY - rect.top) / rect.height;
|
||||
|
||||
const x = Math.max(0.01, Math.min(0.99, relativeX));
|
||||
const y = Math.max(0.01, Math.min(0.99, relativeY));
|
||||
|
||||
handleValueChange({
|
||||
...colorHsv.value,
|
||||
s: x * 100,
|
||||
v: (1 - y) * 100,
|
||||
});
|
||||
}
|
||||
|
||||
function handleHueClick(event: MouseEvent) {
|
||||
updateHue(event);
|
||||
}
|
||||
|
||||
function startHueDrag(event: MouseEvent | TouchEvent) {
|
||||
event.preventDefault();
|
||||
isDraggingHue.value = true;
|
||||
updateHue(event);
|
||||
|
||||
function handleMove(e: MouseEvent | TouchEvent) {
|
||||
if (isDraggingHue.value) {
|
||||
updateHue(e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnd() {
|
||||
isDraggingHue.value = false;
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleEnd);
|
||||
document.removeEventListener("touchmove", handleMove);
|
||||
document.removeEventListener("touchend", handleEnd);
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleEnd);
|
||||
document.addEventListener("touchmove", handleMove);
|
||||
document.addEventListener("touchend", handleEnd);
|
||||
}
|
||||
|
||||
function updateHue(event: MouseEvent | TouchEvent) {
|
||||
if (!hueRef.value) return;
|
||||
|
||||
const rect = hueRef.value.getBoundingClientRect();
|
||||
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
|
||||
|
||||
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
|
||||
handleValueChange({
|
||||
...colorHsv.value,
|
||||
h: x * 360,
|
||||
});
|
||||
}
|
||||
|
||||
function startAlphaDrag(event: MouseEvent | TouchEvent) {
|
||||
event.preventDefault();
|
||||
isDraggingAlpha.value = true;
|
||||
updateAlpha(event);
|
||||
|
||||
function handleMove(e: MouseEvent | TouchEvent) {
|
||||
if (isDraggingAlpha.value) {
|
||||
updateAlpha(e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnd() {
|
||||
isDraggingAlpha.value = false;
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleEnd);
|
||||
document.removeEventListener("touchmove", handleMove);
|
||||
document.removeEventListener("touchend", handleEnd);
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleEnd);
|
||||
document.addEventListener("touchmove", handleMove);
|
||||
document.addEventListener("touchend", handleEnd);
|
||||
}
|
||||
|
||||
function updateAlpha(event: MouseEvent | TouchEvent) {
|
||||
if (!alphaRef.value) return;
|
||||
|
||||
const rect = alphaRef.value.getBoundingClientRect();
|
||||
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
|
||||
|
||||
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
|
||||
handleValueChange({
|
||||
...colorHsv.value,
|
||||
a: x,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div @click="isOpen = !isOpen">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="bg-popover absolute top-full left-0 z-50 mt-2 w-80 max-w-[calc(100vw-3rem)] rounded-md border p-4 shadow-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
ref="saturationRef"
|
||||
class="relative aspect-4/2 w-full cursor-crosshair rounded border"
|
||||
:style="{
|
||||
background: `linear-gradient(to right, #fff, hsl(${colorHsv.h}, 100%, 50%)), linear-gradient(to top, #000, transparent)`,
|
||||
}"
|
||||
@mousedown="startSaturationDrag"
|
||||
@touchstart="startSaturationDrag"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute h-4 w-4 rounded-full border-2 border-white"
|
||||
:style="{
|
||||
left: `${colorHsv.s}%`,
|
||||
top: `${100 - colorHsv.v}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="hueRef"
|
||||
class="relative h-4 w-full cursor-pointer rounded"
|
||||
:style="{
|
||||
background:
|
||||
'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)',
|
||||
}"
|
||||
@mousedown="startHueDrag"
|
||||
@touchstart="startHueDrag"
|
||||
@click="handleHueClick"
|
||||
>
|
||||
<div
|
||||
class="border-foreground pointer-events-none absolute h-4 w-4 rounded-full border-2 bg-white"
|
||||
:style="{
|
||||
left: `${(colorHsv.h / 360) * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
top: '50%',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="border-input bg-background inline-flex h-8 w-20 items-center justify-between gap-2 rounded-md border px-2 py-1 text-xs font-medium uppercase"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ colorType }}</span>
|
||||
<Icon name="ph:caret-down-fill" class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isDropdownOpen"
|
||||
class="border-input bg-popover absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-md"
|
||||
>
|
||||
<div class="p-1">
|
||||
<button
|
||||
v-for="option in colorOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-xs font-medium uppercase select-none"
|
||||
@click="selectColorType(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex grow">
|
||||
<input
|
||||
v-if="colorType === 'hex'"
|
||||
:value="hexValue"
|
||||
type="text"
|
||||
class="border-input bg-background placeholder:text-muted-foreground flex h-8 w-full rounded-md border px-3 py-1 text-sm focus:outline-none"
|
||||
placeholder="#000000"
|
||||
@input="updateFromHex(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
|
||||
<ObjectColorInput
|
||||
v-else
|
||||
:value="colorType === 'hsl' || colorType === 'hsla' ? hslValue : rgbValue"
|
||||
:label="colorType"
|
||||
@value-change="updateFromObject"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="colorType === 'rgba' || colorType === 'hsla'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
ref="alphaRef"
|
||||
class="relative h-4 w-full cursor-pointer rounded"
|
||||
:style="{
|
||||
background: `linear-gradient(to right, rgba(${rgbValue.r}, ${rgbValue.g}, ${rgbValue.b}, 0) 0%, rgba(${rgbValue.r}, ${rgbValue.g}, ${rgbValue.b}, 1) 100%), repeating-conic-gradient(#ccc 0% 25%, transparent 0% 50%) 50% / 8px 8px`,
|
||||
}"
|
||||
@mousedown="startAlphaDrag"
|
||||
@touchstart="startAlphaDrag"
|
||||
>
|
||||
<div
|
||||
class="border-foreground pointer-events-none absolute h-4 w-4 rounded-full border-2 bg-white"
|
||||
:style="{
|
||||
left: `${colorHsv.a * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
top: '50%',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="swatches.length > 0 || !hideDefaultSwatches">
|
||||
<div class="bg-border h-px" />
|
||||
<div
|
||||
v-if="!hideDefaultSwatches"
|
||||
class="flex flex-wrap justify-start gap-2 pt-2"
|
||||
>
|
||||
<button
|
||||
v-for="color in sortedSwatches"
|
||||
:key="`${color}-swatch`"
|
||||
type="button"
|
||||
class="ring-offset-background h-5 w-5 shrink-0 cursor-pointer rounded ring-2 ring-transparent ring-offset-1 transition-all duration-100 hover:ring-current"
|
||||
:style="{ backgroundColor: color }"
|
||||
:aria-label="`Set color to ${color}`"
|
||||
@click="setColorFromHex(color)"
|
||||
@keydown.enter="setColorFromHex(color)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideContrastRatio">
|
||||
<div class="bg-border h-px" />
|
||||
<ContrastRatio :color="colorHsv" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-40"
|
||||
@click="isOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { HsvaColor } from "@uiw/color-convert";
|
||||
import { hsvaToRgba } from "@uiw/color-convert";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
export interface ContrastRatioProps {
|
||||
color: HsvaColor;
|
||||
}
|
||||
|
||||
const props = defineProps<ContrastRatioProps>();
|
||||
|
||||
const darkModeContrastRatio = ref(0);
|
||||
const lightModeContrastValue = ref(0);
|
||||
|
||||
const rgbaColor = computed(() => hsvaToRgba(props.color));
|
||||
|
||||
const shouldUseWhiteText = computed(() => {
|
||||
const rgb = rgbaColor.value;
|
||||
// Calculate luminance of the color background
|
||||
const r = rgb.r / 255;
|
||||
const g = rgb.g / 255;
|
||||
const b = rgb.b / 255;
|
||||
|
||||
const rLinear = r <= 0.03928 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4;
|
||||
const gLinear = g <= 0.03928 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4;
|
||||
const bLinear = b <= 0.03928 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4;
|
||||
|
||||
const luminance = 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear;
|
||||
|
||||
// Use white text if the background is dark (luminance < 0.5)
|
||||
return luminance < 0.5;
|
||||
});
|
||||
|
||||
const currentContrastRatio = computed(() => {
|
||||
// For accessibility, use the better of the two contrast ratios (dark or light mode)
|
||||
// This gives the most accurate assessment of accessibility across different contexts
|
||||
return Math.max(darkModeContrastRatio.value, lightModeContrastValue.value);
|
||||
});
|
||||
|
||||
const isAccessible = computed(() => {
|
||||
return {
|
||||
aa: currentContrastRatio.value >= 4.5,
|
||||
aaa: currentContrastRatio.value >= 7,
|
||||
};
|
||||
});
|
||||
|
||||
function calculateContrastRatios(color: HsvaColor) {
|
||||
const rgb = hsvaToRgba(color);
|
||||
|
||||
function toSRGB(c: number) {
|
||||
const channel = c / 255;
|
||||
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
function alphaBlend(foreground: number, background: number, alpha: number) {
|
||||
return foreground * alpha + background * (1 - alpha);
|
||||
}
|
||||
|
||||
// Apply alpha blending for light background (white: 255)
|
||||
const lightR = alphaBlend(rgb.r, 255, rgb.a);
|
||||
const lightG = alphaBlend(rgb.g, 255, rgb.a);
|
||||
const lightB = alphaBlend(rgb.b, 255, rgb.a);
|
||||
|
||||
// Apply alpha blending for dark background (dark gray: 32)
|
||||
const darkR = alphaBlend(rgb.r, 32, rgb.a);
|
||||
const darkG = alphaBlend(rgb.g, 32, rgb.a);
|
||||
const darkB = alphaBlend(rgb.b, 32, rgb.a);
|
||||
|
||||
// Calculate luminance for light background composition
|
||||
const lightRSRGB = toSRGB(lightR);
|
||||
const lightGSRGB = toSRGB(lightG);
|
||||
const lightBSRGB = toSRGB(lightB);
|
||||
const lightLuminance = 0.2126 * lightRSRGB + 0.7152 * lightGSRGB + 0.0722 * lightBSRGB;
|
||||
|
||||
// Calculate luminance for dark background composition
|
||||
const darkRSRGB = toSRGB(darkR);
|
||||
const darkGSRGB = toSRGB(darkG);
|
||||
const darkBSRGB = toSRGB(darkB);
|
||||
const darkLuminance = 0.2126 * darkRSRGB + 0.7152 * darkGSRGB + 0.0722 * darkBSRGB;
|
||||
|
||||
// Text color luminances (what we're measuring contrast against)
|
||||
const whiteTextLuminance = 1.0;
|
||||
const blackTextLuminance = 0.0;
|
||||
|
||||
// Calculate contrast ratios: text against the color background (alpha-blended)
|
||||
// For dark mode: white text against the dark-composited color
|
||||
const darkModeRatio =
|
||||
(Math.max(whiteTextLuminance, darkLuminance) + 0.05) /
|
||||
(Math.min(whiteTextLuminance, darkLuminance) + 0.05);
|
||||
// For light mode: black text against the light-composited color
|
||||
const lightModeRatio =
|
||||
(Math.max(blackTextLuminance, lightLuminance) + 0.05) /
|
||||
(Math.min(blackTextLuminance, lightLuminance) + 0.05);
|
||||
|
||||
darkModeContrastRatio.value = Number(darkModeRatio.toFixed(2));
|
||||
lightModeContrastValue.value = Number(lightModeRatio.toFixed(2));
|
||||
}
|
||||
|
||||
watch(() => props.color, calculateContrastRatios, { immediate: true, deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4 pt-2 select-none">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex size-10 items-center justify-center rounded border"
|
||||
:style="{
|
||||
backgroundColor: `rgba(${rgbaColor.r}, ${rgbaColor.g}, ${rgbaColor.b}, ${rgbaColor.a})`,
|
||||
backgroundSize: '8px 8px',
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="font-medium"
|
||||
:style="{
|
||||
color: shouldUseWhiteText ? 'white' : 'black',
|
||||
}"
|
||||
>A</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col justify-between">
|
||||
<span class="text-muted-foreground text-xs text-nowrap whitespace-nowrap">
|
||||
Contrast Ratio
|
||||
</span>
|
||||
<span class="text-sm">{{ currentContrastRatio }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1 *:select-none">
|
||||
<Badge
|
||||
:variant="isAccessible.aa ? 'default' : 'secondary'"
|
||||
class="gap-1"
|
||||
>
|
||||
<Icon name="ph:check-fill" v-if="isAccessible.aa" class="size-3" />
|
||||
<Icon name="ph:x-fill" v-else class="size-3" />
|
||||
AA
|
||||
</Badge>
|
||||
<Badge
|
||||
:variant="isAccessible.aaa ? 'default' : 'secondary'"
|
||||
class="gap-1"
|
||||
>
|
||||
<Icon name="ph:check-fill" v-if="isAccessible.aaa" class="size-3" />
|
||||
<Icon name="ph:x-fill" v-else class="size-3" />
|
||||
AAA
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import type { HslaColor, RgbaColor } from "@uiw/color-convert";
|
||||
import { computed } from "vue";
|
||||
|
||||
interface ObjectColorInputProps {
|
||||
label: "hsl" | "hsla" | "rgb" | "rgba";
|
||||
value: HslaColor | RgbaColor;
|
||||
}
|
||||
|
||||
const props = defineProps<ObjectColorInputProps>();
|
||||
const emit = defineEmits<{ "value-change": [value: HslaColor | RgbaColor] }>();
|
||||
|
||||
const inputValues = computed(() => {
|
||||
return getInputValues(props.value);
|
||||
});
|
||||
|
||||
function getInputValues(value: HslaColor | RgbaColor): number[] {
|
||||
const isHsl = props.label === "hsl" || props.label === "hsla";
|
||||
const hasAlpha = props.label === "hsla" || props.label === "rgba";
|
||||
|
||||
if (isHsl) {
|
||||
const hsl = value as HslaColor;
|
||||
const values = [Math.round(hsl.h), Math.round(hsl.s), Math.round(hsl.l)];
|
||||
if (hasAlpha) {
|
||||
values.push(Math.round(hsl.a * 100));
|
||||
}
|
||||
return values;
|
||||
} else {
|
||||
const rgb = value as RgbaColor;
|
||||
const values = [Math.round(rgb.r), Math.round(rgb.g), Math.round(rgb.b)];
|
||||
if (hasAlpha) {
|
||||
values.push(Math.round(rgb.a * 100));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
function getInputClass(index: number, totalInputs: number): string {
|
||||
const baseClass =
|
||||
"flex-1 w-0 h-8 px-2 py-1 text-xs border border-input bg-background text-center overflow-hidden focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0";
|
||||
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === totalInputs - 1;
|
||||
const isMiddle = !isFirst && !isLast;
|
||||
|
||||
let positionClasses = "";
|
||||
if (isFirst) {
|
||||
positionClasses = "rounded-l-md -mr-px";
|
||||
} else if (isLast) {
|
||||
positionClasses = "rounded-r-md";
|
||||
} else if (isMiddle) {
|
||||
positionClasses = "rounded-none -mr-px";
|
||||
}
|
||||
|
||||
return `${baseClass} ${positionClasses}`;
|
||||
}
|
||||
|
||||
const inputs = computed(() => {
|
||||
const isHsl = props.label === "hsl" || props.label === "hsla";
|
||||
const hasAlpha = props.label === "hsla" || props.label === "rgba";
|
||||
|
||||
let inputsData;
|
||||
if (isHsl) {
|
||||
const hsl = props.value as HslaColor;
|
||||
inputsData = [
|
||||
{ key: "h", value: Math.round(hsl.h), min: 0, max: 360, prop: "h" },
|
||||
{ key: "s", value: Math.round(hsl.s), min: 0, max: 100, prop: "s" },
|
||||
{ key: "l", value: Math.round(hsl.l), min: 0, max: 100, prop: "l" },
|
||||
];
|
||||
if (hasAlpha) {
|
||||
inputsData.push({ key: "a", value: Math.round(hsl.a * 100), min: 0, max: 100, prop: "a" });
|
||||
}
|
||||
} else {
|
||||
const rgb = props.value as RgbaColor;
|
||||
inputsData = [
|
||||
{ key: "r", value: Math.round(rgb.r), min: 0, max: 255, prop: "r" },
|
||||
{ key: "g", value: Math.round(rgb.g), min: 0, max: 255, prop: "g" },
|
||||
{ key: "b", value: Math.round(rgb.b), min: 0, max: 255, prop: "b" },
|
||||
];
|
||||
if (hasAlpha) {
|
||||
inputsData.push({ key: "a", value: Math.round(rgb.a * 100), min: 0, max: 100, prop: "a" });
|
||||
}
|
||||
}
|
||||
|
||||
return inputsData.map((input, index) => ({
|
||||
...input,
|
||||
class: getInputClass(index, inputsData.length),
|
||||
}));
|
||||
});
|
||||
|
||||
function handleChange(idx: number, event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = Number(target.value);
|
||||
if (Number.isNaN(value)) return;
|
||||
|
||||
const input = inputs.value[idx];
|
||||
const clampedValue = Math.max(input.min, Math.min(input.max, value));
|
||||
const finalValue = input.prop === "a" ? clampedValue / 100 : clampedValue;
|
||||
|
||||
emit("value-change", { ...props.value, [input.prop]: finalValue } as HslaColor | RgbaColor);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full min-w-0">
|
||||
<input
|
||||
v-for="(input, idx) in inputs"
|
||||
:key="input.key"
|
||||
:value="inputValues[idx]"
|
||||
:min="input.min"
|
||||
:max="input.max"
|
||||
:class="input.class"
|
||||
step="1"
|
||||
@input="handleChange(idx, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as ColorPicker } from "./ColorPicker.vue";
|
||||
export type { ColorPickerProps, ColorPickerValue } from "./ColorPicker.vue";
|
||||
export { default as ContrastRatio } from "./ContrastRatio.vue";
|
||||
|
||||
export { default as ObjectColorInput } from "./ObjectColorInput.vue";
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import type { ListboxRootEmits, ListboxRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ListboxRoot, useFilter, useForwardPropsEmits } from 'reka-ui'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { provideCommandContext } from '.'
|
||||
|
||||
const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
modelValue: '',
|
||||
highlightOnHover: true,
|
||||
})
|
||||
|
||||
const emits = defineEmits<ListboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
|
||||
const allItems = ref<Map<string, string>>(new Map())
|
||||
const allGroups = ref<Map<string, Set<string>>>(new Map())
|
||||
|
||||
const { contains } = useFilter({ sensitivity: 'base' })
|
||||
const filterState = reactive({
|
||||
search: '',
|
||||
filtered: {
|
||||
/** The count of all visible items. */
|
||||
count: 0,
|
||||
/** Map from visible item id to its search score. */
|
||||
items: new Map() as Map<string, number>,
|
||||
/** Set of groups with at least one visible item. */
|
||||
groups: new Set() as Set<string>,
|
||||
},
|
||||
})
|
||||
|
||||
function filterItems() {
|
||||
if (!filterState.search) {
|
||||
filterState.filtered.count = allItems.value.size
|
||||
// Do nothing, each item will know to show itself because search is empty
|
||||
return
|
||||
}
|
||||
|
||||
// Reset the groups
|
||||
filterState.filtered.groups = new Set()
|
||||
let itemCount = 0
|
||||
|
||||
// Check which items should be included
|
||||
for (const [id, value] of allItems.value) {
|
||||
const score = contains(value, filterState.search)
|
||||
filterState.filtered.items.set(id, score ? 1 : 0)
|
||||
if (score)
|
||||
itemCount++
|
||||
}
|
||||
|
||||
// Check which groups have at least 1 item shown
|
||||
for (const [groupId, group] of allGroups.value) {
|
||||
for (const itemId of group) {
|
||||
if (filterState.filtered.items.get(itemId)! > 0) {
|
||||
filterState.filtered.groups.add(groupId)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filterState.filtered.count = itemCount
|
||||
}
|
||||
|
||||
watch(() => filterState.search, () => {
|
||||
filterItems()
|
||||
})
|
||||
|
||||
provideCommandContext({
|
||||
allItems,
|
||||
allGroups,
|
||||
filterState,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListboxRoot
|
||||
data-slot="command"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-popover text-popover-foreground rounded-3xl p-1 flex size-full flex-col overflow-hidden', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ListboxRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import Command from './Command.vue'
|
||||
|
||||
const props = withDefaults(defineProps<DialogRootProps & {
|
||||
title?: string
|
||||
description?: string
|
||||
class?: HTMLAttributes['class']
|
||||
showCloseButton?: boolean
|
||||
}>(), {
|
||||
title: 'Command Palette',
|
||||
description: 'Search for a command to run...',
|
||||
showCloseButton: false,
|
||||
})
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-slot="slotProps" v-bind="forwarded">
|
||||
<DialogContent
|
||||
:class="cn('rounded-3xl! top-1/3 translate-y-0 overflow-hidden p-0', props.class)"
|
||||
:show-close-button="showCloseButton"
|
||||
>
|
||||
<DialogHeader class="sr-only">
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription>{{ description }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Command>
|
||||
<slot v-bind="slotProps" />
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user