Compare commits

...
58 Commits
Author SHA1 Message Date
FliesentischKingandDevin 82e679e316 Fix production database URL injection via NUXT_DATABASE_URL
runtimeConfig values are inlined at build time; only NUXT_* env
vars override them at runtime, so DATABASE_URL never reached the
app and every request failed with a 500.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 14:23:04 +01:00
FliesentischKingandDevin e72413fd1f Update README with recent features
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 14:04:00 +01:00
FliesentischKingandDevin 16c5ff8ea4 Add Dockerfile and production build setup for Coolify
The prod compose file references `build: .`, so add the missing
two-stage Dockerfile, run migrations + permission seeding on
container start (not only in dev), and add sharp for IPX.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 14:04:00 +01:00
FliesentischKing ccfbbd7c61 Readme Update 2026-10-25 12:30:47 +01:00
FliesentischKingandDevin 1d4ded2b99 Add production docker-compose file for Coolify deployment
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 12:24:48 +01:00
FliesentischKingandDevin 44968b1557 Allow leaving groups and removing members
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 12:15:30 +01:00
FliesentischKingandDevin 3705a29c2b Add report system with mod panel
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 12:15:29 +01:00
FliesentischKingandDevin 1461e5d3b4 Require accepting group chat invitations
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 12:15:27 +01:00
FliesentischKingandDevin 7ef5652719 Add user blocking
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 12:15:24 +01:00
FliesentischKingandDevin 674e1b0ffd Give the composer textarea a card background
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:17 +02:00
FliesentischKingandDevin 10319c77bf Add Discord Dark and Onyx themes
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:16 +02:00
FliesentischKingandDevin 50f82f43bd Show uniform thumbnails in halls
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:16 +02:00
FliesentischKingandDevin 83b32b7628 Keep the composer noise suppression toggle expanded while popovers are open
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:15 +02:00
FliesentischKingandDevin 013e4c8add Add admin-managed icons and animation effects for tags
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:15 +02:00
FliesentischKingandDevin 71d8432885 Filter uploads by a calendar date range
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:12 +02:00
FliesentischKingandDevin 8924bcf688 Replace admin limit sliders with number fields
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:10 +02:00
FliesentischKingandDevin 4800080a04 Make shop prices configurable from the admin panel
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:10 +02:00
FliesentischKingandDevin de39c20dea Allow stickers as reactions on comments, uploads and messages
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:09 +02:00
FliesentischKingandDevin 94cf0aaa23 Add animated pulse and beam variants for the golden upload frame
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:06 +02:00
FliesentischKingandDevin c13fe604f3 Use a pointer cursor for dropdown menu entries
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 00:34:05 +02:00
FliesentischKingandDevin 943f1d2ab4 Let admins gift points to users
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 18:46:30 +02:00
FliesentischKingandDevin 8253682f6b Animate the golden frame and name gradient and show it on thread author names
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 18:46:30 +02:00
FliesentischKingandDevin 244fa24c20 Let users deactivate purchased shop effects without a refund
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 18:46:29 +02:00
FliesentischKingandDevin 7d5f44b22d Add point shop with timed upload effects, flair, name gradient, a mystery box and sticker slots
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 18:46:29 +02:00
FliesentischKingandDevin dace93c28a Add purchasable public display names backed by a point balance
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 18:45:11 +02:00
FliesentischKingandDevin 54a81c4642 Widen the profile upload grid and pin pagination to the viewport
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 18:44:26 +02:00
FliesentischKingandDevin 8a0c3f57d5 Apply UI guidelines to admin, halls, stats, channels, users and login pages
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 18:44:25 +02:00
FliesentischKingandDevin ab0734a7dd Add per-thread moderation locks for comments, reactions, stickers, links and videos
Uploaders and users with the new thread.moderate permission can lock
comments, reactions, stickers, links and YouTube videos per thread
through a settings popover. The server enforces the flags in the
comment and reaction endpoints, clients receive changes live via the
thread-settings WebSocket event, and state labels on the lock buttons
reflect whether a restriction is active.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 21:42:40 +02:00
FliesentischKingandDevin f61f862e82 Reveal noise suppression toggle only in composer context
The noise suppression button stays collapsed at zero width unless the
pointer is inside the composer area, the field has focus, or a
recording is running. The enabled state only shows through the filled
styling while visible. Applied to both the thread and DM composers.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 21:42:32 +02:00
FliesentischKingandDevin 2c1af595e6 Apply UI design rules to thread, messages and meme pages
Audit pass: remove decorative icons and redundant borders where
surface or selection state already carries the meaning, normalize
spacing to the 4-based rhythm, replace tiny arbitrary text sizes
with text-xs, and use font-semibold tracking-tight for headings.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 21:42:24 +02:00
FliesentischKingandDevin f82f28bee8 Open group chats as floating windows
FloatingChatTab now keys on 'u<id>'/'g<id>' so groups can open in the
same draggable, resizable messenger windows as 1:1 chats. The group
header gains a picture-in-picture button that opens the floating
window and navigates home.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 21:42:13 +02:00
FliesentischKingandDevin cbf1661a3b Keep upload filters in client state and add a followed-users filter
All filter controls (tag, types, uploader, period, followed users)
now write into shared useState instead of the URL, so filtering no
longer changes the address bar. Deep links like /?tag= still apply
once and are stripped via router.replace. The new followed-users
switch filters uploads through the user_follows table.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 21:42:08 +02:00
FliesentischKingandDevin 6041350f8a Let users follow each other and notify followers on new uploads
Adds a user_follows table, a follow toggle endpoint and profile
stats, and a follow button on profile pages. When a followed user
posts an upload, followers get a thread subscription and a mention
notification through the established bot-comment path.

The migration journal also records the upcoming 0046 entry.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 21:42:04 +02:00
FliesentischKingandDevin bf4bd38e84 Support pasting media files and URLs into the upload flow
Ctrl+V with media in the clipboard now opens a confirmation dialog
with previews instead of uploading directly. Inside the comment and
DM composers, MentionTextarea emits paste-files so pasted images
become attachments. Clipboard URLs prefill the upload popover's URL
field without submitting. Native paste inside editable fields is
untouched.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 21:41:28 +02:00
FliesentischKingandDevin 34a1f803c7 Heal WebSocket user registration so personal pushes are not lost
Peer-to-user registration only ran once in the async WS open handler,
so a failed cookie lookup or a close during the await left the socket
globally registered forever: broadcasts arrived, but all pushToUser
messages (notifications, DMs) were silently dropped until reload.

Registration is now self-healing: ensurePeerUser lazily resolves and
caches the user, incoming messages retry it, and pushToUser falls back
to sweeping unresolved global peers before delivering. A closed flag
prevents zombie registration from the open/close race.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 21:40:54 +02:00
FliesentischKingandDevin e9561ddb2a Translate AGENTS.md to English and add UI design rules
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 19:50:45 +02:00
FliesentischKingandDevin 1940ec29be Fix notification menu flickering on bell hover
The bell tooltip and popover auto-focus could steal pointer hover and
re-trigger open/close in a loop. Tooltips no longer intercept pointer
events or open on programmatic focus, and the menu only closes once the
pointer has truly left both the bell and the popover.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-23 19:50:42 +02:00
FliesentischKingandDevin d850e94161 Add eight TweakCN themes
Adds Claude +, 2077, Astra, Apotheosis Mint Midnight, Emerald, Vulkan,
Sakura, and Minimal Neutral theme variants.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 21:24:56 +02:00
FliesentischKingandDevin 9e0ff478ae Add activity-based thumbnail sizing
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 21:24:43 +02:00
FliesentischKingandDevin 1c187cce6e Open the notification menu on bell hover
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 21:24:37 +02:00
FliesentischKingandDevin 9e0c6bc06a Keep the theme submenu open while switching themes
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 20:38:24 +02:00
FliesentischKingandDevin 6e7bcc9219 Increase navbar username size and reduce edge spacing
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 20:38:21 +02:00
FliesentischKingandDevin 4b36cda2af Add icons to admin panel tabs
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 20:38:12 +02:00
FliesentischKingandDevin fa462ffd97 Align tab surfaces and radii with theme tokens
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 20:38:09 +02:00
FliesentischKingandDevin 3c6d11109e Organize profile settings into themed tabs
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 20:38:03 +02:00
FliesentischKingandDevin 16c41dde3b Replace native tag suggestions with a themed combobox
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 20:37:58 +02:00
FliesentischKingandDevin db3b09e445 Use a static uploader border in thread user info
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 20:37:46 +02:00
FliesentischKingandDevin aedadd56ad Improve media view transitions between uploads
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 20:37:36 +02:00
FliesentischKingandDevin 47cb2c3c8e Add user and standard halls with visibility controls and stacked cover thumbnails
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 22:02:20 +02:00
FliesentischKingandDevin 93aaef143f Replace filled square ph:x-fill icons with clean ph:x-bold crosses
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 22:02:20 +02:00
FliesentischKing 12b5aa2ad2 Use rounded chevron icons for breadcrumb separators 2026-10-20 21:56:39 +02:00
FliesentischKing 598df59671 Add media banners to user hover cards with full-card holographic effects 2026-10-20 21:56:39 +02:00
FliesentischKing 305245c40f Refine emoji picker layout and category navigation 2026-10-20 21:56:30 +02:00
FliesentischKing 113af5f28f Improve bot command popover sizing and theme typography 2026-10-20 21:56:30 +02:00
FliesentischKingandDevin 22639fe770 Add draggable and resizable floating direct message chat windows
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 22:25:41 +02:00
FliesentischKingandDevin 76f07f64d8 Add user bio and role badges to hover cards and profile pages
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 22:25:30 +02:00
FliesentischKingandDevin 37e6ad3e04 Add 3D holographic hover card effects including Galaxy and Cosmos holofoil
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 22:24:34 +02:00
FliesentischKingandDevin b6eee52cc4 Remove rounded corners in the 4f theme
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 22:22:56 +02:00
254 changed files with 57031 additions and 1165 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
node_modules
.nuxt
.output
uploads
data
.env
.env.*
*.log
docker-compose*.yml
Dockerfile
.dockerignore
+46 -14
View File
@@ -2,20 +2,52 @@
## Workflow
- Keine Headless-Browser-Verifizierung nach UI-Änderungen - der User testet selbst. Nur bei wiederholten Fehlern nachfragen/debuggen. Syntax-Checks sind okay; `npm run build` nur ausführen, wenn der User es ausdrücklich will.
- 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).
- Keine Git-Commits von sich aus - nur committen, wenn der User es explizit anweist.
- No headless-browser verification after UI changes - the user tests manually. Only ask/debug after repeated failures. Syntax checks are fine; run `npm run build` only if the user explicitly asks for it.
- Dev server: `npm run dev` (port 3000). The user usually runs it themselves - do not start it blindly, check whether it is already running first.
- DB: MariaDB via `docker-compose.yml` (container `nuxt-shadcn-demo-db`), URL in `DATABASE_URL`. Migrations: `npm run db:generate` (schema → SQL), `npm run db:migrate` (apply; also runs automatically via Nitro plugin in dev mode).
- No Git commits on your own - only commit when the user explicitly instructs it.
## 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 werden in `app/assets/css/tailwind.css`, `app/composables/useTheme.ts` und `app/components/ThemeSelector.vue` gepflegt - `--primary` ist die Signaturfarbe, `--accent` der Kontrast; `neo` hat `--radius: 0` (eckig), daher dort `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-Auswahl über `UnicodeEmojiPicker.vue` (emoji-picker-element Web-Component, alle Unicode-Emojis mit Suche; Daten lokal aus `emoji-picker-element-data`, UI-Texte aus `emoji-picker-element/i18n`).
- Terminologie intern: **Emoji** = Unicode-Reactions (`UnicodeEmojiPicker.vue`, `CommentReactionPicker.vue`, `emoji`-Spalten in den Reaction-Tabellen); **Sticker** = hochgeladene Medien (`StickerPicker.vue`, `StickerMedia.vue`, `useStickers`, `shared/utils/stickers.ts`, Tabelle `stickers`, `/api/stickers`, `/uploads/stickers`, Permission `sticker.standard`). Shortcode-Rendering `:name:` über `stickerParts()`.
- 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.
- Direktnachrichten: Tabelle `direct_messages` (sender/recipient/content/read_at). API unter `/api/messages` (Liste, Verlauf mit `?before=`, Senden, `read`). Live per `pushToUser` mit `dm`-Event (Message + sender/recipient-Userinfos) und `dm-read`-Lesebestätigung. Client-State in `useDirectMessages` - `openPartnerId` markiert die offene Konversation; `liveMessageHandler`/`liveReadHandler`/`liveReactionHandler` sind Modul-Singletons, die die Messages-Seite registriert. DM-Reactions: Tabelle `direct_message_reactions` (message/user/emoji, `seen_at`), Toggle unter `/api/messages/reactions/:messageId`, WS-Event `dm-reaction` an beide Teilnehmer. Ungesehene fremde Reactions zählen wie ungelesene Nachrichten für den Unread-Badge; `read` setzt `seen_at` mit. DM-Anhänge: `attachments`-JSON-Spalte in `CommentAttachment`-Form (`meta.voice` = Sprachnachricht); `POST /api/messages/:userId` akzeptiert multipart mit `files`-/`voice`-Feldern wie das Kommentar-Formular, Rendering über `CommentAttachments`.
- 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.
- i18n: `@nuxtjs/i18n`, Locales `de` (Default) + `en` in `i18n/locales/*.json` - beide Dateien immer gleiche Key-Struktur halten. Alle UI-Strings über `$t()`/`t()`, nie hardcoden. `strategy: 'no_prefix'`, Locale-Cookie `i18n_locale`. Permission-Labels im Admin über `admin.perms.<key>`; Effekt-/Theme-Namen bleiben Eigennamen.
- Vue 3 `<script setup lang="ts">`, Composition API, shadcn-vue components under `app/components/ui/`.
- Theme colors via semantic tokens (`bg-primary`, `border-primary`, …), never hardcode them. Themes are maintained in `app/assets/css/tailwind.css`, `app/composables/useTheme.ts` and `app/components/ThemeSelector.vue` - `--primary` is the signature color, `--accent` the contrast; `neo` has `--radius: 0` (square), so use `rounded-md`/`rounded-sm` instead of `rounded-2xl`/`rounded-xl` there.
- Tailwind: `size-*` instead of `w-* h-*`, `gap-*` instead of `space-x/y-*`, `cn()` for conditional classes.
- Live updates run through `server/routes/_ws.ts` + `broadcastToAll`; client-side handling in `app/plugins/notifications.client.ts`.
- Comment reactions: `useCommentReactions` (state map per comment ID). Emoji selection via `UnicodeEmojiPicker.vue` (emoji-picker-element web component, all Unicode emojis with search; local data from `emoji-picker-element-data`, UI texts from `emoji-picker-element/i18n`).
- Internal terminology: **Emoji** = Unicode reactions (`UnicodeEmojiPicker.vue`, `CommentReactionPicker.vue`, `emoji` columns in the reaction tables); **Sticker** = uploaded media (`StickerPicker.vue`, `StickerMedia.vue`, `useStickers`, `shared/utils/stickers.ts`, table `stickers`, `/api/stickers`, `/uploads/stickers`, permission `sticker.standard`). Shortcode rendering `:name:` via `stickerParts()`.
- Thread notifications: `threadSubscriptions` stores subscriptions and explicit opt-outs. Creating, commenting, favoriting and post reactions call `ensureThreadSubscription()`; a disabled subscription must not be reactivated by this. `notifyComment()` notifies active subscribers and mentioned users, respects opt-outs even for mentions and groups notifications per user/thread.
- Direct messages: table `direct_messages` (sender/recipient/content/read_at). API under `/api/messages` (list, history with `?before=`, send, `read`). Live via `pushToUser` with `dm` event (message + sender/recipient user info) and `dm-read` read receipt. Client state in `useDirectMessages` - `openPartnerId` marks the open conversation; `liveMessageHandler`/`liveReadHandler`/`liveReactionHandler` are module singletons registered by the messages page. DM reactions: table `direct_message_reactions` (message/user/emoji, `seen_at`), toggle under `/api/messages/reactions/:messageId`, WS event `dm-reaction` to both participants. Unseen foreign reactions count like unread messages for the unread badge; `read` sets `seen_at` as well. DM attachments: `attachments` JSON column in `CommentAttachment` shape (`meta.voice` = voice message); `POST /api/messages/:userId` accepts multipart with `files`/`voice` fields like the comment form, rendered via `CommentAttachments`.
- Permissions: catalog in `shared/utils/permissions.ts` (`PERMISSIONS`), server check via `requirePermission(event, key)` in `server/utils/auth.ts` - the `admin` role is an implicit wildcard. Client-side `can(key)` from `useAuth`. Seeding via `seedPermissions()` in the migration plugin/script.
- i18n: `@nuxtjs/i18n`, locales `de` (default) + `en` in `i18n/locales/*.json` - always keep both files with identical key structure. All UI strings via `$t()`/`t()`, never hardcoded. `strategy: 'no_prefix'`, locale cookie `i18n_locale`. Permission labels in admin via `admin.perms.<key>`; effect/theme names remain proper nouns.
## UI & Design System Rules (Anti-Vibe-Coding)
When auditing, refactoring, or building UI, strictly apply these design rules:
### 1. Visual Hierarchy & Card Fatigue
- **Eliminate "Card-in-Card" nesting**: Never place a Card inside another Card. Use whitespace, divider lines (`<Separator />`), or subtle background shifts instead of borders.
- **Icon Restraint**: Do NOT add icons to every button, table header, or card title. Icons should only exist when they materially aid scanning (max 1 icon per actionable group).
- **Border Reduction**: If a border doesn't separate distinct functional contexts, remove it. Favor spacing (`gap`, `space-y`) over border boxes.
### 2. Spacing & Rhythm (8pt Grid)
- Stick strictly to a 4-step spacing hierarchy:
- Tight (inline/tags): `gap-1.5` to `gap-2`
- Grouped elements (form fields, lists): `space-y-3` / `gap-3`
- Section spacing: `space-y-6` / `gap-6`
- Layout/Page level: `py-8` to `py-12`, max container `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8`
- Do not mix random paddings (e.g. `p-5`, `p-7`). Stick to `p-4` (compact) or `p-6` (standard).
### 3. Typography & Contrast
- Contrast comes from weight and color, not just font size.
- Secondary text must consistently use `text-muted-foreground text-xs` or `text-sm`.
- Main headers: Use `tracking-tight font-semibold` instead of generic `font-bold`.
- Avoid 5 different font sizes on a single screen. Max 3 sizes per view (e.g. Title `text-xl`, Body `text-sm`, Metadata `text-xs`).
### 4. Nuxt 4 & Shadcn Vue Specifics
- Always leverage semantic Shadcn primitives (`CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`) properly—never inline custom headers inside `CardContent`.
- Keep templates clean: Move complex inline Tailwind classes into reusable Nuxt components or tailwind-variants (`cva`) if reused.
- Ensure proper empty states (`EmptyState` pattern) and loading skeletons instead of blank spaces.
+25
View File
@@ -0,0 +1,25 @@
# syntax=docker/dockerfile:1
# Build stage: full source + dev deps, produces .output
# Debian base (not Alpine): ffmpeg-static ships a glibc binary
FROM node:24-slim AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build
# Runtime stage: only the Nitro output + runtime extras
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
NITRO_PORT=3000
COPY --from=build /app/.output ./.output
# Drizzle migrations folder is read relative to cwd at server start
COPY --from=build /app/server/db/migrations ./server/db/migrations
# ffmpeg-static's binary is not picked up by Nitro's dependency tracing
COPY --from=build /app/node_modules/ffmpeg-static /app/node_modules/ffmpeg-static
COPY --from=build /app/node_modules/ffmpeg-static /app/.output/server/node_modules/ffmpeg-static
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
+67 -27
View File
@@ -1,31 +1,42 @@
# BRETT
An imageboard-style upload and discussion platform built with **Nuxt 4** and **shadcn-vue**. Users can upload files or URLs, comment in threads (including file attachments and voice messages), respond with Discord-style reactions, subscribe to threads, and pick from over 20 themes.
An imageboard-style upload and discussion platform built with **Nuxt 4** and **shadcn-vue**. Users can upload files or URLs, comment in threads (including file attachments and voice messages), react with emojis and stickers, chat via direct and group messages, collect uploads in halls, and spend points in the shop - in German or English, with over 30 themes.
![Screenshot of the upload grid](screenshot.png)
## Features
- **Uploads:** Drag & drop (page dropzone + upload popover), file or URL upload, max. 200 MB. Types: **images** (generated thumbnails), **videos** (embedded Plyr player), **audio** (MP3/FLAC with embedded cover art and music metadata via `music-metadata`, dedicated player dialog), **YouTube** and **Twitch embeds**, plus **link preview cards**
- **Threads & comments:** Every upload is a thread with comments. Comments support **file attachments** and **voice messages** recorded in the browser (converted to MP3 via `ffmpeg-static`). Discord-style **reactions** on both comments *and* uploads (toggle, counters, tooltips, optimistic updates, burst animation), **@mentions** with autocomplete suggestions and profile hover cards - all with **live WebSocket updates**
- **Uploads:** Drag & drop (page dropzone + upload popover), **paste media or URLs straight from the clipboard**, file or URL upload. Types: **images** (generated thumbnails), **videos** (embedded Plyr player), **audio** (MP3/FLAC with embedded cover art and music metadata via `music-metadata`, dedicated player dialog), **YouTube** and **Twitch embeds**, plus **link preview cards**
- **Threads & comments:** Every upload is a thread with comments (recursive quotes, lightbox). Comments support **file attachments** and **voice messages** recorded in the browser (converted to MP3 via `ffmpeg-static`, optional **noise suppression**). Discord-style **reactions** on comments *and* uploads - emojis **and stickers** - with toggle, counters, tooltips, optimistic updates and burst animation, plus **@mentions** with autocomplete and profile hover cards. **Bot commands** in the composer (magic 8-ball, coin flip, choose, reminders, …). All with **live WebSocket updates** and smooth **view transitions**
- **Direct & group messages:** Floating, draggable/resizable chat windows plus a dedicated messages page; attachments, voice messages, reactions and read receipts. **Group chats** support invitations that must be **accepted or declined**, leaving groups, and creator-only member removal - synced live via `pushToUser`
- **Follows & blocking:** Follow users to get notified about their uploads and to use the followed-only grid filter. **Blocking** hides a user's uploads and comments, blocks DMs, and removes them from mention suggestions
- **Reports & mod panel:** Report uploads, comments, or users (reason + details, self- and duplicate-protected). The **mod panel** (`mod.access`, `report.moderate`) provides an enriched queue with resolve/dismiss actions and direct links to targets
- **Thread moderation:** Per-thread locks for comments, reactions, stickers, links, and videos (`thread.moderate`)
- **Thread subscriptions & notifications:** Creating, commenting, favoriting, or reacting to a thread automatically subscribes you (`thread_subscriptions` table). Can be toggled per thread via a bell button (a disabled subscription stays disabled even on later participation and also suppresses mentions in that thread). Notifications are **grouped per thread** (no spam from multiple comments), there are **no self-notifications**, and the bell shows live events plus toasts
- **Announcement (MOTD):** Admin-editable slim bar below the header (`MotdBanner.vue`), publicly readable and updated live via WebSocket
- **Tags & filters:** Uploads can be tagged; filter popover by **type, uploader, time range, and tag** (URL query–driven)
- **Command palette:** `Ctrl/Cmd + K` opens a global search across uploads/users/actions
- **Tags & filters:** Uploads can be tagged; filter popover by **type, uploader, tag, calendar date range, and followed users**. Admins can attach **icons (emoji, sticker, Iconify, or uploaded image) and animation effects** to tags (`tag.manage`)
- **Command palette:** `Ctrl/Cmd + K` opens a global search across uploads/users/actions, including a random-thread action
- **Favorites:** Favorite uploads, visible on the profile page
- **Auth & roles:** Session-cookie login (30 days); the first registered user automatically becomes admin. **Admin panel** with user list, role management, and **per-role configurable permissions** (`admin.access`, `roles.manage`, `users.manage`, `site.manage`, `comment.delete`, `upload.delete`, `tag.delete`) - the `admin` role is an implicit wildcard and can do everything; `moderator` is a system role with moderation rights
- **Profiles & personalization:** Profile pages with own uploads, comments, and stats; user overview page. In settings: **avatar** (max. 5 MB), **name color**, **card effects** (WebGL shaders via `ogl` - Aurora, Particles, Plasma, Silk, Dither, Scanner and more as hover-card backgrounds), **border effects**, and vignette
- **Themes:** 21 themes (Standard, Discord, Cyberpunk, Spring, Terminal, Infoseer, Homebrew, Florense, Kawi Green, Clo.nz, Clawmorphism, Realmorphism, Amber, Sunny Sprout, Spotify, Nordic Theme, SS, Amethyst Haze, Deep Wine, myClaude, Neo), switched via `data-theme` on `<html>`, persisted via cookie - SSR-safe without flash. The imported TweakCN themes are included as dark variants only
- **Layouts:** Switchable Bento/uniform grid for the upload overview
- **Halls:** Curated upload collections - user halls and admin-managed **standard halls** (`hall.standard`), with visibility controls, stacked cover thumbnails, and a uniform thumbnail grid
- **Stickers & memes:** Standard stickers (admin-managed, `sticker.standard`) plus personal sticker slots from the shop; `:name:` shortcodes render inline. **Meme generator** with admin-managed standard templates (`meme.standard`)
- **Points & shop:** Point balance spent on **public display names**, **flair**, **animated name gradients**, **golden upload frames** (animated pulse/beam variants), timed upload effects, a **mystery box**, and extra sticker slots. Purchased effects can be deactivated without a refund. **Prices are admin-configurable**, and admins can gift points to users
- **Bot & YouTube monitoring:** Composer bot commands (8-ball, coin, choose, reminders, …) and **monitored YouTube channels** polled on a configurable interval - new videos are uploaded automatically as bot posts
- **Statistics:** Stats page with Unovis charts
- **Auth & roles:** Session-cookie login (30 days); the first registered user automatically becomes admin. **Admin panel** with user list, role management, and **per-role configurable permissions** (`admin.access`, `roles.manage`, `users.manage`, `site.manage`, `comment.delete`, `thread.moderate`, `upload.delete`, `tag.delete`, `tag.manage`, `sticker.standard`, `meme.standard`, `hall.standard`, `mod.access`, `report.moderate`) - the `admin` role is an implicit wildcard and can do everything; `moderator` is a system role with moderation rights. Configurable **site limits** (upload sizes, attachments, …) and optional **guest access** via site settings
- **Profiles & personalization:** Profile pages with own uploads, comments, stats, **bio** and **role badges**. Rich hover cards with media banners and **3D holographic effects** (Galaxy, Cosmos holofoil). In settings: **avatar** (max. 5 MB), **name color**, **card effects** (WebGL shaders via `ogl` - Aurora, Particles, Plasma, Silk, Dither, Scanner and more as hover-card backgrounds), **border effects**, and vignette
- **Themes:** 33 themes (Standard, Discord Ash/Dark/Onyx, 4f, Cyberpunk, Spring, Terminal, Infoseer, Homebrew, Florense, Kawi Green, Clo.nz, Clawmorphism, Realmorphism, Amber, Sunny Sprout, Spotify, Nordic Theme, SS, Amethyst Haze, Deep Wine, myClaude, Focused, Claude +, 2077, Astra, Apotheosis Mint Midnight, Emerald, Vulkan, Sakura, Minimal Neutral, Neo), switched via `data-theme` on `<html>`, persisted via cookie - SSR-safe without flash. The imported TweakCN themes are included as dark variants only
- **Layouts:** Switchable Bento/uniform grid for the upload overview; Bento tiles grow with activity
- **i18n:** German (default) and English UI via `@nuxtjs/i18n`, persisted in a locale cookie
## Tech stack
| Area | Technology |
|---|---|
| Framework | Nuxt 4 / Nitro, Vue 3 (`<script setup>` + TS) |
| UI | shadcn-vue (`shadcn-nuxt`), Reka UI, Tailwind CSS v4, Phosphor/Simple Icons via `@nuxt/icon` (local SVG bundle mode), vue-sonner |
| UI | shadcn-vue (`shadcn-nuxt`), Reka UI, Tailwind CSS v4, Phosphor/Simple Icons via `@nuxt/icon` (local SVG bundle mode), vue-sonner, emoji-picker-element |
| i18n | `@nuxtjs/i18n` (de + en) |
| Database | MariaDB 11 + Drizzle ORM (`mysql2`) |
| Realtime | Nitro WebSocket (`crossws`) |
| Realtime | Nitro WebSocket (`crossws`) - `broadcastToAll` + per-user `pushToUser` |
| Media | `@nuxt/image`, Plyr, `music-metadata`, `ffmpeg-static`, `ogl` (shader effects) |
| Data/Utils | `@tanstack/vue-table`, Zod, Unovis charts, dnd-kit-vue |
| State | Nuxt `useState` composables, VueUse |
@@ -36,35 +47,54 @@ An imageboard-style upload and discussion platform built with **Nuxt 4** and **s
app/
assets/css/tailwind.css # Tailwind v4 entry + all theme tokens ([data-theme="…"])
components/ # App components (header, dropzone, tiles, comments,
# MotdBanner, CommandPalette, ThreadSubscriptionButton, …)
# MotdBanner, CommandPalette, DmSidebar/DmFloatingWindow,
# StickerPicker, ReportDialog, UploadFilters, halls, …)
effects/ # WebGL card effects + vignette/border
ui/ # shadcn-vue base components
composables/ # useAuth, useTheme, useCommandPalette, useCommentReactions,
# useNotifications, useCardShader, …
# useNotifications, useDirectMessages, useStickers,
# useSiteSettings, useCardShader, …
layouts/ middleware/ plugins/ pages/
index.vue # Upload grid with filters / homepage
thread/[id].vue # Thread detail page with comments, attachments, voice recording
admin.vue # Admin panel (users, roles, permissions, MOTD)
settings.vue # Profile settings (avatar, color, card effects)
t/[id].vue # Thread detail page with comments, attachments, voice recording
messages/[[id]].vue # Direct & group messages
halls/ # Hall overview + detail page
memes.vue shop.vue stats.vue channels.vue
admin.vue # Admin panel (users, roles, permissions, limits, prices,
# MOTD, tags, site settings)
mod.vue # Moderator panel (report queue)
settings.vue # Profile settings (avatar, color, card effects, bio)
users.vue user/[id].vue login.vue register.vue
server/
api/ # Nitro endpoints (auth, uploads, comments, notifications,
# users, tags, link-preview, motd, admin/…)
# users, tags, halls, stickers, memes, messages, reports,
# mod, shop, stats, youtube, admin/, link-preview, motd,
# site, limits, icons, health)
db/schema.ts # Drizzle schema (users, sessions, uploads, comments,
# comment_attachments, comment_reactions,
# upload_reactions, favorites, thread_subscriptions,
# link_previews, notifications, roles, user_roles,
# permissions, role_permissions, tags, upload_tags,
# settings)
# settings, direct_messages(+_reactions), dm_groups(+_members),
# user_follows, user_blocks, reports, halls(+_items),
# stickers, meme_templates, point_purchases, bot_reminders,
# youtube_channels(+_subs), site_stats)
db/migrations/ # Generated SQL migrations
plugins/migrate.ts # Runs migrations on server start (dev)
routes/_ws.ts # WebSocket route for live events (broadcastToAll)
utils/ # auth (requirePermission), db, roles, uploads,
# notifications, threadSubscriptions, linkPreview
plugins/migrate.ts # Runs migrations + permission seeding on server start (dev only)
routes/_ws.ts # WebSocket route for live events (broadcastToAll / pushToUser)
routes/uploads/ # Uploaded-file serving with guest-access check
utils/ # auth (requirePermission), db, roles, uploads, blocks,
# notifications, threadSubscriptions, linkPreview,
# bot, dm, halls, youtubeFeed, site, limits, shop, quotes
scripts/migrate.ts # Manual migration runner (npm run db:migrate)
shared/utils/ # Shared utilities - incl. permissions.ts (catalog + seeding)
public/uploads/ # Upload target, incl. uploads/avatars/ (gitignored)
docker-compose.yml # MariaDB container
shared/utils/ # Shared utilities - incl. permissions.ts (catalog + seeding),
# reports.ts, site.ts, messages.ts, stickers.ts, bot.ts
i18n/locales/ # de.json + en.json (same key structure)
uploads/ # Upload target (gitignored): media, avatars, stickers,
# tag-icons, hall covers, thumbnails, …
Dockerfile # Two-stage build → Nitro .output (production)
docker-compose.yml # MariaDB container (local development)
docker-compose.prod.yml # Production stack (app + MariaDB) for Coolify
```
## Setup
@@ -100,6 +130,15 @@ Provide your own MariaDB/MySQL instance and adjust `DATABASE_URL` in `.env`:
DATABASE_URL=mysql://user:pass@host:3306/database
```
### Production / self-hosting
`docker-compose.prod.yml` defines the production stack (Nuxt app + MariaDB with healthcheck, persistent `uploads` and `db_data` volumes) and is meant for orchestrators like **Coolify**. Notes:
- `DB_PASSWORD` / `DB_ROOT_PASSWORD` come from the environment (set them in Coolify, never commit them)
- The `Dockerfile` builds the Nitro output on `node:24-slim` (Debian - required for the `ffmpeg-static` binary); migrations + permission seeding run automatically on container start
- Uploads live in `uploads/` relative to the app working directory - keep it on a persistent volume
- The app serves everything (HTTP + WebSocket) on port 3000 behind the reverse proxy
## NPM scripts
| Command | Purpose |
@@ -115,7 +154,8 @@ DATABASE_URL=mysql://user:pass@host:3306/database
- **Change the schema:** Edit `server/db/schema.ts` → `npm run db:generate` → commit the migration. In dev mode, `server/plugins/migrate.ts` applies it automatically on start.
- **Add a new permission:** Add an entry to `PERMISSIONS` in `shared/utils/permissions.ts` → seeded on next start via `seedPermissions()`; check server-side with `requirePermission(event, key)`, client-side with `can(key)` from `useAuth`.
- **Live events:** The server broadcasts via `broadcastToAll` in `server/routes/_ws.ts`; the client handles events in `app/plugins/notifications.client.ts`.
- **Add a new theme:** Create a `[data-theme="…"]` block with the CSS variables in `app/assets/css/tailwind.css`, add the name to `THEME_NAMES` in `app/composables/useTheme.ts`, and add an entry in `app/components/ThemeSelector.vue`. Themes can also override the `shadow-*` tokens (glow, offset, or flat shadows) and `font-sans`/`font-heading`/`font-mono`; `--font-logo` intentionally stays theme-independent.
- **Live events:** The server broadcasts via `broadcastToAll`/`pushToUser` in `server/routes/_ws.ts`; the client handles events in `app/plugins/notifications.client.ts` and registered handlers (e.g. DM handlers in `useDirectMessages`).
- **UI strings:** All strings go through `$t()`/`t()` - keep `i18n/locales/de.json` and `en.json` key-identical.
- **Add a new theme:** Create a `[data-theme="…"]` block with the CSS variables in `app/assets/css/tailwind.css`, add the name to `THEME_NAMES` and `THEMES` in `app/composables/useTheme.ts`. Themes can also override the `shadow-*` tokens (glow, offset, or flat shadows) and `font-sans`/`font-heading`/`font-mono`; `--font-logo` intentionally stays theme-independent.
- **Conventions:** Semantic tokens instead of hardcoded colors (`primary`, `accent`, `muted`, …); `rounded-md`/`rounded-sm` scale via `--radius` with the theme (`neo` is square: `--radius: 0`). More details in `AGENTS.md`.
- **Health check:** `GET /api/health` reports the DB connection status.
+707 -2
View File
@@ -2,6 +2,18 @@
@import url('https://fonts.googleapis.com/css2?family=Anton&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Basic&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Electrolize&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Michroma&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
@import "tailwindcss";
@import "tw-animate-css";
@@ -56,7 +68,7 @@
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.6);
--radius-3xl: calc(var(--radius) * 2.4);
}
@@ -137,12 +149,90 @@
--sidebar-ring: oklch(0.7151 0.1523 235.2888);
}
/* Discord „Dunkel“-Theme: offizielle Werte #1A1A1E (Bg) / #242429 (Card),
eine Stufe dunkler als Asche; Blurple + Textfarbe wie oben */
[data-theme="discord-dark"] {
--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: #1a1a1e;
--foreground: oklch(1 0 0);
--card: #242429;
--card-foreground: oklch(1 0 0);
--popover: #242429;
--popover-foreground: oklch(1 0 0);
--primary: oklch(0.5774 0.2091 273.8504);
--primary-foreground: oklch(1 0 0);
--secondary: #2e2e34;
--secondary-foreground: oklch(1 0 0);
--muted: #202025;
--muted-foreground: oklch(0.723 0.0087 278.582);
--accent: #2e2e34;
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.8004 0.1141 23.185);
--border: #2e2e34;
--input: #17171b;
--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: #17171b;
--sidebar-foreground: oklch(0.6876 0.0102 279.6082);
--sidebar-primary: #2e2e34;
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: #242429;
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: #242429;
--sidebar-ring: oklch(0.7151 0.1523 235.2888);
}
/* Discord „Onyx“-Theme: offizielle Werte #070709 (Bg) / #131416 (Card),
die dunkelste der drei Discord-Stufen */
[data-theme="discord-onyx"] {
--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: #070709;
--foreground: oklch(1 0 0);
--card: #131416;
--card-foreground: oklch(1 0 0);
--popover: #131416;
--popover-foreground: oklch(1 0 0);
--primary: oklch(0.5774 0.2091 273.8504);
--primary-foreground: oklch(1 0 0);
--secondary: #1a1a1e;
--secondary-foreground: oklch(1 0 0);
--muted: #0e0e11;
--muted-foreground: oklch(0.723 0.0087 278.582);
--accent: #1a1a1e;
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.8004 0.1141 23.185);
--border: #1a1a1e;
--input: #0b0b0e;
--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: #050507;
--sidebar-foreground: oklch(0.6876 0.0102 279.6082);
--sidebar-primary: #1a1a1e;
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: #131416;
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: #131416;
--sidebar-ring: oklch(0.7151 0.1523 235.2888);
}
/* 4f-Theme: Dunkelgrau mit Magenta-Primary */
[data-theme="4f"] {
--font-sans: 'Figtree', sans-serif;
--font-heading: 'Manrope', sans-serif;
--font-logo: 'Archivo Black', sans-serif;
--radius: 0.625rem;
--radius: 0rem;
--background: #323232;
--foreground: oklch(0.95 0 0);
--card: #323232;
@@ -176,6 +266,12 @@
--sidebar-ring: #ff0096;
}
[data-theme="4f"] *,
[data-theme="4f"] *::before,
[data-theme="4f"] *::after {
border-radius: 0 !important;
}
/* Cyberpunk-Theme (tweakcn „赛博朋克", Dark-Variante):
Neon-Grün auf Schwarz, Magenta-Primary, eckig, Glow-Schatten */
[data-theme="cyberpunk"] {
@@ -1206,6 +1302,518 @@
letter-spacing: var(--letter-spacing);
}
/* Claude-Plus-Theme (tweakcn „Claude +", Dark-Variante):
warmes Anthrazit mit Claude-Orange und Outfit-Schrift */
[data-theme="claude-plus"] {
color-scheme: dark;
--font-sans: 'Outfit', sans-serif;
--font-heading: 'Outfit', sans-serif;
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
--radius: 1rem;
--background: oklch(0.2679 0.0036 106.6427);
--foreground: oklch(0.9576 0.0027 106.4494);
--card: oklch(0.2928 0.0018 106.5092);
--card-foreground: oklch(0.9818 0.0054 95.0986);
--popover: oklch(0.3085 0.0035 106.6039);
--popover-foreground: oklch(0.9211 0.0040 106.4781);
--primary: oklch(0.6724 0.1308 38.7559);
--primary-foreground: oklch(0.1908 0.0020 106.5859);
--secondary: oklch(0.9818 0.0054 95.0986);
--secondary-foreground: oklch(0.3085 0.0035 106.6039);
--muted: oklch(0.2213 0.0038 106.7070);
--muted-foreground: oklch(0.7713 0.0169 99.0657);
--accent: oklch(0.2130 0.0078 95.4245);
--accent-foreground: oklch(0.9663 0.0080 98.8792);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.3618 0.0101 106.8928);
--input: oklch(0.4336 0.0113 100.2195);
--ring: oklch(0.6724 0.1308 38.7559);
--chart-1: oklch(0.5583 0.1276 42.9956);
--chart-2: oklch(0.6898 0.1581 290.4107);
--chart-3: oklch(0.2130 0.0078 95.4245);
--chart-4: oklch(0.3074 0.0516 289.3230);
--chart-5: oklch(0.5608 0.1348 42.0584);
--sidebar: oklch(0.2357 0.0024 67.7077);
--sidebar-foreground: oklch(0.8074 0.0142 93.0137);
--sidebar-primary: oklch(0.3250 0 0);
--sidebar-primary-foreground: oklch(0.9881 0 0);
--sidebar-accent: oklch(0.1680 0.0020 106.6177);
--sidebar-accent-foreground: oklch(0.8074 0.0142 93.0137);
--sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0.7731 0 0);
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.1;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0;
--shadow-offset-y: 1px;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--letter-spacing: 0em;
--tracking-normal: 0em;
--spacing: 0.25rem;
}
[data-theme="claude-plus"] body {
letter-spacing: var(--letter-spacing);
}
/* 2077-Theme (tweakcn, Dark-Variante): schwarzer Grund,
orange-rote Akzente, eckige Retro-Futurismus-Flächen */
[data-theme="2077"] {
color-scheme: dark;
--font-sans: 'Chakra Petch', ui-sans-serif, sans-serif, system-ui;
--font-heading: 'Chakra Petch', ui-sans-serif, sans-serif, system-ui;
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: 'IBM Plex Mono', ui-monospace, monospace;
--radius: 0rem;
--background: oklch(0 0 0);
--foreground: oklch(1.0000 0 0);
--card: oklch(0 0 0);
--card-foreground: oklch(1.0000 0 0);
--popover: oklch(0 0 0);
--popover-foreground: oklch(1.0000 0 0);
--primary: oklch(0.6152 0.1657 26.9800);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.6152 0.1657 26.9800);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.2246 0.0094 107.1335);
--muted-foreground: oklch(1.0000 0 0);
--accent: oklch(0.6152 0.1657 26.9800);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.6152 0.1657 26.9800);
--destructive-foreground: oklch(0 0 0);
--border: oklch(0.2520 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6152 0.1657 26.9800);
--chart-1: oklch(0.7152 0.1485 58.8324);
--chart-2: oklch(0.8299 0.1718 107.4066);
--chart-3: oklch(0.5798 0.1829 259.5141);
--chart-4: oklch(0.4962 0.2332 288.0574);
--chart-5: oklch(0.6152 0.1657 26.9800);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(1.0000 0 0);
--sidebar-primary: oklch(0.6152 0.1657 26.9800);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.6152 0.1657 26.9800);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6152 0.1657 26.9800);
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.1;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0;
--shadow-offset-y: 1px;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--letter-spacing: 0em;
--tracking-normal: 0em;
--spacing: 0.25rem;
}
[data-theme="2077"] body {
letter-spacing: var(--letter-spacing);
}
/* Astra-Theme (tweakcn, Dark-Variante): dunkles Neon-Violett,
Magenta-Primary, Cyan-Akzente und Glow-Schatten */
[data-theme="astra"] {
color-scheme: dark;
--font-sans: 'Basic', ui-sans-serif, sans-serif, system-ui;
--font-heading: 'Basic', ui-sans-serif, sans-serif, system-ui;
--font-serif: 'Georgia', serif;
--font-mono: 'JetBrains Mono', monospace;
--radius: 0.5rem;
--background: oklch(0.1418 0.0662 295.8049);
--foreground: oklch(0.9714 0.0141 343.1982);
--card: oklch(0.1947 0.0674 299.8714);
--card-foreground: oklch(0.9714 0.0141 343.1982);
--popover: oklch(0.1947 0.0674 299.8714);
--popover-foreground: oklch(0.9714 0.0141 343.1982);
--primary: oklch(0.7017 0.3225 328.3634);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.5136 0.2930 288.6666);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.2511 0.1142 301.6663);
--muted-foreground: oklch(0.7217 0.1767 305.5038);
--accent: oklch(0.9054 0.1546 194.7689);
--accent-foreground: oklch(0.1418 0.0662 295.8049);
--destructive: oklch(0.6528 0.2381 13.7745);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.3796 0.1783 293.7446);
--input: oklch(0.3796 0.1783 293.7446);
--ring: oklch(0.7017 0.3225 328.3634);
--chart-1: oklch(0.7017 0.3225 328.3634);
--chart-2: oklch(0.5136 0.2930 288.6666);
--chart-3: oklch(0.9054 0.1546 194.7689);
--chart-4: oklch(0.8369 0.1644 84.4286);
--chart-5: oklch(0.7253 0.1752 349.7607);
--sidebar: oklch(0.1604 0.0696 295.6276);
--sidebar-foreground: oklch(0.9714 0.0141 343.1982);
--sidebar-primary: oklch(0.7017 0.3225 328.3634);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.2511 0.1142 301.6663);
--sidebar-accent-foreground: oklch(0.7017 0.3225 328.3634);
--sidebar-border: oklch(0.3796 0.1783 293.7446);
--sidebar-ring: oklch(0.7017 0.3225 328.3634);
--shadow-color: #ff00ff;
--shadow-opacity: 0.5;
--shadow-blur: 15px;
--shadow-spread: 1px;
--shadow-offset-x: 0px;
--shadow-offset-y: 0px;
--shadow-2xs: 0px 0px 15px 1px hsl(300 100% 50% / 0.25);
--shadow-xs: 0px 0px 15px 1px hsl(300 100% 50% / 0.25);
--shadow-sm: 0px 0px 15px 1px hsl(300 100% 50% / 0.50), 0px 1px 2px 0px hsl(300 100% 50% / 0.50);
--shadow: 0px 0px 15px 1px hsl(300 100% 50% / 0.50), 0px 1px 2px 0px hsl(300 100% 50% / 0.50);
--shadow-md: 0px 0px 15px 1px hsl(300 100% 50% / 0.50), 0px 2px 4px 0px hsl(300 100% 50% / 0.50);
--shadow-lg: 0px 0px 15px 1px hsl(300 100% 50% / 0.50), 0px 4px 6px 0px hsl(300 100% 50% / 0.50);
--shadow-xl: 0px 0px 15px 1px hsl(300 100% 50% / 0.50), 0px 8px 10px 0px hsl(300 100% 50% / 0.50);
--shadow-2xl: 0px 0px 15px 1px hsl(300 100% 50% / 1.25);
--letter-spacing: 0.05em;
--tracking-normal: 0.05em;
--spacing: 0.25rem;
}
[data-theme="astra"] body {
letter-spacing: var(--letter-spacing);
}
/* Apotheosis-Mint-Midnight-Theme (tweakcn, Dark-Variante):
schwarzer Grund, Mint-Schrift, Violett-Akzente und Glow-Schatten */
[data-theme="apotheosis-mint-midnight"] {
color-scheme: dark;
--font-sans: 'Electrolize', ui-sans-serif, sans-serif, system-ui;
--font-heading: 'Michroma', ui-sans-serif, sans-serif, system-ui;
--font-serif: 'Michroma', ui-sans-serif, sans-serif, system-ui;
--font-mono: 'Space Mono', ui-monospace, monospace;
--radius: 0.625rem;
--background: oklch(0.1448 0 0);
--foreground: oklch(0.8871 0.1828 166.5465);
--card: oklch(0.1696 0.0017 17.3207);
--card-foreground: oklch(0.8871 0.1828 166.5465);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.8871 0.1828 166.5465);
--primary: oklch(0.5038 0.2937 285.3753);
--primary-foreground: oklch(0.8871 0.1828 166.5465);
--secondary: oklch(0.5038 0.2937 285.3753);
--secondary-foreground: oklch(0.8871 0.1828 166.5465);
--muted: oklch(0.1888 0.1055 292.0750);
--muted-foreground: oklch(0.8871 0.1828 166.5465);
--accent: oklch(0.1888 0.1055 292.0750);
--accent-foreground: oklch(0.8871 0.1828 166.5465);
--destructive: oklch(0 0 0);
--destructive-foreground: oklch(0.6670 0.2950 322.1500);
--border: oklch(0.5038 0.2937 285.3753);
--input: oklch(0.5038 0.2937 285.3753);
--ring: oklch(0.8871 0.1828 166.5465);
--chart-1: oklch(0.8871 0.1828 166.5465);
--chart-2: oklch(0.5597 0.2956 301.9121);
--chart-3: oklch(0.3451 0.2089 279.9220);
--chart-4: oklch(0.8653 0.1475 204.0171);
--chart-5: oklch(0.5038 0.2937 285.3753);
--sidebar: oklch(0.1448 0 0);
--sidebar-foreground: oklch(0.8871 0.1828 166.5465);
--sidebar-primary: oklch(0.5038 0.2937 285.3753);
--sidebar-primary-foreground: oklch(0.8871 0.1828 166.5465);
--sidebar-accent: oklch(0.3451 0.2089 279.9220);
--sidebar-accent-foreground: oklch(0.8653 0.1475 204.0171);
--sidebar-border: oklch(0.3451 0.2089 279.9220);
--sidebar-ring: oklch(0.8871 0.1828 166.5465);
--shadow-color: #6600ff;
--shadow-opacity: 0.5;
--shadow-blur: 10px;
--shadow-spread: -2px;
--shadow-offset-x: 0px;
--shadow-offset-y: 0px;
--shadow-2xs: 0px 0px 10px -2px hsl(264 100% 50% / 0.25);
--shadow-xs: 0px 0px 10px -2px hsl(264 100% 50% / 0.25);
--shadow-sm: 0px 0px 10px -2px hsl(264 100% 50% / 0.50), 0px 1px 2px -3px hsl(264 100% 50% / 0.50);
--shadow: 0px 0px 10px -2px hsl(264 100% 50% / 0.50), 0px 1px 2px -3px hsl(264 100% 50% / 0.50);
--shadow-md: 0px 0px 10px -2px hsl(264 100% 50% / 0.50), 0px 2px 4px -3px hsl(264 100% 50% / 0.50);
--shadow-lg: 0px 0px 10px -2px hsl(264 100% 50% / 0.50), 0px 4px 6px -3px hsl(264 100% 50% / 0.50);
--shadow-xl: 0px 0px 10px -2px hsl(264 100% 50% / 0.50), 0px 8px 10px -3px hsl(264 100% 50% / 0.50);
--shadow-2xl: 0px 0px 10px -2px hsl(264 100% 50% / 1.25);
--letter-spacing: 0.075em;
--tracking-normal: 0.075em;
--spacing: 0.25rem;
}
[data-theme="apotheosis-mint-midnight"] body {
letter-spacing: var(--letter-spacing);
}
/* Emerald-Theme (tweakcn, Dark-Variante): tiefes Grün auf Schwarz,
sehr große Rundungen und weiche Schatten */
[data-theme="emerald"] {
color-scheme: dark;
--font-sans: 'Geist', ui-sans-serif, sans-serif, system-ui;
--font-heading: 'Geist', ui-sans-serif, sans-serif, system-ui;
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--radius: 1.875rem;
--background: oklch(0 0 0);
--foreground: oklch(0.9480 0.1063 136.6182);
--card: oklch(0.1394 0.0188 144.4860);
--card-foreground: oklch(0.6018 0.1867 143.5144);
--popover: oklch(0.2235 0.0648 138.5618);
--popover-foreground: oklch(0.9761 0 0);
--primary: oklch(0.7562 0.1879 135.2688);
--primary-foreground: oklch(0.3317 0.0955 140.6918);
--secondary: oklch(0.4064 0.1223 139.4284);
--secondary-foreground: oklch(0.7562 0.1879 135.2688);
--muted: oklch(0.2127 0.0637 136.4132);
--muted-foreground: oklch(0.8872 0.0915 135.0400);
--accent: oklch(0.3216 0.0820 135.2826);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0.7040 0.1910 22.2160);
--destructive-foreground: oklch(0.9850 0 0);
--border: oklch(0.1878 0.0495 136.3376);
--input: oklch(0.3208 0.0916 137.1633);
--ring: oklch(0.4773 0.1390 138.5743);
--chart-1: oklch(0.8022 0.2412 136.8747);
--chart-2: oklch(0.7045 0.2326 142.7036);
--chart-3: oklch(0.5041 0.1565 139.3263);
--chart-4: oklch(0.4169 0.1362 141.7016);
--chart-5: oklch(0.3287 0.1114 142.3311);
--sidebar: oklch(0.1670 0.0467 149.3431);
--sidebar-foreground: oklch(0.9304 0.1324 142.7013);
--sidebar-primary: oklch(0.6871 0.2295 142.8480);
--sidebar-primary-foreground: oklch(0.5383 0.1493 143.4647);
--sidebar-accent: oklch(0.2652 0.0710 132.5432);
--sidebar-accent-foreground: oklch(0.6472 0.1551 139.2676);
--sidebar-border: oklch(0.4949 0.1102 137.9803);
--sidebar-ring: oklch(0.6576 0.0279 17.8828);
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.45;
--shadow-blur: 30px;
--shadow-spread: 0px;
--shadow-offset-x: 0;
--shadow-offset-y: 1px;
--shadow-2xs: 0 1px 30px 0px hsl(0 0% 0% / 0.23);
--shadow-xs: 0 1px 30px 0px hsl(0 0% 0% / 0.23);
--shadow-sm: 0 1px 30px 0px hsl(0 0% 0% / 0.45), 0 1px 2px -1px hsl(0 0% 0% / 0.45);
--shadow: 0 1px 30px 0px hsl(0 0% 0% / 0.45), 0 1px 2px -1px hsl(0 0% 0% / 0.45);
--shadow-md: 0 1px 30px 0px hsl(0 0% 0% / 0.45), 0 2px 4px -1px hsl(0 0% 0% / 0.45);
--shadow-lg: 0 1px 30px 0px hsl(0 0% 0% / 0.45), 0 4px 6px -1px hsl(0 0% 0% / 0.45);
--shadow-xl: 0 1px 30px 0px hsl(0 0% 0% / 0.45), 0 8px 10px -1px hsl(0 0% 0% / 0.45);
--shadow-2xl: 0 1px 30px 0px hsl(0 0% 0% / 1.13);
--letter-spacing: 0.075em;
--tracking-normal: 0.075em;
--spacing: 0.25rem;
}
[data-theme="emerald"] body {
letter-spacing: var(--letter-spacing);
}
/* Vulkan-Theme (tweakcn, Dark-Variante): neutrales Dunkelgrau,
rote Akzente, eckige Flächen und keine Schatten */
[data-theme="vulkan"] {
color-scheme: dark;
--font-sans: 'Chakra Petch', ui-sans-serif, sans-serif, system-ui;
--font-heading: 'Chakra Petch', ui-sans-serif, sans-serif, system-ui;
--font-serif: Georgia, serif;
--font-mono: 'IBM Plex Mono', ui-monospace, monospace;
--radius: 0rem;
--background: oklch(0.2178 0 0);
--foreground: oklch(0.9612 0 0);
--card: oklch(0.2178 0 0);
--card-foreground: oklch(0.9612 0 0);
--popover: oklch(0.2178 0 0);
--popover-foreground: oklch(0.9612 0 0);
--primary: oklch(0.5799 0.2203 25.1911);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.2393 0 0);
--secondary-foreground: oklch(0.9851 0 0);
--muted: oklch(0.2393 0 0);
--muted-foreground: oklch(0.7252 0 0);
--accent: oklch(0.2230 0.0725 21.8281);
--accent-foreground: oklch(0.6331 0.1896 20.4467);
--destructive: oklch(0.3901 0.1298 25.5873);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.3715 0 0);
--input: oklch(0.3715 0 0);
--ring: oklch(0.5799 0.2203 25.1911);
--chart-1: oklch(0.5799 0.2203 25.1911);
--chart-2: oklch(0.8853 0 0);
--chart-3: oklch(0.6830 0 0);
--chart-4: oklch(0.5103 0 0);
--chart-5: oklch(0.3211 0 0);
--sidebar: oklch(0.2178 0 0);
--sidebar-foreground: oklch(0.8853 0 0);
--sidebar-primary: oklch(0.5799 0.2203 25.1911);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.2178 0 0);
--sidebar-accent-foreground: oklch(0.6331 0.1896 20.4467);
--sidebar-border: oklch(0.2686 0 0);
--sidebar-ring: oklch(0.5799 0.2203 25.1911);
--shadow-color: #1a1a1a;
--shadow-opacity: 0;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 0px;
--shadow-2xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0);
--shadow-xs: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0);
--shadow-sm: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0);
--shadow: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0), 0px 1px 2px -1px hsl(0 0% 10.1961% / 0);
--shadow-md: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0), 0px 2px 4px -1px hsl(0 0% 10.1961% / 0);
--shadow-lg: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0), 0px 4px 6px -1px hsl(0 0% 10.1961% / 0);
--shadow-xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0), 0px 8px 10px -1px hsl(0 0% 10.1961% / 0);
--shadow-2xl: 0px 0px 0px 0px hsl(0 0% 10.1961% / 0);
--letter-spacing: 0em;
--tracking-normal: 0em;
--spacing: 0.22rem;
}
[data-theme="vulkan"] body {
letter-spacing: var(--letter-spacing);
}
/* Sakura-Theme (tweakcn, Dark-Variante): warmes Dunkelbraun,
rosige Akzente und weiche Schatten */
[data-theme="sakura"] {
color-scheme: dark;
--font-sans: 'Inter', system-ui, sans-serif;
--font-heading: 'Inter', system-ui, sans-serif;
--font-serif: Georgia, serif;
--font-mono: 'JetBrains Mono', monospace;
--radius: 0.5rem;
--background: oklch(0.1979 0.0107 39.2759);
--foreground: oklch(0.9135 0.0123 43.2722);
--card: oklch(0.2379 0.0124 44.5317);
--card-foreground: oklch(0.9135 0.0123 43.2722);
--popover: oklch(0.2379 0.0124 44.5317);
--popover-foreground: oklch(0.9135 0.0123 43.2722);
--primary: oklch(0.7508 0.1610 2.6024);
--primary-foreground: oklch(0.1979 0.0107 39.2759);
--secondary: oklch(0.2696 0.0148 39.2735);
--secondary-foreground: oklch(0.9135 0.0123 43.2722);
--muted: oklch(0.2696 0.0148 39.2735);
--muted-foreground: oklch(0.6608 0.0272 49.5764);
--accent: oklch(0.2964 0.0372 5.9697);
--accent-foreground: oklch(0.8436 0.0913 2.8077);
--destructive: oklch(0.6256 0.1933 23.0261);
--destructive-foreground: oklch(0.9921 0.0017 325.5900);
--border: oklch(0.2937 0.0152 45.3658);
--input: oklch(0.2937 0.0152 45.3658);
--ring: oklch(0.7508 0.1610 2.6024);
--chart-1: oklch(0.7508 0.1610 2.6024);
--chart-2: oklch(0.8436 0.0913 2.8077);
--chart-3: oklch(0.7621 0.0581 33.4626);
--chart-4: oklch(0.4279 0.0265 46.6194);
--chart-5: oklch(0.2937 0.0152 45.3658);
--sidebar: oklch(0.1740 0.0094 42.9895);
--sidebar-foreground: oklch(0.9135 0.0123 43.2722);
--sidebar-primary: oklch(0.7508 0.1610 2.6024);
--sidebar-primary-foreground: oklch(0.1979 0.0107 39.2759);
--sidebar-accent: oklch(0.2964 0.0372 5.9697);
--sidebar-accent-foreground: oklch(0.8436 0.0913 2.8077);
--sidebar-border: oklch(0.2696 0.0148 39.2735);
--sidebar-ring: oklch(0.7508 0.1610 2.6024);
--shadow-color: 0 0% 0%;
--shadow-opacity: 0.3;
--shadow-blur: 15px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 4px;
--shadow-2xs: 0px 4px 15px 0px hsl(0 0% 0% / 0.15);
--shadow-xs: 0px 4px 15px 0px hsl(0 0% 0% / 0.15);
--shadow-sm: 0px 4px 15px 0px hsl(0 0% 0% / 0.30), 0px 1px 2px -1px hsl(0 0% 0% / 0.30);
--shadow: 0px 4px 15px 0px hsl(0 0% 0% / 0.30), 0px 1px 2px -1px hsl(0 0% 0% / 0.30);
--shadow-md: 0px 4px 15px 0px hsl(0 0% 0% / 0.30), 0px 2px 4px -1px hsl(0 0% 0% / 0.30);
--shadow-lg: 0px 4px 15px 0px hsl(0 0% 0% / 0.30), 0px 4px 6px -1px hsl(0 0% 0% / 0.30);
--shadow-xl: 0px 4px 15px 0px hsl(0 0% 0% / 0.30), 0px 8px 10px -1px hsl(0 0% 0% / 0.30);
--shadow-2xl: 0px 4px 15px 0px hsl(0 0% 0% / 0.75);
--letter-spacing: -0.01em;
--tracking-normal: -0.01em;
--spacing: 0.25rem;
}
[data-theme="sakura"] body {
letter-spacing: var(--letter-spacing);
}
/* Minimal-Neutral-Theme (tweakcn, Dark-Variante):
schlichtes Monochrome mit großzügigen Rundungen */
[data-theme="minimal-neutral"] {
color-scheme: dark;
--font-sans: 'DM Sans', ui-sans-serif, sans-serif, system-ui;
--font-heading: 'DM Sans', ui-sans-serif, sans-serif, system-ui;
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
--radius: 1rem;
--background: oklch(0.2050 0 0);
--foreground: oklch(0.9850 0 0);
--card: oklch(0.1650 0 0);
--card-foreground: oklch(0.9850 0 0);
--popover: oklch(0.2050 0 0);
--popover-foreground: oklch(0.9850 0 0);
--primary: oklch(0.9220 0 0);
--primary-foreground: oklch(0.2050 0 0);
--secondary: oklch(0.2690 0 0);
--secondary-foreground: oklch(0.9850 0 0);
--muted: oklch(0.2690 0 0);
--muted-foreground: oklch(0.7080 0 0);
--accent: oklch(0.3710 0 0);
--accent-foreground: oklch(0.9850 0 0);
--destructive: oklch(0.7040 0.1910 22.2160);
--destructive-foreground: oklch(0.9850 0 0);
--border: oklch(0.2690 0 0);
--input: oklch(0.2690 0 0);
--ring: oklch(0.5560 0 0);
--chart-1: oklch(0.8100 0.1000 252);
--chart-2: oklch(0.6200 0.1900 260);
--chart-3: oklch(0.5500 0.2200 263);
--chart-4: oklch(0.4900 0.2200 264);
--chart-5: oklch(0.4200 0.1800 266);
--sidebar: oklch(0.2690 0 0);
--sidebar-foreground: oklch(0.9850 0 0);
--sidebar-primary: oklch(0.4880 0.2430 264.3760);
--sidebar-primary-foreground: oklch(0.9850 0 0);
--sidebar-accent: oklch(0.2690 0 0);
--sidebar-accent-foreground: oklch(0.9850 0 0);
--sidebar-border: oklch(0.2750 0 0);
--sidebar-ring: oklch(0.4390 0 0);
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.1;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0;
--shadow-offset-y: 1px;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--letter-spacing: 0em;
--tracking-normal: 0em;
--spacing: 0.25rem;
}
[data-theme="minimal-neutral"] body {
letter-spacing: var(--letter-spacing);
}
/* Neo-Brutalismus: helle Pastell-Flächen, schwarze 2px-Borders,
harte Offset-Schatten, keine Rundungen */
@@ -1325,3 +1933,100 @@ svg.iconify--ph {
.thread-reveal {
animation: thread-reveal 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Laufender Farbverlauf für den Shop-Namenseffekt (background-size: 200% wird
inline gesetzt, damit die Gradientfarben je Nutzer variieren können) */
@keyframes name-gradient-shift {
from {
background-position: 0% 50%;
}
to {
background-position: 200% 50%;
}
}
/* Pulsierender Glow ueber drop-shadow: folgt der Glyphenform, weil er auf der
gerenderten Alpha-Kante des background-clip:text-Elements arbeitet */
@keyframes name-gradient-glow {
0%, 100% {
filter: drop-shadow(0 0 3px var(--color-accent)) drop-shadow(0 0 6px var(--color-accent));
}
50% {
filter: drop-shadow(0 0 6px var(--color-accent)) drop-shadow(0 0 16px var(--color-accent));
}
}
/* Tag-Deko-Effekte (Admin konfigurierbar) - glow/wiggle laufen als Klasse
auf der Pill, sparkle/confetti/shine als Overlay-Kinder */
@keyframes tag-glow {
0%, 100% {
box-shadow: 0 0 4px 1px var(--color-accent);
}
50% {
box-shadow: 0 0 14px 3px var(--color-accent);
}
}
.tag-fx-glow {
animation: tag-glow 2.2s ease-in-out infinite;
}
@keyframes tag-wiggle {
0%, 100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-2.5deg);
}
75% {
transform: rotate(2.5deg);
}
}
.tag-fx-wiggle {
animation: tag-wiggle 1.6s ease-in-out infinite;
}
/* Funkel-Partikel steigen ueber die Pill hinaus auf (wie Super-Reactions) */
@keyframes tag-sparkle {
0% {
transform: translateY(0) scale(0.4);
opacity: 0;
}
25% {
opacity: 1;
}
100% {
transform: translateY(-18px) scale(1);
opacity: 0;
}
}
.tag-fx-sparkle-bit {
animation: tag-sparkle 1.8s ease-in-out infinite;
line-height: 1;
}
/* Konfetti-Schnipsel fliegen aus der Pill-Mitte nach oben auseinander */
@keyframes tag-confetti {
0% {
transform: translate(0, 0) rotate(0deg);
opacity: 0;
}
15% {
opacity: 1;
}
100% {
transform: translate(var(--cx), var(--cy)) rotate(240deg);
opacity: 0;
}
}
.tag-fx-confetti-bit {
animation: tag-confetti 1.4s ease-out infinite;
}
/* Schimmer-Streifen wandert durch die Pill (im Overlay geclippt) */
@keyframes tag-shine {
from {
transform: translateX(-150%) skewX(-20deg);
}
to {
transform: translateX(350%) skewX(-20deg);
}
}
.tag-fx-shine-beam {
animation: tag-shine 2.4s ease-in-out infinite;
}
+282
View File
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export type HallVisibility = 'public' | 'unlisted' | 'private'
export interface HallOption {
id: string
title: string
visibility: HallVisibility
isStandard: boolean
itemCount: number
containsUpload?: boolean
canEdit: boolean
}
const props = defineProps<{
uploadId: string
}>()
const { user, can } = useAuth()
const { t } = useI18n()
const open = ref(false)
const loading = ref(false)
const lists = ref<HallOption[]>([])
const togglingId = ref<string | null>(null)
const creatingOpen = ref(false)
const newTitle = ref('')
const newVisibility = ref<HallVisibility>('public')
const newIsStandard = ref(false)
const creating = ref(false)
const canManageStandard = computed(() => can('hall.standard'))
async function loadHalls() {
if (!user.value) return
loading.value = true
try {
const res = await $fetch<{
standard: HallOption[]
mine: HallOption[]
}>('/api/halls', {
query: { forUpload: props.uploadId },
})
const editableStandard = res.standard.filter(h => h.canEdit)
const seen = new Set<string>()
const merged: HallOption[] = []
for (const h of [...editableStandard, ...res.mine]) {
if (!seen.has(h.id)) {
seen.add(h.id)
merged.push(h)
}
}
lists.value = merged
}
catch {
toast.error(t('halls.loadFailed'))
}
finally {
loading.value = false
}
}
watch(open, (isOpen) => {
if (isOpen) {
creatingOpen.value = false
void loadHalls()
}
})
async function toggleItem(hall: HallOption) {
if (togglingId.value !== null) return
togglingId.value = hall.id
try {
const res = await $fetch<{ added: boolean }>(`/api/halls/${hall.id}/items`, {
method: 'POST',
body: { uploadId: props.uploadId, action: 'toggle' },
})
hall.containsUpload = res.added
hall.itemCount = Math.max(0, hall.itemCount + (res.added ? 1 : -1))
toast.success(res.added
? t('halls.addedTo', { title: hall.title })
: t('halls.removedFrom', { title: hall.title }))
}
catch {
toast.error(t('halls.updateFailed'))
}
finally {
togglingId.value = null
}
}
async function createAndAdd() {
const title = newTitle.value.trim()
if (!title || creating.value) return
creating.value = true
try {
const res = await $fetch<{ hall: HallOption }>('/api/halls', {
method: 'POST',
body: {
title,
visibility: newIsStandard.value ? 'public' : newVisibility.value,
isStandard: newIsStandard.value,
uploadId: props.uploadId,
},
})
if (res.hall) {
lists.value.unshift({ ...res.hall, containsUpload: true })
}
newTitle.value = ''
newIsStandard.value = false
newVisibility.value = 'public'
creatingOpen.value = false
toast.success(t('halls.createdAndAdded', { title }))
}
catch {
toast.error(t('halls.createFailed'))
}
finally {
creating.value = false
}
}
function visibilityIcon(h: HallOption) {
if (h.isStandard || h.visibility === 'public') return 'ph:globe-fill'
if (h.visibility === 'unlisted') return 'ph:link-bold'
return 'ph:lock-simple-fill'
}
</script>
<template>
<Popover v-if="user" v-model:open="open">
<PopoverAnchor as-child>
<span class="inline-flex">
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="ghost"
size="icon"
class="cursor-pointer text-muted-foreground hover:text-foreground"
:aria-label="$t('halls.saveTo')"
@click="open = !open"
>
<Icon name="at-icons:trophy" class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('halls.saveTo') }}</TooltipContent>
</Tooltip>
</span>
</PopoverAnchor>
<PopoverContent v-if="open" side="bottom" align="end" class="w-80 gap-3 p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5">
<Icon name="at-icons:trophy" class="size-4 text-primary" />
<span class="text-sm font-semibold">{{ $t('halls.saveTo') }}</span>
</div>
<NuxtLink
to="/halls"
class="text-xs text-muted-foreground hover:text-foreground hover:underline"
@click="open = false"
>
{{ $t('halls.allHalls') }}
</NuxtLink>
</div>
<div v-if="loading" class="flex items-center justify-center py-4 text-muted-foreground">
<Spinner />
</div>
<div v-else-if="!lists.length" class="py-2 text-xs text-muted-foreground">
{{ $t('halls.noOwnYet') }}
</div>
<div v-else class="flex max-h-56 flex-col gap-1 overflow-y-auto pr-1">
<button
v-for="hall in lists"
:key="hall.id"
type="button"
class="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent disabled:opacity-50"
:disabled="togglingId === hall.id"
@click="toggleItem(hall)"
>
<Checkbox :model-value="Boolean(hall.containsUpload)" class="pointer-events-none" />
<span class="min-w-0 flex-1 truncate font-medium">{{ hall.title }}</span>
<Badge v-if="hall.isStandard" variant="secondary" class="px-1.5 py-0 text-[10px]">
{{ $t('halls.standardBadge') }}
</Badge>
<Icon
:name="visibilityIcon(hall)"
class="size-3.5 shrink-0 text-muted-foreground"
/>
</button>
</div>
<div class="border-t pt-2.5">
<Button
v-if="!creatingOpen"
variant="outline"
size="sm"
class="w-full cursor-pointer gap-1.5"
@click="creatingOpen = true"
>
<Icon name="ph:plus-bold" class="size-3.5" />
{{ $t('halls.newHall') }}
</Button>
<form v-else class="flex flex-col gap-2" @submit.prevent="createAndAdd">
<Input
v-model="newTitle"
:placeholder="$t('halls.titlePlaceholder')"
maxlength="100"
class="h-8 text-sm"
/>
<Select v-if="!newIsStandard" v-model="newVisibility">
<SelectTrigger class="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="public">
{{ $t('halls.visibility.public') }}
</SelectItem>
<SelectItem value="unlisted">
{{ $t('halls.visibility.unlisted') }}
</SelectItem>
<SelectItem value="private">
{{ $t('halls.visibility.private') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<label
v-if="canManageStandard"
class="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground"
>
<Checkbox v-model="newIsStandard" />
{{ $t('halls.asStandard') }}
</label>
<div class="flex justify-end gap-1.5 pt-0.5">
<Button
type="button"
variant="ghost"
size="sm"
:disabled="creating"
@click="creatingOpen = false"
>
{{ $t('common.cancel') }}
</Button>
<Button
type="submit"
size="sm"
:disabled="!newTitle.trim() || creating"
>
<Spinner v-if="creating" />
{{ $t('halls.create') }}
</Button>
</div>
</form>
</div>
</PopoverContent>
</Popover>
</template>
+3 -2
View File
@@ -2,7 +2,7 @@
import { useCommand } from '@/components/ui/command'
import { CommandGroup, CommandItem, CommandShortcut } from '@/components/ui/command'
interface TagResult { name: string, count: number }
interface TagResult { name: string, count: number, icon?: string | null }
interface ListedUser { id: number, name: string, color: string | null, avatarUrl: string | null }
const emit = defineEmits<{ select: [to: string] }>()
@@ -44,7 +44,8 @@ onMounted(async () => {
class="gap-2"
@select="emit('select', `/?tag=${encodeURIComponent(t.name)}`)"
>
<Icon name="ph:tag-fill" class="size-4 text-muted-foreground" />
<TagIcon v-if="t.icon" :icon="t.icon" class="size-4" />
<Icon v-else name="ph:tag-fill" class="size-4 text-muted-foreground" />
{{ t.name }}
<CommandShortcut class="tracking-normal">{{ t.count }}</CommandShortcut>
</CommandItem>
+6 -5
View File
@@ -10,15 +10,16 @@ const { t } = useI18n()
const open = ref(false)
const busy = ref(false)
async function pick(emoji: string) {
async function pick(emoji: string, sticker = false) {
if (busy.value) return
busy.value = true
open.value = false
try {
await toggle(props.commentId, emoji)
await toggle(props.commentId, emoji, sticker)
}
catch {
toast.error(t('comments.reactionFailed'))
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('comments.reactionFailed'))
}
finally {
busy.value = false
@@ -38,7 +39,7 @@ async function pick(emoji: string) {
</button>
</PopoverTrigger>
<PopoverContent v-if="open" align="end" class="w-auto overflow-hidden p-0">
<UnicodeEmojiPicker @select="pick" />
<EmojiStickerPicker @select="pick" />
</PopoverContent>
</Popover>
</template>
+19 -7
View File
@@ -7,6 +7,7 @@ const props = defineProps<{ commentId: number }>()
const { user } = useAuth()
const { reactionsFor, toggle } = useCommentReactions()
const { byName } = useStickers()
const { t } = useI18n()
const busy = ref(false)
@@ -20,7 +21,7 @@ const BURST_LINES = Array.from({ length: 12 }, (_, i) => ({
const burstEmoji = ref<string | null>(null)
const burstNonce = ref(0)
async function pick(emoji: string) {
async function pick(emoji: string, sticker = false) {
if (!user.value) {
toast.error(t('comments.loginToReact'))
return
@@ -31,10 +32,11 @@ async function pick(emoji: string) {
burstEmoji.value = emoji
burstNonce.value++
try {
await toggle(props.commentId, emoji)
await toggle(props.commentId, emoji, sticker)
}
catch {
toast.error(t('comments.reactionFailed'))
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('comments.reactionFailed'))
}
finally {
busy.value = false
@@ -56,7 +58,7 @@ async function pick(emoji: string) {
: 'border-border bg-muted/40 hover:border-foreground/30',
pending === r.emoji && 'animate-pulse',
)"
@click="pick(r.emoji)"
@click="pick(r.emoji, r.sticker)"
>
<span v-if="burstEmoji === r.emoji" :key="burstNonce" class="pointer-events-none absolute inset-0" aria-hidden="true">
<i
@@ -66,12 +68,22 @@ async function pick(emoji: string) {
: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>
<StickerMedia
v-if="r.sticker && byName.get(r.emoji)"
:key="burstNonce"
:sticker="byName.get(r.emoji)!"
variant="reaction"
:class="burstEmoji === r.emoji && 'emoji-pop'"
/>
<span v-else-if="r.sticker" class="text-xs leading-none text-muted-foreground">:{{ r.emoji }}:</span>
<span v-else :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 class="flex items-center gap-2.5 px-3 py-2">
<span class="text-5xl leading-none">{{ r.emoji }}</span>
<StickerMedia v-if="r.sticker && byName.get(r.emoji)" :sticker="byName.get(r.emoji)!" variant="compact" />
<span v-else-if="r.sticker" class="text-xl leading-none">:{{ r.emoji }}:</span>
<span v-else class="text-5xl leading-none">{{ r.emoji }}</span>
<span class="flex flex-col leading-snug">
<span v-for="n in r.names" :key="n">{{ n }}</span>
</span>
+3 -2
View File
@@ -16,6 +16,7 @@ interface LatestComment {
userId: number | null
isBot?: boolean
userColor: string | null
userNameGradient?: boolean
userAvatarUrl: string | null
uploadId: string
attachments?: CommentAttachment[]
@@ -81,7 +82,7 @@ const mergedComments = computed(() => {
<template v-if="comment.isBot || comment.userId === null">
<span
class="truncate text-sm font-medium"
:style="comment.userColor ? { color: comment.userColor } : undefined"
:style="shopGradientNameStyle(comment.userColor, comment.userNameGradient)"
>{{ comment.userName }}</span>
<Badge variant="secondary" class="px-1 py-0 text-[9px] uppercase">
{{ $t('comments.botBadge') }}
@@ -96,7 +97,7 @@ const mergedComments = computed(() => {
>
<span
class="truncate text-sm font-medium text-foreground hover:underline"
:style="comment.userColor ? { color: comment.userColor } : undefined"
:style="shopGradientNameStyle(comment.userColor, comment.userNameGradient)"
@click.stop.prevent="navigateTo(`/user/${comment.userId}`)"
>{{ comment.userName }}</span>
</UserHoverCard>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
const route = useRoute()
const { user } = useAuth()
const dms = useDirectMessages()
// Auf der dedizierten /messages-Seite keine schwebenden Fenster doppelt zeigen
const visible = computed(() =>
!!user.value
&& !route.path.startsWith('/messages')
&& dms.floatingChats.value.length > 0,
)
// Z-Index-Reihenfolge: zuletzt angeklicktes Fenster liegt ganz vorne
const zOrder = ref<string[]>([])
function bringToFront(key: string) {
zOrder.value = [...zOrder.value.filter(k => k !== key), key]
}
function zIndexFor(key: string, idx: number) {
const pos = zOrder.value.indexOf(key)
return 40 + (pos >= 0 ? pos + 10 : idx)
}
</script>
<template>
<div
v-if="visible"
class="pointer-events-none fixed inset-0 z-40"
>
<DmFloatingWindow
v-for="(tab, idx) in dms.floatingChats.value"
:key="tab.key"
:tab="tab"
:index="idx"
:z-index="zIndexFor(tab.key, idx)"
@focus="bringToFront(tab.key)"
/>
</div>
</template>
+675
View File
@@ -0,0 +1,675 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import { cn } from '@/lib/utils'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { FloatingChatTab } from '@/composables/useDirectMessages'
import type { DmGroup, DmMessage, DmReactionEvent, DmUser } from '~~/shared/utils/messages'
const props = defineProps<{
tab: FloatingChatTab
index: number
zIndex: number
}>()
const emit = defineEmits<{
focus: []
}>()
const { t } = useI18n()
const { user } = useAuth()
const dms = useDirectMessages()
const timeAgo = useTimeAgo()
const { byName } = useStickers()
const MIN_W = 260
const MIN_H = 280
const DEFAULT_W = 340
const DEFAULT_H = 430
const windowEl = ref<HTMLElement>()
// null = unten rechts angedockt; sobald gezogen/resized wurde, gelten x/y (px)
const pos = ref<{ x: number, y: number } | null>(null)
const size = ref<{ w: number, h: number }>({ w: DEFAULT_W, h: DEFAULT_H })
const interacting = ref(false)
function clampToViewport(x: number, y: number, w: number, h: number) {
const vw = window.innerWidth
const vh = window.innerHeight
const effectiveH = props.tab.minimized ? 40 : h
return {
x: Math.max(8, Math.min(vw - w - 8, x)),
y: Math.max(8, Math.min(vh - effectiveH - 8, y)),
}
}
function ensureAbsolutePos() {
if (pos.value || !windowEl.value) return
const rect = windowEl.value.getBoundingClientRect()
pos.value = { x: rect.left, y: rect.top }
}
const windowStyle = computed<Record<string, string>>(() => {
const w = props.tab.minimized ? Math.min(size.value.w, 260) : size.value.w
const h = props.tab.minimized ? 40 : size.value.h
const base: Record<string, string> = {
width: `${w}px`,
height: `${h}px`,
zIndex: String(props.zIndex),
}
if (pos.value) {
base.left = `${pos.value.x}px`
base.top = `${pos.value.y}px`
}
else {
// Angedockter Standardplatz unten rechts (links von der Kommentar-Sidebar)
const offset = props.index * (DEFAULT_W + 12)
base.right = `calc(var(--sidebar-width, 0px) + 1rem + ${offset}px)`
base.bottom = '0px'
}
return base
})
// --- Verschieben über die Kopfleiste ---
let dragMoved = false
function startDrag(e: PointerEvent) {
if (e.button !== 0) return
if ((e.target as HTMLElement).closest('button, a')) return
emit('focus')
ensureAbsolutePos()
if (!pos.value) return
const startX = e.clientX
const startY = e.clientY
const origX = pos.value.x
const origY = pos.value.y
dragMoved = false
interacting.value = true
function onMove(ev: PointerEvent) {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
if (!dragMoved && Math.hypot(dx, dy) < 5) return
dragMoved = true
pos.value = clampToViewport(origX + dx, origY + dy, size.value.w, size.value.h)
}
function onUp() {
interacting.value = false
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
if (!dragMoved) {
dms.toggleMinimizeFloatingChat(props.tab.key)
}
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
// --- Manuelles Vergrößern/Verkleinern an allen Kanten & Ecken ---
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
function startResize(dir: ResizeDir, e: PointerEvent) {
if (e.button !== 0 || props.tab.minimized) return
e.preventDefault()
e.stopPropagation()
emit('focus')
ensureAbsolutePos()
if (!pos.value) return
const startX = e.clientX
const startY = e.clientY
const origX = pos.value.x
const origY = pos.value.y
const origW = size.value.w
const origH = size.value.h
const vw = window.innerWidth
const vh = window.innerHeight
interacting.value = true
function onMove(ev: PointerEvent) {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
let nextX = origX
let nextY = origY
let nextW = origW
let nextH = origH
if (dir.includes('e')) {
nextW = Math.min(vw - origX - 8, Math.max(MIN_W, origW + dx))
}
if (dir.includes('s')) {
nextH = Math.min(vh - origY - 8, Math.max(MIN_H, origH + dy))
}
if (dir.includes('w')) {
const maxLeft = origX + origW - MIN_W
nextX = Math.max(8, Math.min(maxLeft, origX + dx))
nextW = origW + (origX - nextX)
}
if (dir.includes('n')) {
const maxTop = origY + origH - MIN_H
nextY = Math.max(8, Math.min(maxTop, origY + dy))
nextH = origH + (origY - nextY)
}
pos.value = { x: nextX, y: nextY }
size.value = { w: nextW, h: nextH }
}
function onUp() {
interacting.value = false
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
// Der Tab trägt entweder einen 1:1-Partner (user) oder eine Gruppe (group)
const isGroup = computed(() => !!props.tab.group)
const partner = ref<DmUser | null>(props.tab.user)
const group = ref<DmGroup | null>(props.tab.group)
watch(() => props.tab.user, (u) => {
if (u) partner.value = { ...partner.value, ...u }
})
watch(() => props.tab.group, (g) => {
if (g) group.value = { ...group.value, ...g }
})
const groupTitle = computed(() => {
const g = group.value
if (!g) return ''
if (g.name) return g.name
const others = g.members.filter(m => m.id !== user.value?.id)
return (others.length ? others : g.members).map(m => m.name).join(', ')
})
function senderOf(m: DmMessage): DmUser | null {
return m.sender ?? group.value?.members.find(u => u.id === m.senderId) ?? null
}
const chatMessages = ref<DmMessage[]>([])
const messageIds = new Set<number>()
const hasMore = ref(false)
const loadingChat = ref(false)
const loadingOlder = ref(false)
const listEl = ref<HTMLElement>()
const draft = ref('')
const sending = ref(false)
const unreadCount = computed(() => {
const conv = dms.conversations.value.find(c =>
isGroup.value
? c.kind === 'group' && c.group?.id === group.value?.id
: c.kind === 'dm' && c.user?.id === partner.value?.id,
)
return conv?.unread ?? 0
})
function nearBottom() {
const el = listEl.value
if (!el) return true
return el.scrollHeight - el.scrollTop - el.clientHeight < 60
}
function scrollToBottom() {
const el = listEl.value
if (el) el.scrollTop = el.scrollHeight
}
async function markRead() {
if (props.tab.minimized) return
if (isGroup.value) {
const gid = group.value?.id
if (!gid) return
dms.markGroupRead(gid)
await $fetch(`/api/messages/groups/${gid}/read`, { method: 'POST' }).catch(() => {})
return
}
const pid = partner.value?.id
if (!pid) return
dms.markConversationRead(pid)
await $fetch(`/api/messages/${pid}/read`, { method: 'POST' }).catch(() => {})
}
async function loadConversation(before?: number) {
try {
const res = isGroup.value
? await $fetch<{ group: DmGroup, messages: DmMessage[], hasMore: boolean }>(
`/api/messages/groups/${group.value!.id}`,
{ query: before ? { before } : undefined },
)
: await $fetch<{ partner: DmUser, messages: DmMessage[], hasMore: boolean }>(
`/api/messages/${partner.value!.id}`,
{ query: before ? { before } : undefined },
)
if ('group' in res) group.value = res.group
else partner.value = res.partner
if (before) {
const fresh = res.messages.filter(m => !messageIds.has(m.id))
for (const m of fresh) messageIds.add(m.id)
chatMessages.value = [...fresh, ...chatMessages.value]
}
else {
messageIds.clear()
for (const m of res.messages) messageIds.add(m.id)
chatMessages.value = res.messages
await nextTick()
scrollToBottom()
}
hasMore.value = res.hasMore
}
catch {
toast.error(t('dm.loadFailed'))
}
}
async function loadOlder() {
const first = chatMessages.value[0]
if (!hasMore.value || !first || loadingOlder.value) return
const el = listEl.value
const oldHeight = el?.scrollHeight ?? 0
const oldTop = el?.scrollTop ?? 0
loadingOlder.value = true
await loadConversation(first.id)
loadingOlder.value = false
await nextTick()
if (el) el.scrollTop = el.scrollHeight - oldHeight + oldTop
}
function applyDmReaction(m: DmMessage, e: { emoji: string, actorId: number, actorName: string, active: boolean, count: number, sticker?: boolean }) {
m.reactions ??= []
let entry = m.reactions.find(r => r.emoji === e.emoji)
if (e.active) {
if (!entry) {
entry = { emoji: e.emoji, count: 0, me: false, names: [], sticker: e.sticker }
m.reactions.push(entry)
}
if (!entry.names.includes(e.actorName)) entry.names.push(e.actorName)
}
else if (entry) {
entry.names = entry.names.filter(n => n !== e.actorName)
}
if (!entry) return
entry.count = e.count
if (e.actorId === user.value?.id) entry.me = e.active
if (entry.count <= 0) m.reactions.splice(m.reactions.indexOf(entry), 1)
}
async function toggleReaction(m: DmMessage, emoji: string, sticker = false) {
const me = user.value
if (!me) return
const before = m.reactions?.find(r => r.emoji === emoji)
applyDmReaction(m, {
emoji,
actorId: me.id,
actorName: me.name,
active: !before?.me,
count: (before?.count ?? 0) + (before?.me ? -1 : 1),
sticker,
})
try {
const res = await $fetch<{ active: boolean, count: number }>(`/api/messages/reactions/${m.id}`, {
method: 'POST',
body: { emoji, sticker },
})
applyDmReaction(m, { emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count, sticker })
}
catch {
applyDmReaction(m, { emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0, sticker })
toast.error(t('comments.reactionFailed'))
}
}
async function send() {
const content = draft.value.trim()
if (!content || sending.value) return
const gid = group.value?.id
const pid = partner.value?.id
if ((isGroup.value && !gid) || (!isGroup.value && !pid)) return
sending.value = true
try {
const url = isGroup.value ? `/api/messages/groups/${gid}` : `/api/messages/${pid}`
const msg = await $fetch<DmMessage>(url, {
method: 'POST',
body: { content },
})
draft.value = ''
if (!messageIds.has(msg.id)) {
messageIds.add(msg.id)
chatMessages.value.push(msg)
await nextTick()
scrollToBottom()
}
if (isGroup.value && group.value) dms.addOwnGroupMessage(group.value, msg)
else if (partner.value) dms.addOwnMessage(partner.value, msg)
}
catch {
toast.error(t('dm.sendFailed'))
}
finally {
sending.value = false
}
}
function insertSticker(name: string) {
draft.value = `${draft.value}${draft.value && !draft.value.endsWith(' ') ? ' ' : ''}:${name}: `
}
const lastOwnId = computed(() => {
for (let i = chatMessages.value.length - 1; i >= 0; i--) {
if (chatMessages.value[i]!.senderId === user.value?.id) return chatMessages.value[i]!.id
}
return null
})
watch(() => props.tab.minimized, async (min) => {
if (!min) {
if (pos.value) {
pos.value = clampToViewport(pos.value.x, pos.value.y, size.value.w, size.value.h)
}
await nextTick()
scrollToBottom()
markRead()
}
})
let unsubscribe: (() => void) | null = null
onMounted(async () => {
loadingChat.value = true
await loadConversation()
loadingChat.value = false
if (!props.tab.minimized) markRead()
unsubscribe = dms.subscribeFloating({
onMessage(msg, other, grp) {
if (isGroup.value) {
if (!grp || grp.id !== group.value?.id) return
group.value = grp
}
else {
if (grp) return
const pid = partner.value?.id
if (!pid || (msg.senderId !== pid && msg.recipientId !== pid)) return
if (other) partner.value = other
}
if (messageIds.has(msg.id)) return
messageIds.add(msg.id)
chatMessages.value.push(msg)
const stick = nearBottom()
nextTick(() => { if (stick && !props.tab.minimized) scrollToBottom() })
const foreign = isGroup.value
? msg.senderId !== user.value?.id
: msg.recipientId === user.value?.id
if (!props.tab.minimized && foreign) markRead()
},
onRead(pid, at) {
if (isGroup.value || pid !== partner.value?.id) return
for (const m of chatMessages.value) {
if (m.senderId === user.value?.id && !m.readAt) m.readAt = at
}
},
onReaction(e: DmReactionEvent) {
if (isGroup.value) {
if (!e.group || e.group.id !== group.value?.id) return
}
else if (e.group) return
const msg = chatMessages.value.find(m => m.id === e.messageId)
if (msg) applyDmReaction(msg, e)
if (!props.tab.minimized && e.actorId !== user.value?.id) markRead()
},
onGroup(g) {
if (isGroup.value && g.id === group.value?.id) group.value = g
},
})
})
onBeforeUnmount(() => {
unsubscribe?.()
})
</script>
<template>
<div
ref="windowEl"
class="pointer-events-auto fixed flex flex-col overflow-hidden border bg-popover text-popover-foreground shadow-2xl"
:class="[
pos ? 'rounded-md' : 'rounded-t-md border-b-0',
interacting && 'select-none',
]"
:style="windowStyle"
@pointerdown="emit('focus')"
>
<!-- Resize-Griffe an allen 4 Kanten & 4 Ecken (nur wenn aufgeklappt) -->
<template v-if="!tab.minimized">
<div class="absolute inset-x-2 top-0 z-20 h-1.5 cursor-ns-resize" @pointerdown="startResize('n', $event)" />
<div class="absolute inset-x-2 bottom-0 z-20 h-1.5 cursor-ns-resize" @pointerdown="startResize('s', $event)" />
<div class="absolute inset-y-2 left-0 z-20 w-1.5 cursor-ew-resize" @pointerdown="startResize('w', $event)" />
<div class="absolute inset-y-2 right-0 z-20 w-1.5 cursor-ew-resize" @pointerdown="startResize('e', $event)" />
<div class="absolute top-0 left-0 z-30 size-3 cursor-nwse-resize" @pointerdown="startResize('nw', $event)" />
<div class="absolute top-0 right-0 z-30 size-3 cursor-nesw-resize" @pointerdown="startResize('ne', $event)" />
<div class="absolute bottom-0 left-0 z-30 size-3 cursor-nesw-resize" @pointerdown="startResize('sw', $event)" />
<div class="absolute right-0 bottom-0 z-30 size-3.5 cursor-nwse-resize" @pointerdown="startResize('se', $event)">
<svg viewBox="0 0 12 12" class="size-full text-muted-foreground/50">
<path d="M10 2L2 10M10 6L6 10M10 10L10 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</div>
</template>
<!-- Messenger-Kopfleiste (Ziehen zum Verschieben, Klick zum Minimieren) -->
<div
class="flex h-10 shrink-0 cursor-move items-center gap-2 border-b bg-card px-2.5 select-none"
@pointerdown="startDrag"
>
<template v-if="isGroup">
<span class="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted text-muted-foreground">
<img
v-if="group?.avatarUrl"
:src="group.avatarUrl"
:alt="groupTitle"
class="size-full object-cover"
>
<Icon v-else name="ph:users-three-fill" class="size-3.5" />
</span>
<span
class="min-w-0 flex-1 truncate text-sm font-medium"
:style="group?.color ? { color: group.color } : undefined"
>{{ groupTitle }}</span>
</template>
<template v-else-if="partner">
<Avatar class="size-6 shrink-0">
<AvatarImage v-if="partner.avatarUrl" :src="partner.avatarUrl" :alt="partner.name" />
<AvatarFallback
class="text-[10px]"
:style="partner.color ? { backgroundColor: partner.color, color: '#fff' } : undefined"
>
{{ partner.name.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<NuxtLink
:to="`/user/${partner.id}`"
class="min-w-0 flex-1 truncate text-sm font-medium hover:underline"
:style="partner.color ? { color: partner.color } : undefined"
@click.stop
>{{ partner.name }}</NuxtLink>
</template>
<Badge
v-if="tab.minimized && unreadCount > 0"
variant="default"
class="px-1.5 py-0 text-[10px] tabular-nums"
>{{ unreadCount > 9 ? '9+' : unreadCount }}</Badge>
<div class="flex shrink-0 items-center gap-0.5" @click.stop>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
class="size-6 cursor-pointer text-muted-foreground hover:text-foreground"
as-child
>
<NuxtLink :to="isGroup ? `/messages/g${group?.id}` : `/messages/${partner?.id}`" :aria-label="$t('dm.openFullView')">
<Icon name="ph:arrows-out-simple-bold" class="size-3.5" />
</NuxtLink>
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.openFullView') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
class="size-6 cursor-pointer text-muted-foreground hover:text-foreground"
:aria-label="tab.minimized ? $t('dm.expandChat') : $t('dm.minimizeChat')"
@click="dms.toggleMinimizeFloatingChat(tab.key)"
>
<Icon :name="tab.minimized ? 'ph:caret-up-bold' : 'ph:minus-bold'" class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ tab.minimized ? $t('dm.expandChat') : $t('dm.minimizeChat') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
class="size-6 cursor-pointer text-muted-foreground hover:text-destructive"
:aria-label="$t('common.close')"
@click="dms.closeFloatingChat(tab.key)"
>
<Icon name="ph:x-bold" class="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('common.close') }}</TooltipContent>
</Tooltip>
</div>
</div>
<!-- Nachrichtenverlauf + Eingabe -->
<template v-if="!tab.minimized">
<div ref="listEl" class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-3 py-2.5 text-sm">
<div v-if="hasMore" class="flex justify-center">
<Button variant="ghost" size="sm" class="h-6 text-xs" :disabled="loadingOlder" @click="loadOlder">
<Spinner v-if="loadingOlder" />
{{ $t('dm.loadOlder') }}
</Button>
</div>
<div v-if="loadingChat" class="flex flex-col gap-2 py-1">
<div
v-for="(w, i) in ['60%', '45%', '70%']"
:key="i"
class="flex flex-col"
:class="i % 2 ? 'items-end' : 'items-start'"
>
<Skeleton class="h-7 rounded-md" :style="{ width: w }" />
</div>
</div>
<p v-else-if="!chatMessages.length" class="py-6 text-center text-xs text-muted-foreground">
{{ $t('dm.empty') }}
</p>
<div
v-for="m in chatMessages"
:key="m.id"
:class="cn('flex flex-col', m.senderId === user?.id ? 'items-end' : 'items-start')"
>
<div
v-if="isGroup && m.senderId !== user?.id"
class="flex items-center gap-1 px-1 pb-0.5"
>
<Avatar class="size-3.5">
<AvatarImage v-if="senderOf(m)?.avatarUrl" :src="senderOf(m)!.avatarUrl!" :alt="senderOf(m)?.name" />
<AvatarFallback
class="text-[7px]"
:style="senderOf(m)?.color ? { backgroundColor: senderOf(m)!.color!, color: '#fff' } : undefined"
>
{{ senderOf(m)?.name.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span
class="text-[10px] font-medium"
:style="senderOf(m)?.color ? { color: senderOf(m)!.color! } : undefined"
>{{ senderOf(m)?.name }}</span>
</div>
<div
class="group/message flex w-full items-center gap-1"
:class="m.senderId === user?.id && 'flex-row-reverse'"
>
<div
:class="cn(
'max-w-[82%] rounded-md px-2.5 py-1.5 text-xs',
m.senderId === user?.id
? 'bg-primary text-primary-foreground'
: 'bg-muted',
)"
>
<div v-if="m.attachments?.length" class="max-w-full">
<CommentAttachments :attachments="m.attachments" />
</div>
<CommentContent v-if="m.content" :content="m.content" />
</div>
<DmReactionPicker @select="(e, s) => toggleReaction(m, e, s)" />
</div>
<div
v-if="m.reactions?.length"
class="mt-1 flex flex-wrap gap-1"
:class="m.senderId === user?.id && 'justify-end'"
>
<button
v-for="r in m.reactions"
:key="r.emoji"
type="button"
:class="cn(
'flex cursor-pointer items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] transition-colors',
r.me
? 'border-foreground/40 bg-secondary'
: 'border-border bg-muted/60',
)"
@click="toggleReaction(m, r.emoji, r.sticker)"
>
<StickerMedia
v-if="r.sticker && byName.get(r.emoji)"
:sticker="byName.get(r.emoji)!"
variant="reaction"
/>
<span v-else>{{ r.sticker ? `:${r.emoji}:` : r.emoji }}</span>
<span class="tabular-nums">{{ r.count }}</span>
</button>
</div>
<div class="mt-0.5 flex items-center gap-1 px-1 text-[10px] text-muted-foreground">
<span>{{ timeAgo(m.createdAt) }}</span>
<template v-if="!isGroup && m.id === lastOwnId">
·
<Icon v-if="m.readAt" name="ph:checks-fill" class="size-3 text-primary" />
<Icon v-else name="ph:check-fill" class="size-3" />
</template>
</div>
</div>
</div>
<form class="flex items-center gap-1 border-t bg-background p-2" @submit.prevent="send">
<input
v-model="draft"
type="text"
:placeholder="$t('dm.placeholder')"
class="min-w-0 flex-1 rounded-md border bg-muted/30 px-2.5 py-1.5 text-xs outline-none focus:border-primary"
@keydown.enter.exact.prevent="send"
>
<StickerPicker @select="insertSticker" />
<Button
type="submit"
size="icon-sm"
class="size-7 shrink-0 cursor-pointer"
:disabled="sending || !draft.trim()"
:aria-label="$t('dm.send')"
>
<Spinner v-if="sending" class="size-3.5" />
<Icon v-else name="ph:paper-plane-tilt-fill" class="size-3.5" />
</Button>
</form>
</template>
</div>
</template>
+4 -4
View File
@@ -2,14 +2,14 @@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const emit = defineEmits<{
select: [emoji: string]
select: [emoji: string, sticker: boolean]
}>()
const open = ref(false)
function pick(emoji: string) {
function pick(emoji: string, sticker: boolean) {
open.value = false
emit('select', emoji)
emit('select', emoji, sticker)
}
</script>
@@ -25,7 +25,7 @@ function pick(emoji: string) {
</button>
</PopoverTrigger>
<PopoverContent v-if="open" align="center" class="w-auto overflow-hidden p-0">
<UnicodeEmojiPicker @select="pick" />
<EmojiStickerPicker @select="pick" />
</PopoverContent>
</Popover>
</template>
+15 -4
View File
@@ -103,7 +103,13 @@ function selectUser(id: number) {
emit('select', id)
}
// Offene Einladung: meine Mitgliedschaft ist pending
function isPendingInvite(c: DmConversation) {
return !!c.group?.members.find(m => m.id === user.value?.id)?.pending
}
function convPreview(c: DmConversation) {
if (isPendingInvite(c)) return t('dm.groupInvite')
const last = c.lastMessage
if (!last) return t('dm.empty')
const own = last.senderId === user.value?.id
@@ -177,9 +183,9 @@ async function createGroup() {
<template>
<Sidebar collapsible="offcanvas" class="border-r md:absolute! md:h-auto!">
<SidebarHeader class="gap-3.5 border-b p-4">
<SidebarHeader class="gap-3 border-b p-4">
<div class="flex items-center justify-between">
<div class="text-base font-medium text-foreground">
<div class="text-base font-semibold tracking-tight text-foreground">
{{ $t('dm.title') }}
</div>
<Tooltip>
@@ -190,7 +196,7 @@ async function createGroup() {
:aria-label="$t('dm.newGroup')"
@click="groupDialogOpen = true"
>
<Icon name="ph:users-three-fill" class="size-4.5" />
<Icon name="ph:users-three-fill" class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.newGroup') }}</TooltipContent>
@@ -286,6 +292,11 @@ async function createGroup() {
>{{ timeAgo(c.lastMessage.createdAt) }}</span>
</div>
<div class="flex w-full items-center gap-2">
<Icon
v-if="isPendingInvite(c)"
name="ph:envelope-simple-fill"
class="size-3.5 shrink-0 text-accent"
/>
<span
class="line-clamp-2 min-w-0 flex-1 text-xs whitespace-normal"
:class="c.unread ? 'font-medium text-foreground' : 'text-muted-foreground'"
@@ -337,7 +348,7 @@ async function createGroup() {
:aria-label="$t('common.delete')"
@click="groupColor = null"
>
<Icon name="ph:x" class="size-4" />
<Icon name="ph:x-bold" class="size-4" />
</button>
</div>
<div class="flex flex-wrap gap-1.5">
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { Sticker } from '~~/shared/utils/stickers'
// Reaction-Picker mit zwei Tabs: Unicode-Emojis oder eigene/Standard-Sticker.
// Mit showIcons kommt ein dritter Tab für die installierten Iconify-Sets
// dazu (für Tag-Icons im Admin; Icons emittieren sticker=false).
// emit('select', wert, true) = Sticker-Name, false = Emoji/Icon-Name
withDefaults(defineProps<{ showIcons?: boolean }>(), { showIcons: false })
const emit = defineEmits<{ select: [value: string, sticker: boolean] }>()
const { user } = useAuth()
const { stickers } = useStickers()
const query = ref('')
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
const list = stickers.value ?? []
return q ? list.filter(e => e.name.includes(q)) : list
})
const standardStickers = computed(() => filtered.value.filter(e => e.isStandard))
const ownStickers = computed(() => filtered.value.filter(e => !e.isStandard && e.userId === user.value?.id))
function pick(e: Sticker) {
emit('select', e.name, true)
}
// Iconify-Tab: Server-Suche über die installierten Sets (ph, simple-icons)
const iconQuery = ref('')
const iconResults = ref<string[]>([])
const iconsLoaded = ref(false)
let iconTimer: ReturnType<typeof setTimeout> | undefined
watch(iconQuery, () => {
clearTimeout(iconTimer)
iconTimer = setTimeout(loadIcons, 200)
})
async function loadIcons() {
try {
const res = await $fetch<{ icons: string[] }>('/api/icons', { query: { q: iconQuery.value } })
iconResults.value = res.icons
iconsLoaded.value = true
}
catch {
iconResults.value = []
}
}
function pickIcon(name: string) {
emit('select', name, false)
}
</script>
<template>
<Tabs default-value="emoji" class="w-[22rem]">
<TabsList class="w-full">
<TabsTrigger value="emoji" class="flex-1 cursor-pointer gap-1.5">
<Icon name="ph:smiley-fill" class="size-4" />
{{ $t('stickers.tabEmoji') }}
</TabsTrigger>
<TabsTrigger value="sticker" class="flex-1 cursor-pointer gap-1.5">
<Icon name="ph:sticker-fill" class="size-4" />
{{ $t('stickers.tabStickers') }}
</TabsTrigger>
<TabsTrigger v-if="showIcons" value="icons" class="flex-1 cursor-pointer gap-1.5" @click="!iconsLoaded && loadIcons()">
<Icon name="ph:shapes-fill" class="size-4" />
{{ $t('stickers.tabIcons') }}
</TabsTrigger>
</TabsList>
<TabsContent value="emoji" class="mt-0">
<UnicodeEmojiPicker @select="emit('select', $event, false)" />
</TabsContent>
<TabsContent value="sticker" class="space-y-2 p-2">
<Input
v-model="query"
:placeholder="$t('stickers.search')"
class="h-8 text-sm"
/>
<div class="max-h-80 overflow-y-auto">
<div v-if="standardStickers.length">
<div class="flex items-center gap-2 px-1 py-1">
<span class="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">{{ $t('stickers.standard') }}</span>
<Separator class="flex-1" />
</div>
<div class="grid grid-cols-4 gap-1">
<button
v-for="e in standardStickers"
:key="e.id"
type="button"
class="group relative flex size-20 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-accent"
:title="`:${e.name}:`"
@click="pick(e)"
>
<StickerMedia :sticker="e" variant="tile" />
</button>
</div>
</div>
<div>
<div class="flex items-center gap-2 px-1 py-1">
<span class="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">{{ $t('stickers.yours') }}</span>
<Separator class="flex-1" />
</div>
<div class="grid grid-cols-4 gap-1">
<button
v-for="e in ownStickers"
:key="e.id"
type="button"
class="group relative flex size-20 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-accent"
:title="`:${e.name}:`"
@click="pick(e)"
>
<StickerMedia :sticker="e" variant="tile" />
</button>
</div>
<p v-if="!ownStickers.length" class="px-1 py-1.5 text-xs text-muted-foreground">
{{ $t('stickers.empty') }}
</p>
</div>
</div>
</TabsContent>
<TabsContent v-if="showIcons" value="icons" class="space-y-2 p-2">
<Input
v-model="iconQuery"
:placeholder="$t('stickers.searchIcons')"
class="h-8 text-sm"
/>
<div class="max-h-80 overflow-y-auto">
<div class="grid grid-cols-7 gap-1">
<button
v-for="name in iconResults"
:key="name"
type="button"
class="flex size-10 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-accent"
:title="name"
@click="pickIcon(name)"
>
<Icon :name="name" class="size-5" />
</button>
</div>
<p v-if="iconsLoaded && !iconResults.length" class="px-1 py-1.5 text-xs text-muted-foreground">
{{ $t('stickers.empty') }}
</p>
</div>
</TabsContent>
</Tabs>
</template>
-1
View File
@@ -96,7 +96,6 @@ onUnmounted(() => {
size="sm"
@click.stop="select(null)"
>
<Icon name="ph:x-fill" class="size-4" />
{{ $t('common.remove') }}
</Button>
</template>
+93
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { useDropZone, useEventListener } from '@vueuse/core'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { Spinner } from '@/components/ui/spinner'
import { isMediaFile, isMediaItemType } from '~~/shared/utils/media'
const { t } = useI18n()
@@ -42,6 +45,61 @@ useEventListener('drop', (e: DragEvent) => {
if (e.dataTransfer?.types.includes('Files')) e.preventDefault()
})
// STRG+V: Mediendateien aus der Zwischenablage landen nicht direkt im
// Upload, sondern erst in einem Bestätigungs-Dialog mit Vorschau.
// Ist ein Eingabefeld fokussiert (z. B. der Kommentar-Composer, der
// selbst paste-files emittiert), wird nicht eingegriffen.
const pasteOpen = ref(false)
const pasteFiles = ref<File[]>([])
const pastePreviews = ref<{ file: File, url: string | null }[]>([])
function clearPaste() {
for (const p of pastePreviews.value) {
if (p.url) URL.revokeObjectURL(p.url)
}
pastePreviews.value = []
pasteFiles.value = []
}
watch(pasteOpen, (open) => {
if (!open) clearPaste()
})
// STRG+V mit URL-Text: öffnet das URL-Upload-Feld im UploadPopover mit der
// URL vorbefüllt (kein Submit). Greift nicht, wenn ein Eingabefeld fokussiert
// ist - dort fügt der native Paste-Handler den Text selbst ein.
const pasteUrl = useState<string>('upload:paste-url', () => '')
useEventListener('paste', (e: ClipboardEvent) => {
const el = document.activeElement as HTMLElement | null
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return
const files = Array.from(e.clipboardData?.files ?? [])
if (!files.length) {
const text = e.clipboardData?.getData('text').trim() ?? ''
if (/^https?:\/\/\S+$/i.test(text)) pasteUrl.value = text
return
}
const media = files.filter(isMediaFile)
if (!media.length) {
toast.error(t('upload.onlyMedia'))
return
}
e.preventDefault()
clearPaste()
pasteFiles.value = media
pastePreviews.value = media.map(file => ({
file,
url: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
}))
pasteOpen.value = true
})
function confirmPaste() {
const files = pasteFiles.value
pasteOpen.value = false
if (files.length) uploadFiles(files)
}
async function uploadFiles(files: File[]) {
if (!user.value) {
toast.error(t('upload.loginToUploadToast'))
@@ -89,4 +147,39 @@ async function uploadFiles(files: File[]) {
</div>
</div>
</div>
<!-- Bestätigung für Zwischenablage-Uploads -->
<Dialog v-model:open="pasteOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('upload.pasteTitle') }}</DialogTitle>
</DialogHeader>
<div class="flex max-h-72 flex-wrap gap-2 overflow-y-auto">
<template v-for="p in pastePreviews" :key="p.file.name + p.file.size">
<img
v-if="p.url"
:src="p.url"
:alt="p.file.name"
class="max-h-40 rounded-md border object-cover"
>
<span
v-else
class="flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1 text-xs"
>
<Icon name="ph:file-fill" class="size-3.5 shrink-0 text-muted-foreground" />
{{ p.file.name }}
</span>
</template>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="uploading" @click="pasteOpen = false">
{{ $t('common.cancel') }}
</Button>
<Button size="sm" :disabled="uploading" @click="confirmPaste">
<Spinner v-if="uploading" />
{{ $t('upload.upload') }}
</Button>
</div>
</DialogContent>
</Dialog>
</template>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge'
import HallCoverStack from '@/components/HallCoverStack.vue'
import UserHoverCard from '@/components/UserHoverCard.vue'
import type { HallVisibility } from '@/components/AddToHallPopover.vue'
export interface HallCardItem {
id: string
title: string
description: string | null
visibility: HallVisibility
isStandard: boolean
userId: number
userName: string
userColor: string | null
userAvatarUrl: string | null
itemCount: number
coverThumbUrl: string | null
coverThumbUrls?: string[]
firstUploadId: string | null
}
const props = defineProps<{
hall: HallCardItem
}>()
const visibilityIcon = computed(() => {
if (props.hall.isStandard || props.hall.visibility === 'public') return 'ph:globe-fill'
if (props.hall.visibility === 'unlisted') return 'ph:link-bold'
return 'ph:lock-simple-fill'
})
</script>
<template>
<NuxtLink
:to="`/halls/${hall.id}`"
class="group flex flex-col overflow-hidden rounded-md border bg-card"
>
<div class="relative aspect-video w-full overflow-hidden bg-muted">
<HallCoverStack
:thumbs="hall.coverThumbUrls"
:fallback-thumb="hall.coverThumbUrl"
:alt="hall.title"
/>
<div class="absolute top-2 left-2 z-10 flex items-center gap-1">
<Badge v-if="hall.isStandard" variant="default" class="gap-1 px-1.5 py-0 text-xs">
<Icon name="ph:star-fill" class="size-3" />
{{ $t('halls.standardBadge') }}
</Badge>
<Badge v-else variant="secondary" class="gap-1 bg-background/85 px-1.5 py-0 text-xs backdrop-blur-xs">
<Icon :name="visibilityIcon" class="size-3" />
{{ $t(`halls.visibility.${hall.visibility}`) }}
</Badge>
</div>
<!-- Hover-Overlay wie bei UploadTile auf der Mainseite -->
<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="!hall.isStandard"
:user-id="hall.userId"
:user-name="hall.userName"
:user-color="hall.userColor"
:user-avatar-url="hall.userAvatarUrl"
class="min-w-0"
>
<span class="truncate">{{ hall.userName }}</span>
</UserHoverCard>
<span v-else />
<span class="flex shrink-0 items-center gap-1">
<Icon name="ph:images-square-fill" class="size-3.5" />
{{ hall.itemCount }}
</span>
</div>
</div>
</div>
<div class="flex flex-1 flex-col gap-1 p-3">
<h3 class="line-clamp-1 font-semibold">
{{ hall.title }}
</h3>
<p v-if="hall.description" class="line-clamp-2 text-xs text-muted-foreground">
{{ hall.description }}
</p>
<div class="mt-auto flex items-center justify-between gap-2 pt-2 text-xs text-muted-foreground">
<span
v-if="!hall.isStandard"
class="truncate font-medium"
:style="hall.userColor ? { color: hall.userColor } : undefined"
>
{{ hall.userName }}
</span>
<span :class="hall.isStandard && 'ml-auto'">{{ $t('halls.itemsCount', { n: hall.itemCount }) }}</span>
</div>
</div>
</NuxtLink>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
const props = defineProps<{
thumbs?: string[] | null
fallbackThumb?: string | null
alt?: string
}>()
const list = computed(() => {
const arr = props.thumbs?.filter(Boolean) ?? []
if (arr.length) return arr.slice(0, 4)
return props.fallbackThumb ? [props.fallbackThumb] : []
})
// Randlos überlappende Ebenen (von hinten nach vorne): jede Ebene reicht über
// den oberen/unteren Rand hinaus, sodass im gesamten Thumbnail keinerlei
// leerer Rand entsteht und man alle Bilder gleichzeitig überlappend sieht.
const STACK_LAYOUTS: Record<number, string[]> = {
2: [
'-inset-y-4 -right-4 left-[34%] rotate-3',
'-inset-y-4 -left-4 right-[32%] -rotate-2 shadow-[6px_0_16px_rgba(0,0,0,0.65)]',
],
3: [
'-inset-y-4 -right-4 left-[50%] rotate-3',
'-inset-y-4 right-[20%] left-[22%] rotate-1 shadow-[6px_0_16px_rgba(0,0,0,0.6)]',
'-inset-y-4 -left-4 right-[46%] -rotate-2 shadow-[6px_0_16px_rgba(0,0,0,0.65)]',
],
4: [
'-inset-y-4 -right-4 left-[60%] rotate-3',
'-inset-y-4 right-[14%] left-[38%] rotate-1 shadow-[6px_0_14px_rgba(0,0,0,0.55)]',
'-inset-y-4 right-[34%] left-[18%] -rotate-1 shadow-[6px_0_14px_rgba(0,0,0,0.6)]',
'-inset-y-4 -left-4 right-[54%] -rotate-3 shadow-[6px_0_16px_rgba(0,0,0,0.65)]',
],
}
const stackClasses = computed(() => STACK_LAYOUTS[list.value.length] ?? [])
const renderItems = computed(() => {
if (list.value.length <= 1) return []
return [...list.value].reverse()
})
</script>
<template>
<div class="relative size-full overflow-hidden bg-muted">
<div v-if="!list.length" class="flex size-full items-center justify-center text-muted-foreground">
<Icon name="at-icons:trophy" class="size-10 opacity-50" />
</div>
<NuxtImg
v-else-if="list.length === 1"
:src="list[0]"
:alt="alt ?? ''"
width="480"
height="270"
fit="cover"
loading="lazy"
class="size-full object-cover"
/>
<div v-else class="relative size-full scale-105">
<div
v-for="(src, idx) in renderItems"
:key="`${idx}-${src}`"
class="absolute overflow-hidden border-r border-black/40 bg-muted"
:class="stackClasses[idx]"
>
<NuxtImg
:src="src"
:alt="idx === renderItems.length - 1 ? (alt ?? '') : ''"
width="360"
height="270"
fit="cover"
loading="lazy"
class="size-full scale-105 object-cover"
/>
</div>
</div>
</div>
</template>
+1 -1
View File
@@ -73,7 +73,7 @@ onKeyStroke('Escape', () => emit('close'))
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" />
<Icon name="ph:x-bold" class="size-5" />
<span class="sr-only">{{ $t('common.close') }}</span>
</button>
</div>
+12 -1
View File
@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'
const model = defineModel<string>({ required: true })
const props = defineProps<{ placeholder?: string }>()
const emit = defineEmits<{ 'paste-files': [files: File[]] }>()
const { data: users } = useMentionUsers()
const listId = useId()
const caret = shallowRef(0)
@@ -59,6 +60,15 @@ async function insert(text: string) {
}
defineExpose({ insert })
// Dateien aus der Zwischenablage (z. B. Screenshots) gehen an den Parent -
// der landet sie als Pending-Anhänge; Text-Paste läuft wie gehabt weiter
function onPaste(event: ClipboardEvent) {
const files = Array.from(event.clipboardData?.files ?? [])
if (!files.length) return
event.preventDefault()
emit('paste-files', files)
}
function onTextareaKeyup(event: KeyboardEvent) {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(event.key)) updateCaret(event)
}
@@ -93,7 +103,7 @@ function onKeydown(event: KeyboardEvent) {
<Textarea
v-model="model"
:placeholder="props.placeholder"
class="min-h-32 pb-9"
class="min-h-32 bg-card pb-9"
:aria-label="$t('comments.commentAria')"
aria-autocomplete="list"
:aria-expanded="open"
@@ -105,6 +115,7 @@ function onKeydown(event: KeyboardEvent) {
@focus="updateCaret"
@blur="focused = false"
@keydown="onKeydown"
@paste="onPaste"
/>
<slot />
</div>
+70 -12
View File
@@ -2,15 +2,54 @@
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { AppNotification } from '@/composables/useNotifications'
const { notifications, unreadCount, markRead, markAllRead } = useNotifications()
const open = ref(false)
const pinned = ref(false)
const anchorEl = useTemplateRef<HTMLElement>('anchorEl')
const timeAgo = useTimeAgo()
let closeTimer: ReturnType<typeof setTimeout> | undefined
function isHoveringMenu() {
return !!anchorEl.value?.matches(':hover')
|| !!document.querySelector('[data-notification-menu]:hover')
}
function showMenu() {
clearTimeout(closeTimer)
open.value = true
}
function scheduleClose() {
if (pinned.value) return
clearTimeout(closeTimer)
closeTimer = setTimeout(() => {
// Events können durch Mount/Reposition des Popovers flackern -
// erst schließen, wenn die Maus wirklich nicht mehr über Glocke oder Menü liegt.
if (isHoveringMenu()) {
scheduleClose()
return
}
open.value = false
}, 120)
}
function togglePinned() {
clearTimeout(closeTimer)
pinned.value = !pinned.value
open.value = pinned.value
}
watch(open, (value) => {
if (!value) pinned.value = false
})
onBeforeUnmount(() => clearTimeout(closeTimer))
async function goTo(n: AppNotification) {
open.value = false
@@ -21,19 +60,38 @@ async function goTo(n: AppNotification) {
<template>
<Popover v-model:open="open">
<PopoverTrigger class="relative inline-flex">
<Button as="span" variant="ghost" size="icon" class="relative" :aria-label="$t('notifications.aria')">
<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 v-if="open" side="bottom" align="end" class="w-80 gap-0 p-0">
<PopoverAnchor as-child>
<span ref="anchorEl" class="relative inline-flex" @pointerenter="showMenu" @pointerleave="scheduleClose">
<Button
type="button"
variant="ghost"
size="icon"
class="relative"
:aria-label="$t('notifications.aria')"
aria-haspopup="dialog"
:aria-expanded="open"
@click="togglePinned"
>
<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>
</span>
</PopoverAnchor>
<PopoverContent
v-if="open"
side="bottom"
align="end"
data-notification-menu
class="w-80 gap-0 p-0"
@pointerenter="showMenu"
@pointerleave="scheduleClose"
>
<div class="flex items-center justify-between border-b px-4 py-2">
<span class="text-sm font-medium">{{ $t('notifications.aria') }}</span>
<Tooltip v-if="unreadCount">
<Tooltip v-if="unreadCount" ignore-non-keyboard-focus>
<TooltipTrigger as-child>
<Button
variant="ghost"
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import { REPORT_REASONS, type ReportReason } from '~~/shared/utils/reports'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Spinner } from '@/components/ui/spinner'
import { Textarea } from '@/components/ui/textarea'
// Melde-Dialog - genau eines der drei Targets wird gesetzt
// (uploadId, commentId oder userId)
const props = defineProps<{
uploadId?: string
commentId?: number
userId?: number
}>()
const open = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
const reason = ref<ReportReason>('spam')
const details = ref('')
const sending = ref(false)
watch(open, (v) => {
if (v) {
reason.value = 'spam'
details.value = ''
}
})
async function submit() {
if (sending.value) return
sending.value = true
try {
await $fetch('/api/reports', {
method: 'POST',
body: {
uploadId: props.uploadId,
commentId: props.commentId,
userId: props.userId,
reason: reason.value,
details: details.value.trim() || undefined,
},
})
open.value = false
toast.success(t('report.success'))
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('report.failed'))
}
finally {
sending.value = false
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{{ $t('report.title') }}</DialogTitle>
<DialogDescription>{{ $t('report.desc') }}</DialogDescription>
</DialogHeader>
<div class="flex flex-col gap-1.5">
<label
v-for="r in REPORT_REASONS"
:key="r"
class="flex cursor-pointer items-center gap-2 text-sm"
>
<input
v-model="reason"
type="radio"
name="report-reason"
:value="r"
class="accent-primary"
>
{{ $t(`report.reasons.${r}`) }}
</label>
</div>
<Textarea
v-model="details"
:placeholder="$t('report.detailsPlaceholder')"
rows="3"
maxlength="1000"
/>
<DialogFooter>
<Button variant="outline" :disabled="sending" @click="open = false">
{{ $t('common.cancel') }}
</Button>
<Button :disabled="sending" @click="submit">
<Spinner v-if="sending" />
{{ $t('report.submit') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+11 -1
View File
@@ -27,7 +27,13 @@ const crumbs = computed<Crumb[]>(() => {
// direkt geöffnete Threads (Notification, Reload) zeigen den schlichten
// Crumb ohne Profilkette
const ctx = navContext.value?.uploadId === route.params.id ? navContext.value : null
if (ctx?.fromPath) {
if (ctx?.fromPath?.startsWith('/halls/')) {
list.push(
{ label: t('nav.halls'), to: '/halls' },
{ label: ctx.fromLabel ?? t('nav.halls'), to: ctx.fromPath },
)
}
else if (ctx?.fromPath) {
list.push(
{ label: t('nav.users'), to: '/users' },
{ label: ctx.fromLabel ?? t('nav.profile'), to: ctx.fromPath },
@@ -35,6 +41,10 @@ const crumbs = computed<Crumb[]>(() => {
}
list.push({ label: `${t('nav.thread')} ${route.params.id ?? ''}` })
}
else if (p === '/halls') list.push({ label: t('nav.halls') })
else if (p.startsWith('/halls/')) {
list.push({ label: t('nav.halls'), to: '/halls' }, { label: `${route.params.id ?? ''}` })
}
else if (p === '/memes') list.push({ label: t('nav.memes') })
else if (p === '/users') list.push({ label: t('nav.users') })
else if (p === '/stats') list.push({ label: t('nav.stats') })
+33 -7
View File
@@ -31,7 +31,10 @@ async function onLogoClick(e: MouseEvent) {
await refreshNuxtData()
}
const initials = computed(() => user.value?.name.slice(0, 2).toUpperCase() ?? '')
// Anzeigename hat Vorrang - der echte Nutzername soll für andere
// (und damit konsistent auch in der eigenen Ansicht) unsichtbar sein
const publicName = computed(() => user.value?.displayName || user.value?.name || '')
const initials = computed(() => publicName.value.slice(0, 2).toUpperCase())
async function onLogout() {
await logout()
@@ -60,7 +63,7 @@ async function openRandomThread() {
<template>
<header class="sticky top-0 z-30 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">
<div class="flex w-full items-center gap-1 pl-4 pr-2 lg:gap-2 lg:pl-6 lg:pr-4">
<NuxtLink
to="/"
class="mr-2 font-logo text-[2.4rem] leading-none tracking-tight transition-colors hover:text-accent"
@@ -87,6 +90,12 @@ async function openRandomThread() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-44">
<DropdownMenuItem as-child>
<NuxtLink to="/halls" class="flex cursor-pointer items-center gap-2">
<Icon name="at-icons:trophy" class="size-4" />
{{ $t('nav.halls') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<NuxtLink to="/users" class="flex cursor-pointer items-center gap-2">
<Icon name="ph:users-fill" class="size-4" />
@@ -111,6 +120,12 @@ async function openRandomThread() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button v-if="can('mod.access')" variant="ghost" as-child>
<NuxtLink to="/mod" class="flex items-center gap-1.5">
<Icon name="ph:gavel-fill" class="size-4" />
Mod
</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" />
@@ -159,7 +174,7 @@ async function openRandomThread() {
<DropdownMenu>
<DropdownMenuTrigger class="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 outline-none hover:bg-accent">
<Avatar class="size-8 shrink-0">
<AvatarImage v-if="user.avatarUrl" :src="user.avatarUrl" :alt="user.name" />
<AvatarImage v-if="user.avatarUrl" :src="user.avatarUrl" :alt="publicName" />
<AvatarFallback
class="text-xs"
:style="user.color ? { backgroundColor: user.color, color: '#fff' } : undefined"
@@ -168,13 +183,13 @@ async function openRandomThread() {
</AvatarFallback>
</Avatar>
<span
class="truncate text-base font-medium"
class="truncate text-lg font-medium"
:style="user.color ? { color: user.color } : undefined"
>{{ user.name }}</span>
>{{ publicName }}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuLabel class="truncate">
{{ user.name }}
{{ publicName }}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem as-child>
@@ -183,6 +198,12 @@ async function openRandomThread() {
{{ $t('nav.profile') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<NuxtLink to="/shop" class="flex items-center gap-2">
<Icon name="ph:coins-fill" class="size-4" />
{{ $t('nav.shop') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<NuxtLink to="/settings" class="flex items-center gap-2">
<Icon name="ph:gear-fill" class="size-4" />
@@ -197,7 +218,12 @@ async function openRandomThread() {
</DropdownMenuSubTrigger>
<DropdownMenuSubContent class="max-h-80 w-44 overflow-y-auto">
<DropdownMenuRadioGroup v-model="theme">
<DropdownMenuRadioItem v-for="t in THEMES" :key="t.value" :value="t.value">
<DropdownMenuRadioItem
v-for="t in THEMES"
:key="t.value"
:value="t.value"
@select.prevent
>
{{ t.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
+7 -1
View File
@@ -5,7 +5,7 @@ import type { Sticker } from '~~/shared/utils/stickers'
const props = withDefaults(defineProps<{
sticker: Sticker
variant?: 'inline' | 'compact' | 'tile' | 'card'
variant?: 'inline' | 'compact' | 'tile' | 'card' | 'reaction' | 'tag'
}>(), { variant: 'inline' })
// Skeleton bis zum ersten load/loadeddata - danach bleibt das Medium
@@ -30,6 +30,8 @@ const wrapClass = computed(() => ({
tile: 'relative inline-block',
compact: 'relative my-0.5 inline-block align-middle',
inline: 'relative my-0.5 inline-block align-middle',
reaction: 'relative inline-block align-middle',
tag: 'relative inline-block align-middle',
}[props.variant]))
const skeletonClass = computed(() => ({
@@ -37,6 +39,8 @@ const skeletonClass = computed(() => ({
tile: 'size-20 rounded-md',
compact: 'block size-12 rounded-md',
inline: 'h-40 w-40 rounded-md',
reaction: 'block size-7 -my-1 rounded',
tag: 'block size-6 -my-0.5 rounded',
}[props.variant]))
const mediaClass = computed(() => ({
@@ -44,6 +48,8 @@ const mediaClass = computed(() => ({
tile: 'size-20 rounded-md object-cover',
compact: 'h-12 w-auto max-w-full rounded-md object-contain',
inline: 'h-40 w-auto max-w-full rounded-md object-contain',
reaction: 'block size-7 -my-1 rounded object-cover',
tag: 'block size-6 -my-0.5 rounded object-cover',
}[props.variant]))
</script>
+4 -2
View File
@@ -13,12 +13,14 @@ import { toast } from 'vue-sonner'
const emit = defineEmits<{ select: [name: string] }>()
// v-model:open nach aussen, damit der Composer seine Hover-Reveals
// (Rauschunterdrückung) offen halten kann, solange der Picker offen ist
const open = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
const { user, can } = useAuth()
const { stickers, refresh } = useStickers()
const { limits } = useLimits()
const open = ref(false)
const query = ref('')
const standardStickers = computed(() => filter((stickers.value ?? []).filter(e => e.isStandard)))
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
// Overlay-Effekte für dekorierte Tag-Pills. Partikel (sparkle/confetti)
// reichen bewusst über die Pill hinaus; shine läuft geclippt im Inneren.
// glow/wiggle sind Pill-Klassen (tagEffectClass), kein Overlay nötig.
defineProps<{ effect: string }>()
const SPARKLES = [
{ left: '12%', size: '10px', delay: '0s' },
{ left: '48%', size: '8px', delay: '0.6s' },
{ left: '82%', size: '12px', delay: '1.1s' },
]
const CONFETTI = [
{ x: '-18px', y: '-14px', c: 'var(--accent)', delay: '0s' },
{ x: '14px', y: '-18px', c: 'var(--primary)', delay: '0.25s' },
{ x: '-6px', y: '-22px', c: 'var(--accent)', delay: '0.5s' },
{ x: '20px', y: '-8px', c: 'var(--primary)', delay: '0.1s' },
{ x: '0px', y: '-26px', c: 'var(--accent)', delay: '0.65s' },
]
</script>
<template>
<span v-if="effect === 'sparkle'" class="pointer-events-none absolute inset-0" aria-hidden="true">
<span
v-for="(s, i) in SPARKLES"
:key="i"
class="tag-fx-sparkle-bit absolute top-1/2 text-accent"
:style="{ left: s.left, fontSize: s.size, animationDelay: s.delay }"
>✦</span>
</span>
<span v-else-if="effect === 'confetti'" class="pointer-events-none absolute inset-0" aria-hidden="true">
<span
v-for="(c, i) in CONFETTI"
:key="i"
class="tag-fx-confetti-bit absolute top-1/2 left-1/2 block size-[3px] rounded-[1px]"
:style="{ backgroundColor: c.c, animationDelay: c.delay, '--cx': c.x, '--cy': c.y }"
/>
</span>
<span v-else-if="effect === 'shine'" class="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]" aria-hidden="true">
<span class="tag-fx-shine-beam absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-foreground/25 to-transparent" />
</span>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
// Tag-Icon: Iconify-Name („ph:x") -> Icon-Komponente, Sticker-Shortcode
// („:name:") -> StickerMedia, lokaler Pfad („/uploads/...") -> <img>,
// sonst Emoji-Text
const props = defineProps<{
icon: string
class?: string
}>()
const { byName } = useStickers()
const stickerName = computed(() => /^:([a-z0-9_]+):$/.exec(props.icon)?.[1] ?? null)
const sticker = computed(() => stickerName.value ? byName.value.get(stickerName.value) ?? null : null)
const isFile = computed(() => props.icon.startsWith('/'))
const isIconify = computed(() => !stickerName.value && !isFile.value && props.icon.includes(':'))
</script>
<template>
<StickerMedia v-if="sticker" :sticker="sticker" variant="tag" :class="props.class" />
<img
v-else-if="isFile"
:src="icon"
:class="cn('size-4 shrink-0 rounded-[2px] object-contain', props.class)"
alt=""
aria-hidden="true"
>
<Icon v-else-if="isIconify" :name="icon" :class="cn('size-3.5 shrink-0', props.class)" aria-hidden="true" />
<span v-else :class="cn('shrink-0 leading-none', props.class)" aria-hidden="true">{{ icon }}</span>
</template>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import {
ComboboxAnchor,
ComboboxContent,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxViewport,
} from 'reka-ui'
import { Badge } from '@/components/ui/badge'
interface TagSuggestion {
name: string
count: number
icon?: string | null
}
const props = withDefaults(defineProps<{
suggestions?: TagSuggestion[]
existingTags?: string[]
placeholder?: string
}>(), {
suggestions: () => [],
existingTags: () => [],
placeholder: '',
})
const modelValue = defineModel<string>({ default: '' })
const emit = defineEmits<{ add: [value: string] }>()
const open = ref(false)
const visibleSuggestions = computed(() =>
props.suggestions.filter(s => !props.existingTags.includes(s.name)),
)
function selectSuggestion(name: string) {
emit('add', name)
open.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === ',') {
e.preventDefault()
emit('add', modelValue.value)
}
else if (e.key === 'Enter' && !e.defaultPrevented && (!open.value || !visibleSuggestions.value.length)) {
e.preventDefault()
emit('add', modelValue.value)
}
}
</script>
<template>
<ComboboxRoot
v-model:open="open"
ignore-filter
open-on-click
:reset-search-term-on-blur="false"
>
<ComboboxAnchor class="block w-full">
<ComboboxInput
v-model="modelValue"
:placeholder="placeholder"
:aria-label="placeholder"
class="w-full bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground"
@keydown="onKeydown"
/>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
position="popper"
side="bottom"
align="start"
:side-offset="6"
hide-when-empty
class="z-50 w-(--reka-combobox-trigger-width) min-w-40 max-w-(--reka-combobox-content-available-width) origin-(--reka-combobox-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2"
>
<ComboboxViewport class="no-scrollbar max-h-64 overflow-y-auto">
<ComboboxGroup>
<ComboboxItem
v-for="suggestion in visibleSuggestions"
:key="suggestion.name"
:value="suggestion.name"
:text-value="suggestion.name"
class="data-highlighted:bg-accent data-highlighted:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none"
@select.prevent="selectSuggestion(suggestion.name)"
>
<TagIcon v-if="suggestion.icon" :icon="suggestion.icon" />
<Icon v-else name="ph:tag-fill" class="size-3.5 text-muted-foreground" />
<span class="truncate">{{ suggestion.name }}</span>
<Badge variant="secondary" class="ml-auto px-1.5 py-0 font-normal">
{{ suggestion.count }}
</Badge>
</ComboboxItem>
</ComboboxGroup>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</template>
+33 -23
View File
@@ -41,26 +41,29 @@ function applyLocale() {
onMounted(async () => {
await ensureLoaded()
// Shadow-CSS anpassen: Button-Radius ist hart auf 100% kodiert und die
// Bereiche (Suche/Kategorie-Nav/Gruppen/Skintone) sind nicht getrennt.
// Offener Shadow-Root erlaubt Override-Regeln mit Theme-Tokens
// Shadow-CSS-Overrides mit Theme-Tokens (offener Shadow-Root):
// kompakteres Emoji-Grid, klare Bereiche Suche/Nav/Gruppen/Favoriten
const style = document.createElement('style')
style.textContent = `
.emoji, button.emoji { border-radius: var(--radius) !important }
.search-row { padding: 0.5rem var(--emoji-padding) 0.375rem }
.nav { border-block: 1px solid var(--border); padding-block: 0.25rem }
.category {
font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
padding: 0.5rem var(--emoji-padding) 0.125rem;
border-top: 1px solid var(--border); margin-top: 0.375rem;
}
div[data-action="calculateEmojiGridStyle"] > div:first-child > .category { border-top: 0; margin-top: 0; padding-top: 0.25rem }
.skintone-button-wrapper { padding: 0.25rem 0 }
/* die geöffnete Skintone-Liste ist an .search-row verankert -
gleicher rechter Offset wie der Button, sonst hängt sie daneben;
top = padding-top der Zeile, damit sie unter dem Button beginnt */
.skintone-list { inset-inline-end: var(--emoji-padding); top: 0.5rem }
.emoji-menu { padding-inline: 0.25rem }
.pad-top { display: none }
.search-row { padding: 0.75rem; gap: 0.5rem }
input.search { min-height: 2.25rem }
.skintone-button-wrapper { padding: 0; flex: none }
#skintone-button { height: 2.25rem }
/* Skintone-Liste bleibt an .search-row verankert (Offsets = deren Padding);
Transform-Animation und z-index-Logik der Lib unverändert */
.skintone-list { top: 0.75rem; inset-inline-end: 0.75rem }
.nav { padding: 0.25rem 0.5rem; border-top: 1px solid var(--border) }
.nav-button { min-width: 0; border-radius: var(--radius) }
.nav-button[aria-selected="true"] { background: var(--accent) }
.nav-emoji { max-width: 100% }
.category { font-size: 0.75rem; font-weight: 600; padding: 0.75rem 0.5rem 0.375rem }
.tabpanel { padding: 0.25rem 0.5rem 0.5rem }
.emoji-menu:not(.favorites) { grid-template-columns: repeat(var(--num-columns), minmax(0, 1fr)); justify-items: center }
.emoji-menu:not(.favorites) > .emoji { width: 100%; max-width: var(--total-emoji-size) }
.favorites { border-top: 1px solid var(--border); padding: 0.375rem 0.5rem; gap: 0.125rem; flex-shrink: 0 }
.favorites > .emoji { flex: 0 0 var(--total-emoji-size) }
`
pickerEl.value?.shadowRoot?.appendChild(style)
applyLocale()
@@ -95,11 +98,18 @@ emoji-picker.unicode-emoji-picker {
--border-size: 0;
--input-border-radius: var(--radius);
--skintone-border-radius: var(--radius);
--emoji-padding: 0.625rem;
--input-padding: 0.375rem;
--num-columns: 9;
width: 24.5rem;
max-width: calc(100vw - 2rem);
height: 22rem;
--emoji-size: 1.375rem;
--emoji-padding: 0.4375rem;
--category-emoji-size: 1.125rem;
--category-emoji-padding: 0.375rem;
--input-font-size: 0.875rem;
--input-padding: 0.5rem;
--num-columns: 8;
font-family: var(--font-sans);
width: 22rem;
max-width: min(calc(100vw - 2rem), var(--reka-popover-content-available-width, calc(100vw - 2rem)));
height: 23rem;
max-height: min(26rem, var(--reka-popover-content-available-height, calc(100dvh - 2rem)));
min-height: 0;
}
</style>
+91 -56
View File
@@ -11,9 +11,13 @@ import {
} 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 { RangeCalendar } from '@/components/ui/range-calendar'
import { Switch } from '@/components/ui/switch'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
import { parseDate } from '@internationalized/date'
import type { DateRange } from 'reka-ui'
import { tagEffectClass } from '~~/shared/utils/tags'
interface ListedUser {
id: number
@@ -23,9 +27,14 @@ interface ListedUser {
stats?: { uploads: number }
}
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { t, locale } = useI18n()
const { user } = useAuth()
const filters = useUploadFilters()
const { tag: tagFilter, types: selectedTypes, uploaderId, dateFrom, dateTo, following: followingOnly } = filters
// Deko-Lookup für den aktiven Tag-Chip
const tagDeco = useTagDecorations()
onMounted(() => tagDeco.load())
const tagDecoForChip = computed(() => tagDeco.get(tagFilter.value))
const TYPE_OPTIONS = [
{ value: 'image', labelKey: 'filters.images', icon: 'ph:image-fill' },
@@ -36,15 +45,9 @@ const TYPE_OPTIONS = [
{ value: 'link', labelKey: 'filters.links', icon: 'ph:link-fill' },
] as const
const DAY_OPTIONS = [
{ value: '1', labelKey: 'filters.today' },
{ value: '7', labelKey: 'filters.days7' },
{ value: '30', labelKey: 'filters.days30' },
{ value: '365', labelKey: 'filters.lastYear' },
] as const
const open = ref(false)
const uploaderOpen = ref(false)
const dateOpen = ref(false)
const users = ref<ListedUser[]>([])
const usersLoaded = ref(false)
@@ -61,24 +64,41 @@ async function loadUsers() {
watch(open, v => v && loadUsers())
onMounted(() => {
if (route.query.uploader) loadUsers()
if (uploaderId.value) 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 }),
// Ohne Login ist der Follow-Filter sinnlos - beim Logout direkt zurücksetzen
watch(() => user.value, u => {
if (!u) followingOnly.value = false
})
// Datumsbereich als DateRange-Objekt für den Kalender; State hält ISO-Strings.
// Nur Start gewählt = offenes Ende (Uploads seit diesem Datum).
const rangeModel = computed<DateRange>({
get: () => ({
start: dateFrom.value ? parseDate(dateFrom.value) : undefined,
end: dateTo.value ? parseDate(dateTo.value) : undefined,
}),
set: (r) => {
dateFrom.value = r?.start ? r.start.toString() : null
dateTo.value = r?.end ? r.end.toString() : null
},
})
const fmtDate = (iso: string) =>
new Date(`${iso}T00:00:00`).toLocaleDateString(locale.value, { day: 'numeric', month: 'short', year: 'numeric' })
const rangeLabel = computed(() => {
const from = dateFrom.value
const to = dateTo.value
if (from && to) return `${fmtDate(from)} – ${fmtDate(to)}`
if (from) return t('filters.rangeFrom', { date: fmtDate(from) })
if (to) return t('filters.rangeUntil', { date: fmtDate(to) })
return ''
})
const daysOption = computed(() => DAY_OPTIONS.find(d => d.value === daysModel.value))
const typeLabel = (value: string) => {
const option = TYPE_OPTIONS.find(o => o.value === value)
@@ -86,26 +106,15 @@ const typeLabel = (value: string) => {
}
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 })
}
const activeCount = filters.activeCount
function selectUser(u: ListedUser | null) {
setQuery({ uploader: u ? String(u.id) : undefined })
uploaderId.value = u ? String(u.id) : null
uploaderOpen.value = false
}
function clearFilters() {
setQuery({ tag: undefined, type: undefined, uploader: undefined, days: undefined })
filters.reset()
}
</script>
@@ -190,17 +199,28 @@ function clearFilters() {
</PopoverContent>
</Popover>
</Field>
<Field v-if="user" class="flex flex-row items-center justify-between">
<FieldLabel for="filter-following">{{ $t('filters.following') }}</FieldLabel>
<Switch id="filter-following" v-model="followingOnly" />
</Field>
<Field>
<FieldLabel>{{ $t('filters.period') }}</FieldLabel>
<Select v-model="daysModel">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('filters.allTime')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ $t('filters.allTime') }}</SelectItem>
<SelectItem v-for="d in DAY_OPTIONS" :key="d.value" :value="d.value">{{ $t(d.labelKey) }}</SelectItem>
</SelectContent>
</Select>
<Popover v-model:open="dateOpen">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" :aria-expanded="dateOpen" class="w-full justify-between font-normal">
<span class="flex items-center gap-1.5 truncate">
<Icon name="ph:calendar-fill" class="size-4 shrink-0 text-muted-foreground" />
<span class="truncate">
{{ rangeLabel || $t('filters.allTime') }}
</span>
</span>
<Icon name="ph:caret-up-down-fill" class="size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<RangeCalendar v-model="rangeModel" :number-of-months="2" initial-focus />
</PopoverContent>
</Popover>
</Field>
<Button variant="ghost" size="sm" :disabled="!activeCount" class="self-start" @click="clearFilters">
{{ $t('common.reset') }}
@@ -209,17 +229,20 @@ function clearFilters() {
</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" />
<span v-if="tagFilter" class="relative inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm" :class="tagEffectClass(tagDecoForChip?.effect)">
<TagIcon v-if="tagDecoForChip?.icon && tagDecoForChip.iconPosition === 'before'" :icon="tagDecoForChip.icon" />
<Icon v-else name="ph:tag-fill" class="size-3.5" />
{{ tagFilter }}
<TagIcon v-if="tagDecoForChip?.icon && tagDecoForChip.iconPosition === 'after'" :icon="tagDecoForChip.icon" />
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:aria-label="$t('filters.removeTag')"
@click="setQuery({ tag: undefined })"
@click="tagFilter = null"
>
<Icon name="ph:x-fill" class="size-3.5" />
<Icon name="ph:x-bold" class="size-3.5" />
</button>
<TagEffect v-if="tagDecoForChip?.effect" :effect="tagDecoForChip.effect" />
</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" />
@@ -230,7 +253,7 @@ function clearFilters() {
:aria-label="$t('filters.removeType', { label: typeLabel(t) })"
@click="selectedTypes = selectedTypes.filter(v => v !== t)"
>
<Icon name="ph:x-fill" class="size-3.5" />
<Icon name="ph:x-bold" 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">
@@ -240,21 +263,33 @@ function clearFilters() {
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:aria-label="$t('filters.removeUploader')"
@click="setQuery({ uploader: undefined })"
@click="uploaderId = null"
>
<Icon name="ph:x-fill" class="size-3.5" />
<Icon name="ph:x-bold" 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">
<span v-if="followingOnly" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon name="ph:user-check-fill" class="size-3.5" />
{{ $t('filters.following') }}
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:aria-label="$t('filters.removeFollowing')"
@click="followingOnly = false"
>
<Icon name="ph:x-bold" class="size-3.5" />
</button>
</span>
<span v-if="rangeLabel" 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 ? $t(daysOption.labelKey) : '' }}
{{ rangeLabel }}
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:aria-label="$t('filters.removePeriod')"
@click="setQuery({ days: undefined })"
@click="dateFrom = null; dateTo = null"
>
<Icon name="ph:x-fill" class="size-3.5" />
<Icon name="ph:x-bold" class="size-3.5" />
</button>
</span>
</div>
+15 -9
View File
@@ -23,6 +23,17 @@ const errorMessage = ref('')
const loading = ref(false)
const progress = ref(0)
// Vom globalen Paste-Handler (GlobalDropZone) gesetzt: URL aus der
// Zwischenablage ins URL-Feld übernehmen und das Popover öffnen
const pasteUrl = useState<string>('upload:paste-url', () => '')
watch(pasteUrl, (v) => {
if (!v) return
url.value = v
pasteUrl.value = ''
open.value = true
nextTick(() => (document.getElementById('upload-url') as HTMLInputElement | null)?.select())
})
async function onSubmit() {
if (loading.value || (!file.value && !url.value.trim())) return
loading.value = true
@@ -64,17 +75,12 @@ async function onSubmit() {
{{ $t('upload.upload') }}
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-160">
<PopoverContent align="start" class="w-160 max-w-[calc(100vw-2rem)]">
<template v-if="user">
<PopoverHeader>
<PopoverTitle>{{ $t('upload.upload') }}</PopoverTitle>
<PopoverDescription class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span class="flex items-center gap-1"><Icon name="ph:image-fill" class="size-3.5" />{{ $t('filters.images') }}</span>
<span class="flex items-center gap-1"><Icon name="ph:video-fill" class="size-3.5" />{{ $t('filters.videos') }}</span>
<span class="flex items-center gap-1"><Icon name="ph:music-note-fill" class="size-3.5" />{{ $t('filters.audio') }}</span>
<span class="flex items-center gap-1"><Icon name="simple-icons:youtube" class="size-3.5" />YouTube</span>
<span class="flex items-center gap-1"><Icon name="simple-icons:twitch" class="size-3.5" />Twitch</span>
<span class="flex items-center gap-1"><Icon name="ph:link-simple-duotone" class="size-3.5" />{{ $t('filters.links') }}</span>
<PopoverDescription>
{{ $t('filters.images') }} · {{ $t('filters.videos') }} · {{ $t('filters.audio') }} · YouTube · Twitch · {{ $t('filters.links') }}
</PopoverDescription>
</PopoverHeader>
<form class="space-y-4" @submit.prevent="onSubmit">
@@ -86,7 +92,7 @@ async function onSubmit() {
<Label for="upload-url">{{ $t('upload.orUrl') }}</Label>
<Input id="upload-url" v-model="url" type="url" placeholder="https://…" :disabled="!!file" />
</div>
<p v-if="errorMessage" class="text-sm text-destructive">
<p v-if="errorMessage" role="alert" class="text-sm text-destructive">
{{ errorMessage }}
</p>
<div v-if="loading && file" class="flex items-center gap-2.5">
+19 -7
View File
@@ -8,6 +8,7 @@ const emit = defineEmits<{ subscriptionChange: [uploadId: string, enabled: boole
const { user } = useAuth()
const { reactionsFor, toggle } = useUploadReactions()
const { byName } = useStickers()
const { t } = useI18n()
const busy = ref(false)
@@ -21,7 +22,7 @@ const BURST_LINES = Array.from({ length: 12 }, (_, i) => ({
const burstEmoji = ref<string | null>(null)
const burstNonce = ref(0)
async function pick(emoji: string) {
async function pick(emoji: string, sticker = false) {
if (!user.value) {
toast.error(t('comments.loginToReact'))
return
@@ -33,11 +34,12 @@ async function pick(emoji: string) {
burstNonce.value++
try {
const uploadId = props.uploadId
const result = await toggle(uploadId, emoji)
const result = await toggle(uploadId, emoji, sticker)
emit('subscriptionChange', uploadId, result.isSubscribed)
}
catch {
toast.error(t('comments.reactionFailed'))
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('comments.reactionFailed'))
}
finally {
busy.value = false
@@ -59,7 +61,7 @@ async function pick(emoji: string) {
: 'border-border bg-muted/40 hover:border-foreground/30',
pending === r.emoji && 'animate-pulse',
)"
@click="pick(r.emoji)"
@click="pick(r.emoji, r.sticker)"
>
<span v-if="burstEmoji === r.emoji" :key="burstNonce" class="pointer-events-none absolute inset-0" aria-hidden="true">
<i
@@ -69,12 +71,22 @@ async function pick(emoji: string) {
: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>
<StickerMedia
v-if="r.sticker && byName.get(r.emoji)"
:key="burstNonce"
:sticker="byName.get(r.emoji)!"
variant="reaction"
:class="burstEmoji === r.emoji && 'emoji-pop'"
/>
<span v-else-if="r.sticker" class="text-xs leading-none text-muted-foreground">:{{ r.emoji }}:</span>
<span v-else :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 class="flex items-center gap-2.5 px-3 py-2">
<span class="text-5xl leading-none">{{ r.emoji }}</span>
<StickerMedia v-if="r.sticker && byName.get(r.emoji)" :sticker="byName.get(r.emoji)!" variant="compact" />
<span v-else-if="r.sticker" class="text-xl leading-none">:{{ r.emoji }}:</span>
<span v-else class="text-5xl leading-none">{{ r.emoji }}</span>
<span class="flex flex-col leading-snug">
<span v-for="n in r.names" :key="n">{{ n }}</span>
</span>
+86 -20
View File
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
import { thumbnailSizeForScore, type ThumbnailSize } from '~~/shared/utils/activityScore'
import type { CardBorderEffectKey } from '~~/shared/utils/cardBorderEffects'
interface TileUpload {
id: string
@@ -11,16 +14,33 @@ interface TileUpload {
userColor?: string | null
userAvatarUrl?: string | null
commentCount?: number
activityScore?: number
thumbnailSize?: ThumbnailSize
boostedUntil?: string | null
goldenUntil?: string | null
goldenEffect?: string | null
spotlight?: boolean
}
const props = withDefaults(defineProps<{ upload: TileUpload, selectable?: boolean, selected?: boolean }>(), {
const props = withDefaults(defineProps<{
upload: TileUpload
selectable?: boolean
selected?: boolean
fromPath?: string | null
fromLabel?: string | null
// Erzwingt 1x1-Kacheln unabhängig vom globalen Grid-Modus (z. B. Hallen)
forceUniform?: boolean
}>(), {
selectable: false,
selected: false,
fromPath: undefined,
fromLabel: undefined,
})
const emit = defineEmits<{ select: [] }>()
const { unreadUploadIds } = useNotifications()
const { uniform } = useGridMode()
const { limits, loaded: limitsLoaded } = useLimits()
const lastViewedId = useLastViewedUpload()
const pendingMedia = usePendingUploadMedia()
const hasNotification = computed(() => unreadUploadIds.value.has(props.upload.id))
@@ -36,6 +56,7 @@ const canPlaceholder = computed(() => {
const indicator = useLoadingIndicator()
const route = useRoute()
const navigating = ref(false)
const tileMediaEl = useTemplateRef<HTMLElement>('tileMediaEl')
let prefetchedImageUrl: string | null = null
function prefetchOnHover() {
@@ -51,21 +72,37 @@ function prefetchOnHover() {
// und Breadcrumbs auf die Uploads dieses Nutzers
const navContext = useUploadNavContext()
const isBoosted = computed(() => !!props.upload.boostedUntil && new Date(props.upload.boostedUntil).getTime() > Date.now())
const isGolden = computed(() => !!props.upload.goldenUntil && new Date(props.upload.goldenUntil).getTime() > Date.now())
// 'none' laesst nur den statischen goldenen Ring; Default bleibt der Beam
const goldenBorderEffect = computed<CardBorderEffectKey | null>(() => {
if (!isGolden.value) return null
const e = props.upload.goldenEffect
return e === 'pulse' || e === 'beam' ? e : e === 'none' ? null : 'beam'
})
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'
if (props.forceUniform || uniform.value) return ''
// Shop-Boost forciert die groesste Thumbnail-Groesse (spiegelt den Server)
if (isBoosted.value) return 'col-span-2 row-span-2 sm:col-span-3 sm:row-span-3'
const score = props.upload.activityScore ?? props.upload.commentCount ?? 0
const size = limitsLoaded.value
? thumbnailSizeForScore(score, limits.value)
: props.upload.thumbnailSize ?? thumbnailSizeForScore(score, limits.value)
if (size === 'large') return 'col-span-2 row-span-2 sm:col-span-3 sm:row-span-3'
if (size === 'medium') return 'col-span-2 row-span-2'
return ''
})
// Capture-Phase: läuft vor NuxtLinks eigenem Klick-Handler - preventDefault +
// stopImmediatePropagation verhindert die Navigation sicher in allen Browsern
// Capture runs before NuxtLink starts navigation and takes the old snapshot.
function captureClick(e: MouseEvent) {
if (!props.selectable) return
e.preventDefault()
e.stopImmediatePropagation()
emit('select')
if (props.selectable) {
e.preventDefault()
e.stopImmediatePropagation()
emit('select')
return
}
openUpload(e)
}
function openUpload(e: MouseEvent) {
@@ -76,10 +113,22 @@ function openUpload(e: MouseEvent) {
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
// Vue batches reactive updates; the browser can snapshot before they flush.
// Set the name and hide hover overlays directly on the source element.
lastViewedId.value = null
if (tileMediaEl.value) {
const image = tileMediaEl.value.querySelector('img')
const video = tileMediaEl.value.querySelector('video')
const ready = image
? image.complete && image.naturalWidth > 0
: !!video && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
tileMediaEl.value.style.viewTransitionName = ready ? 'upload-media' : ''
lastViewedId.value = ready ? props.upload.id : null
for (const overlay of tileMediaEl.value.querySelectorAll<HTMLElement>('[data-tile-hover-overlay]')) {
overlay.style.transition = 'none'
overlay.style.opacity = '0'
}
}
// Bekannte Mediendaten, damit die Thread-Seite sofort ein benanntes
// Element rendern kann (View-Transition-Ziel vor Eintreffen der Daten)
pendingMedia.value = {
@@ -90,8 +139,12 @@ function openUpload(e: MouseEvent) {
}
navContext.value = {
uploadId: props.upload.id,
fromPath: /^\/user\/\d+$/.test(route.path) ? route.path : null,
fromLabel: props.upload.userName ?? null,
fromPath: props.fromPath !== undefined
? props.fromPath
: (/^\/user\/\d+$/.test(route.path) ? route.path : null),
fromLabel: props.fromLabel !== undefined
? props.fromLabel
: (props.upload.userName ?? null),
}
}
</script>
@@ -100,13 +153,13 @@ function openUpload(e: MouseEvent) {
<NuxtLink
:to="`/t/${upload.id}`"
class="upload-tile group block"
:class="[spanClass, navigating ? 'opacity-80' : '']"
:class="spanClass"
@pointerenter="prefetchOnHover"
@click.capture="captureClick"
@click="openUpload"
>
<div
:class="cn('upload-tile-media relative h-full w-full overflow-hidden rounded-md', selected ? 'ring-4 ring-accent' : hasNotification && 'ring-4 ring-primary')"
ref="tileMediaEl"
:class="cn('upload-tile-media relative h-full w-full overflow-hidden rounded-md', selected ? 'ring-4 ring-accent' : hasNotification ? 'ring-4 ring-primary' : isGolden && !goldenBorderEffect ? 'ring-2 ring-amber-400 shadow-[0_0_14px_2px_rgba(251,191,36,0.35)]' : upload.spotlight && 'ring-2 ring-accent')"
:style="lastViewedId === upload.id ? 'view-transition-name: upload-media' : undefined"
>
<NuxtImg
@@ -138,17 +191,30 @@ function openUpload(e: MouseEvent) {
>
<Icon v-if="selected" name="ph:check-bold" class="size-3.5" />
</span>
<span
v-if="upload.spotlight"
:class="cn('absolute top-1.5 z-10 flex size-6 items-center justify-center rounded-full bg-accent text-accent-foreground shadow', selectable ? 'left-9' : 'left-1.5')"
>
<Icon name="ph:sparkle-fill" class="size-3.5" />
</span>
<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>
<CardBorderEffect
v-if="goldenBorderEffect && !selected && !hasNotification"
:effect="goldenBorderEffect"
color="#fbbf24"
/>
<div
data-tile-hover-overlay
class="absolute inset-0 bg-primary/30 opacity-0 transition-opacity"
:class="!navigating && 'group-hover:opacity-100'"
/>
<div
data-tile-hover-overlay
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"
:class="!navigating && 'group-hover:opacity-100'"
>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { usePreferredReducedMotion } from '@vueuse/core'
// Dekoratives Banner-Medium für die UserHoverCard (Bild/GIF oder Video).
// Plain <img> statt NuxtImg, damit animierte GIFs erhalten bleiben.
const props = defineProps<{
src: string
type: 'image' | 'video'
}>()
const videoEl = ref<HTMLVideoElement | null>(null)
const reducedMotion = usePreferredReducedMotion()
// Video-Autoplay respektiert prefers-reduced-motion (GIFs bleiben unverändert)
function syncMotion() {
const el = videoEl.value
if (!el) return
if (reducedMotion.value === 'reduce') {
el.pause()
}
else {
el.muted = true
el.play().catch(() => {})
}
}
watch([reducedMotion, videoEl, () => props.src], syncMotion, { flush: 'post' })
</script>
<template>
<video
v-if="type === 'video'"
ref="videoEl"
:src="src"
:autoplay="reducedMotion !== 'reduce'"
loop
muted
playsinline
preload="metadata"
aria-hidden="true"
class="size-full object-cover"
/>
<img
v-else
:src="src"
alt=""
class="size-full object-cover"
>
</template>
+134 -34
View File
@@ -4,13 +4,21 @@ import {
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
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 type { CardHoloEffectKey } from '~~/shared/utils/cardHoloEffects'
import { isCardHoloEffect } from '~~/shared/utils/cardHoloEffects'
import CardBackgroundEffect from '@/components/effects/CardBackgroundEffect.vue'
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
import CardHoloEffect from '@/components/effects/CardHoloEffect.vue'
import CardVignette from '@/components/effects/CardVignette.vue'
import UserCardBanner from '@/components/UserCardBanner.vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
userId: number
@@ -20,6 +28,8 @@ const props = defineProps<{
}>()
const { summaries, ensureSummary } = useUserSummaries()
const { user: currentUser } = useAuth()
const { openFloatingChat } = useDirectMessages()
const { locale } = useI18n()
const summary = computed(() => summaries.value[props.userId])
const avatar = computed(() => summary.value?.avatarUrl ?? props.userAvatarUrl ?? null)
@@ -32,16 +42,45 @@ const borderEffect = computed<CardBorderEffectKey | null>(() => {
const key = summary.value?.cardBorderEffect
return key && isCardBorderEffect(key) ? key : null
})
const holoEffect = computed<CardHoloEffectKey | null>(() => {
const key = summary.value?.cardHoloEffect
return key && isCardHoloEffect(key) ? key : null
})
// Gekaufter Namensverlauf (Shop)
const nameStyle = computed(() => shopGradientNameStyle(color.value, summary.value?.nameGradient))
const banner = computed(() => {
const src = summary.value?.cardBannerUrl
const type = summary.value?.cardBannerType
return src && type ? { src, type } : null
})
const open = ref(false)
const holoActive = computed(() => open.value && !!holoEffect.value)
const { holoStyle, onPointerMove, onPointerLeave } = useHoloTilt(holoActive)
function onOpen(v: boolean) {
open.value = v
if (v) ensureSummary(props.userId)
}
function startFloatingDm() {
openFloatingChat({
id: props.userId,
name: summary.value?.name ?? props.userName,
color: color.value,
avatarUrl: avatar.value,
})
open.value = false
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString(locale.value, { day: '2-digit', month: 'short', year: 'numeric' })
}
function formatDateTime(value: string) {
return new Date(value).toLocaleString(locale.value, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
@@ -51,16 +90,20 @@ function formatDate(value: string) {
</HoverCardTrigger>
<HoverCardContent
v-if="open"
class="relative w-72 overflow-hidden rounded-md ring-0 border-2"
:class="borderEffect && 'border-transparent'"
:style="!borderEffect ? { borderColor: color || 'var(--color-border)' } : undefined"
:class="cn(
'relative w-72 overflow-hidden rounded-md p-0 ring-0 border-2',
borderEffect && 'border-transparent',
holoEffect && 'shadow-[0_14px_28px_-6px_rgba(0,0,0,0.65)]',
)"
:style="{
...(!borderEffect ? { borderColor: color || 'var(--color-border)' } : {}),
...holoStyle,
}"
@pointermove="onPointerMove"
@pointerleave="onPointerLeave"
>
<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">
<div v-if="banner" class="relative h-24">
<UserCardBanner :src="banner.src" :type="banner.type" />
<NuxtImg
v-if="avatar"
:src="avatar"
@@ -68,38 +111,95 @@ function formatDate(value: string) {
width="56"
height="56"
fit="cover"
class="size-14 shrink-0 rounded-md"
class="absolute -bottom-7 left-4 z-10 size-14 rounded-md ring-4 ring-popover"
/>
<span
v-else
class="flex size-14 shrink-0 items-center justify-center rounded-md text-lg font-medium"
class="absolute -bottom-7 left-4 z-10 flex size-14 items-center justify-center rounded-md text-lg font-medium ring-4 ring-popover"
: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" />
{{ $t('misc.since', { date: formatDate(summary.createdAt) }) }}
</p>
</div>
<div :class="cn('relative isolate overflow-hidden p-4', banner && 'pt-10')">
<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 z-10 flex items-start gap-3">
<NuxtImg
v-if="!banner && avatar"
:src="avatar"
:alt="userName"
width="56"
height="56"
fit="cover"
class="size-14 shrink-0 rounded-md"
/>
<span
v-else-if="!banner"
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">
<div class="flex flex-wrap items-center gap-1.5">
<NuxtLink
:to="`/user/${userId}`"
class="truncate text-base font-medium hover:underline"
:style="nameStyle"
>{{ summary?.name ?? userName }}</NuxtLink>
<Badge
v-for="role in summary?.roles"
:key="role"
variant="secondary"
class="px-1.5 py-0 text-[10px] uppercase tracking-wider"
>{{ role }}</Badge>
<Badge v-if="summary?.flair" class="px-1.5 py-0 text-[10px]">
{{ summary.flair }}
</Badge>
</div>
<p class="text-xs text-muted-foreground">
ID {{ userId }}
</p>
<p v-if="summary?.renamedUntil" class="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<Icon name="ph:identification-badge-fill" class="size-3 shrink-0" />
{{ $t('misc.renamedUntil', { date: formatDateTime(summary.renamedUntil) }) }}
</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" />
{{ $t('misc.since', { date: formatDate(summary.createdAt) }) }}
</p>
</div>
</div>
<p
v-if="summary?.bio"
class="relative z-10 mt-2.5 line-clamp-3 break-words whitespace-pre-line text-xs text-muted-foreground"
>{{ summary.bio }}</p>
<div v-if="summary" class="relative z-10 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 }} {{ $t('misc.uploadsCol') }}
</span>
<span class="flex items-center gap-1.5">
<Icon name="ph:chat-fill" class="size-3.5" />
{{ summary.stats.comments }} {{ $t('misc.commentsCol') }}
</span>
<Tooltip v-if="currentUser && currentUser.id !== userId">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
class="ml-auto size-7 cursor-pointer text-muted-foreground hover:text-foreground"
:aria-label="$t('dm.message')"
@click.stop.prevent="startFloatingDm"
>
<Icon name="ph:chats-circle-fill" class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.message') }}</TooltipContent>
</Tooltip>
</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 }} {{ $t('misc.uploadsCol') }}
</span>
<span class="flex items-center gap-1.5">
<Icon name="ph:chat-fill" class="size-3.5" />
{{ summary.stats.comments }} {{ $t('misc.commentsCol') }}
</span>
</div>
<CardHoloEffect v-if="holoEffect" :effect="holoEffect" />
<CardBorderEffect v-if="borderEffect" :effect="borderEffect" :color="summary?.cardBorderEffectColor ?? color ?? '#6366f1'" />
</HoverCardContent>
</HoverCard>
+16 -10
View File
@@ -167,36 +167,42 @@ const cssVars = computed<CSSProperties>(() => ({ '--be-color': `rgb(${rgb.value}
</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;
}
.border-beam::before {
content: '';
position: absolute;
inset: -100%;
background: conic-gradient(transparent 60%, var(--be-color) 80%, transparent 100%);
animation: be-rotate 3s linear infinite;
}
.border-pulse {
border: 2px solid var(--be-color);
box-shadow: 0 0 12px 1px var(--be-color);
box-shadow: 0 0 16px 2px 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;
}
.border-rainbow::before {
content: '';
position: absolute;
inset: -100%;
background: conic-gradient(var(--be-color), #ffffff, var(--be-color));
animation: be-rotate 4s linear infinite;
}
@keyframes be-rotate {
to { --beam-angle: 360deg; }
to { transform: rotate(360deg); }
}
@keyframes be-pulse {
+596
View File
@@ -0,0 +1,596 @@
<script setup lang="ts">
import type { CardHoloEffectKey } from '~~/shared/utils/cardHoloEffects'
defineProps<{
effect: CardHoloEffectKey
}>()
</script>
<template>
<div
class="holo-layer holo-shine pointer-events-none absolute inset-0 z-20 rounded-[inherit]"
:data-holo="effect"
/>
<div
class="holo-layer holo-shine-secondary pointer-events-none absolute inset-0 z-20 rounded-[inherit]"
:data-holo="effect"
/>
<div
class="holo-layer holo-glare pointer-events-none absolute inset-0 z-20 rounded-[inherit]"
:data-holo="effect"
/>
</template>
<style scoped>
/* Alle Farbverläufe schließen nahtlos (Startfarbe === Endfarbe) und nutzen
weiche Übergänge statt harter Pixel-Scanlines - so entstehen weder
Abriss-Kanten noch Moiré-Linien bei 3D-Neigung. */
.holo-layer {
--sunpillar-1: hsl(2, 100%, 73%);
--sunpillar-2: hsl(53, 100%, 69%);
--sunpillar-3: hsl(93, 100%, 69%);
--sunpillar-4: hsl(176, 100%, 76%);
--sunpillar-5: hsl(228, 100%, 74%);
--sunpillar-6: hsl(283, 100%, 73%);
--holo-spectrum: repeating-linear-gradient(
115deg,
var(--sunpillar-1) 0%,
var(--sunpillar-2) 14%,
var(--sunpillar-3) 28%,
var(--sunpillar-4) 42%,
var(--sunpillar-5) 57%,
var(--sunpillar-6) 71%,
var(--sunpillar-1) 85%,
var(--sunpillar-2) 100%
);
--glitter-dots:
radial-gradient(circle at 16% 24%, rgba(255, 255, 255, 0.85) 0 0.9px, transparent 2.4px),
radial-gradient(circle at 76% 20%, rgba(255, 255, 255, 0.8) 0 1.1px, transparent 2.6px),
radial-gradient(circle at 42% 66%, rgba(255, 255, 255, 0.85) 0 0.9px, transparent 2.4px),
radial-gradient(circle at 84% 72%, rgba(255, 255, 255, 0.75) 0 1.1px, transparent 2.6px),
radial-gradient(circle at 26% 80%, rgba(255, 255, 255, 0.8) 0 1px, transparent 2.5px),
radial-gradient(circle at 58% 32%, rgba(255, 255, 255, 0.8) 0 0.9px, transparent 2.4px);
}
.holo-shine {
-webkit-mask-image: radial-gradient(
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.42) 38%,
rgba(0, 0, 0, 0.08) 72%,
transparent 100%
);
mask-image: radial-gradient(
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.42) 38%,
rgba(0, 0, 0, 0.08) 72%,
transparent 100%
);
}
.holo-shine-secondary {
-webkit-mask-image: radial-gradient(
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
rgba(0, 0, 0, 0.75) 0%,
rgba(0, 0, 0, 0.28) 42%,
transparent 82%
);
mask-image: radial-gradient(
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
rgba(0, 0, 0, 0.75) 0%,
rgba(0, 0, 0, 0.28) 42%,
transparent 82%
);
}
/* Dezenter Specular-Glare um den Mauszeiger */
.holo-glare {
background-image: radial-gradient(
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
hsla(0, 0%, 100%, 0.12) 0%,
hsla(195, 90%, 88%, 0.04) 30%,
transparent 60%
);
mix-blend-mode: plus-lighter;
}
/* =========================================================
1) REGULAR HOLO (weiche vertikale Lichtsäulen + Spektrum)
========================================================= */
.holo-shine[data-holo="holo"] {
background-image: var(--holo-spectrum);
background-position: calc(((50% - var(--background-x, 50%)) * 2.2) + 50%) calc(((50% - var(--background-y, 50%)) * 2.2) + 50%);
background-size: 300% 300%;
mix-blend-mode: plus-lighter;
opacity: 0.16;
}
.holo-shine-secondary[data-holo="holo"] {
background-image: linear-gradient(
90deg,
transparent 0%,
hsla(190, 100%, 85%, 0.28) 22%,
transparent 38%,
hsla(295, 100%, 85%, 0.25) 54%,
transparent 70%,
hsla(52, 100%, 82%, 0.22) 86%,
transparent 100%
);
background-position: calc(((50% - var(--background-x, 50%)) * 1.8) + 50%) 50%;
background-size: 220% 100%;
mix-blend-mode: plus-lighter;
opacity: 0.28;
}
/* =========================================================
2) REVERSE HOLO (metallischer Foil-Schimmer + Gegenlicht)
========================================================= */
.holo-shine[data-holo="reverse-holo"] {
background-image:
radial-gradient(
circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
hsla(195, 100%, 88%, 0.65) 0%,
hsla(265, 85%, 74%, 0.28) 38%,
transparent 72%
),
linear-gradient(
-45deg,
transparent 20%,
hsla(180, 95%, 82%, 0.4) 46%,
hsla(320, 90%, 80%, 0.35) 54%,
transparent 80%
);
background-size: 130% 130%, 220% 220%;
background-position:
center center,
calc(var(--pointer-from-left, 0.5) * 100%) calc(var(--pointer-from-top, 0.5) * 100%);
mix-blend-mode: plus-lighter;
opacity: 0.22;
}
.holo-shine-secondary[data-holo="reverse-holo"] {
background-image: radial-gradient(
circle at calc(100% - var(--pointer-x, 50%)) calc(100% - var(--pointer-y, 50%)),
hsla(52, 100%, 80%, 0.28) 0%,
hsla(175, 90%, 75%, 0.12) 40%,
transparent 70%
);
mix-blend-mode: plus-lighter;
opacity: 0.26;
}
/* =========================================================
3) COSMOS HOLOFOIL (3-Layer aus cosmos-holo.css:
cosmos-bottom + cosmos-middle-trans + cosmos-top-trans
mit Parallax-Versatz + 82deg-Regenbogen-Gradient)
========================================================= */
.holo-shine[data-holo="cosmos"] {
--space: 4%;
background-image:
url("/holo/cosmos-bottom.png"),
repeating-linear-gradient(
82deg,
hsl(53, 65%, 60%) calc(var(--space) * 1),
hsl(93, 56%, 50%) calc(var(--space) * 2),
hsl(176, 54%, 49%) calc(var(--space) * 3),
hsl(228, 59%, 55%) calc(var(--space) * 4),
hsl(283, 60%, 55%) calc(var(--space) * 5),
hsl(326, 59%, 51%) calc(var(--space) * 6),
hsl(326, 59%, 51%) calc(var(--space) * 7),
hsl(283, 60%, 55%) calc(var(--space) * 8),
hsl(228, 59%, 55%) calc(var(--space) * 9),
hsl(176, 54%, 49%) calc(var(--space) * 10),
hsl(93, 56%, 50%) calc(var(--space) * 11),
hsl(53, 65%, 60%) calc(var(--space) * 12)
),
radial-gradient(
farthest-corner circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
hsla(180, 100%, 89%, 0.6) 5%,
hsla(180, 14%, 57%, 0.3) 40%,
hsl(0, 0%, 0%) 120%
);
background-blend-mode: color-burn, multiply;
background-position:
center center,
calc(10% + (var(--pointer-from-left, 0.5) * 80%)) calc(10% + (var(--pointer-from-top, 0.5) * 80%)),
center center;
background-size: cover, 400% 900%, cover;
mix-blend-mode: plus-lighter;
opacity: 0.42;
}
.holo-shine-secondary[data-holo="cosmos"] {
--space: 4%;
background-image:
url("/holo/cosmos-middle-trans.png"),
url("/holo/cosmos-top-trans.png"),
repeating-linear-gradient(
82deg,
hsl(53, 65%, 60%) calc(var(--space) * 1),
hsl(93, 56%, 50%) calc(var(--space) * 2),
hsl(176, 54%, 49%) calc(var(--space) * 3),
hsl(228, 59%, 55%) calc(var(--space) * 4),
hsl(283, 60%, 55%) calc(var(--space) * 5),
hsl(326, 59%, 51%) calc(var(--space) * 6),
hsl(326, 59%, 51%) calc(var(--space) * 7),
hsl(283, 60%, 55%) calc(var(--space) * 8),
hsl(228, 59%, 55%) calc(var(--space) * 9),
hsl(176, 54%, 49%) calc(var(--space) * 10),
hsl(93, 56%, 50%) calc(var(--space) * 11),
hsl(53, 65%, 60%) calc(var(--space) * 12)
);
background-blend-mode: lighten, multiply;
background-position:
center center,
center center,
calc(18% + (var(--pointer-from-left, 0.5) * 64%)) calc(18% + (var(--pointer-from-top, 0.5) * 64%));
background-size: cover, cover, 400% 900%;
mix-blend-mode: plus-lighter;
opacity: 0.38;
}
/* =========================================================
3b) GALAXY HOLOFOIL (Classic galaxy.jpg + Cosmos-Ringe)
========================================================= */
.holo-shine[data-holo="galaxy"] {
--space: 4%;
background-image:
url("/holo/galaxy.jpg"),
repeating-linear-gradient(
82deg,
hsl(53, 65%, 60%) calc(var(--space) * 1),
hsl(93, 56%, 50%) calc(var(--space) * 2),
hsl(176, 54%, 49%) calc(var(--space) * 3),
hsl(228, 59%, 55%) calc(var(--space) * 4),
hsl(283, 60%, 55%) calc(var(--space) * 5),
hsl(326, 59%, 51%) calc(var(--space) * 6),
hsl(326, 59%, 51%) calc(var(--space) * 7),
hsl(283, 60%, 55%) calc(var(--space) * 8),
hsl(228, 59%, 55%) calc(var(--space) * 9),
hsl(176, 54%, 49%) calc(var(--space) * 10),
hsl(93, 56%, 50%) calc(var(--space) * 11),
hsl(53, 65%, 60%) calc(var(--space) * 12)
);
background-blend-mode: color-burn;
background-position:
center center,
calc(12% + (var(--pointer-from-left, 0.5) * 76%)) calc(12% + (var(--pointer-from-top, 0.5) * 76%));
background-size: cover, 400% 900%;
mix-blend-mode: plus-lighter;
opacity: 0.44;
}
.holo-shine-secondary[data-holo="galaxy"] {
background-image:
url("/holo/cosmos-middle-trans.png"),
var(--holo-spectrum);
background-blend-mode: multiply;
background-position:
center center,
calc(20% + (var(--pointer-from-left, 0.5) * 60%)) calc(20% + (var(--pointer-from-top, 0.5) * 60%));
background-size: cover, 300% 300%;
mix-blend-mode: plus-lighter;
opacity: 0.34;
}
/* =========================================================
4) AMAZING RARE (funkelnder Glitzer + Sunpillar-Wellen)
========================================================= */
.holo-shine[data-holo="amazing-rare"] {
background-image: var(--holo-spectrum);
background-size: 320% 320%;
background-position: calc(50% + (50% - var(--background-x, 50%)) * 2.2) calc(50% + (50% - var(--background-y, 50%)) * 2.2);
mix-blend-mode: plus-lighter;
opacity: 0.18;
}
.holo-shine-secondary[data-holo="amazing-rare"] {
background-image: var(--glitter-dots);
background-size: 40px 40px, 48px 48px, 36px 36px, 54px 54px, 44px 44px, 32px 32px;
background-position:
calc(var(--pointer-x, 50%) * 0.35) calc(var(--pointer-y, 50%) * 0.35),
calc(var(--pointer-x, 50%) * -0.25) calc(var(--pointer-y, 50%) * 0.3),
calc(var(--pointer-x, 50%) * 0.2) calc(var(--pointer-y, 50%) * -0.25),
center center,
calc(var(--pointer-x, 50%) * -0.3) calc(var(--pointer-y, 50%) * -0.2),
center center;
mix-blend-mode: plus-lighter;
opacity: 0.55;
}
/* =========================================================
5) RADIANT CROSSHATCH (weiches 45° / -45° Diamant-Schimmern)
========================================================= */
.holo-shine[data-holo="radiant"] {
background-image:
var(--holo-spectrum),
repeating-linear-gradient(
45deg,
#000 0px,
#444 7px,
#fff 14px,
#444 21px,
#000 28px
);
background-size: 300% 300%, 200% 200%;
background-position:
calc(((var(--background-x, 50%) - 50%) * -2) + 50%) calc(((var(--background-y, 50%) - 50%) * -2) + 50%),
calc(((var(--background-x, 50%) - 50%) * 1.4) + 50%) calc(((var(--background-y, 50%) - 50%) * 1.4) + 50%);
background-blend-mode: multiply;
mix-blend-mode: plus-lighter;
opacity: 0.24;
}
.holo-shine-secondary[data-holo="radiant"] {
background-image:
var(--holo-spectrum),
repeating-linear-gradient(
-45deg,
#000 0px,
#444 7px,
#fff 14px,
#444 21px,
#000 28px
);
background-size: 300% 300%, 200% 200%;
background-position:
calc(((var(--background-x, 50%) - 50%) * 1.8) + 50%) calc(((var(--background-y, 50%) - 50%) * 1.8) + 50%),
calc(((var(--background-x, 50%) - 50%) * -1.4) + 50%) calc(((var(--background-y, 50%) - 50%) * -1.4) + 50%);
background-blend-mode: multiply;
mix-blend-mode: plus-lighter;
opacity: 0.22;
}
/* =========================================================
6) HOLO V (weiche 133°-Lichtbänder + Sunpillars)
========================================================= */
.holo-shine[data-holo="v-holo"] {
background-image:
var(--holo-spectrum),
linear-gradient(
133deg,
#000 0%,
#000 26%,
#d8f3f3 36%,
#000 46%,
#000 64%,
#ffffff 74%,
#000 84%,
#000 100%
);
background-blend-mode: multiply;
background-size: 250% 250%, 240% 240%;
background-position:
0% var(--background-y, 50%),
var(--background-x, 50%) var(--background-y, 50%);
mix-blend-mode: plus-lighter;
opacity: 0.34;
}
.holo-shine-secondary[data-holo="v-holo"] {
background-image: linear-gradient(
133deg,
transparent 15%,
hsla(185, 100%, 86%, 0.38) 38%,
transparent 52%,
hsla(300, 100%, 86%, 0.32) 72%,
transparent 88%
);
background-size: 220% 220%;
background-position: calc(100% - var(--background-x, 50%)) calc(100% - var(--background-y, 50%));
mix-blend-mode: plus-lighter;
opacity: 0.28;
}
/* =========================================================
7) FULL ART ULTRA (weicher Sunpillar-Glanz + Radial-Halo)
========================================================= */
.holo-shine[data-holo="v-full-art"] {
background-image: var(--holo-spectrum);
background-size: 260% 260%;
background-position: calc(var(--background-x, 50%) + (var(--background-y, 50%) * 0.2)) var(--background-y, 50%);
mix-blend-mode: plus-lighter;
opacity: 0.18;
}
.holo-shine-secondary[data-holo="v-full-art"] {
background-image: radial-gradient(
circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
hsla(195, 100%, 88%, 0.38) 0%,
hsla(280, 90%, 80%, 0.18) 32%,
transparent 68%
);
mix-blend-mode: plus-lighter;
opacity: 0.32;
}
/* =========================================================
8) VMAX (-33° / 133° Prismatic-Schimmer + Glitzer)
========================================================= */
.holo-shine[data-holo="v-max"] {
background-image:
linear-gradient(
-33deg,
hsl(2, 88%, 62%) 0%,
hsl(228, 85%, 70%) 20%,
hsl(176, 82%, 58%) 40%,
hsl(123, 80%, 56%) 60%,
hsl(283, 88%, 66%) 80%,
hsl(2, 88%, 62%) 100%
),
linear-gradient(
133deg,
#000 0%,
#000 28%,
#c8f4f4 48%,
#000 68%,
#000 100%
);
background-blend-mode: multiply;
background-size: 280% 280%, 240% 240%;
background-position:
var(--background-x, 50%) var(--background-y, 50%),
calc(var(--background-x, 50%) * 1.2) var(--background-y, 50%);
mix-blend-mode: plus-lighter;
opacity: 0.3;
}
.holo-shine-secondary[data-holo="v-max"] {
background-image: var(--glitter-dots);
background-size: 46px 46px, 56px 56px, 40px 40px, 62px 62px, 50px 50px, 36px 36px;
background-position:
calc(var(--pointer-x, 50%) * 0.4) calc(var(--pointer-y, 50%) * 0.4),
calc(var(--pointer-x, 50%) * -0.3) calc(var(--pointer-y, 50%) * 0.35),
center center,
center center,
calc(var(--pointer-x, 50%) * 0.25) calc(var(--pointer-y, 50%) * -0.25),
center center;
mix-blend-mode: plus-lighter;
opacity: 0.48;
}
/* =========================================================
9) VSTAR (Radialer Halo-Ring + Conic-Strahlen)
========================================================= */
.holo-shine[data-holo="v-star"] {
background-image:
var(--holo-spectrum),
conic-gradient(
from calc(var(--pointer-from-left, 0.5) * 120deg) at var(--pointer-x, 50%) var(--pointer-y, 50%),
#000 0deg,
#fff 30deg,
#000 60deg,
#fff 120deg,
#000 180deg,
#fff 240deg,
#000 300deg,
#fff 330deg,
#000 360deg
);
background-blend-mode: multiply;
background-size: 260% 260%, cover;
background-position: 0% var(--background-y, 50%), center center;
mix-blend-mode: plus-lighter;
opacity: 0.22;
}
.holo-shine-secondary[data-holo="v-star"] {
background-image: radial-gradient(
circle at var(--pointer-x, 50%) var(--pointer-y, 50%),
transparent 10%,
hsla(185, 100%, 82%, 0.32) 26%,
hsla(305, 100%, 82%, 0.24) 38%,
transparent 58%
);
mix-blend-mode: plus-lighter;
opacity: 0.42;
}
/* =========================================================
10) TRAINER GALLERY (sanfte -22°-Pastell-Regenbogenwellen)
========================================================= */
.holo-shine[data-holo="trainer-gallery"] {
background-image: linear-gradient(
-22deg,
hsla(283, 75%, 68%, 0.85) 0%,
hsla(2, 85%, 66%, 0.85) 16%,
hsla(53, 85%, 62%, 0.85) 33%,
hsla(93, 75%, 60%, 0.85) 50%,
hsla(176, 70%, 60%, 0.85) 66%,
hsla(228, 100%, 78%, 0.85) 83%,
hsla(283, 75%, 68%, 0.85) 100%
);
background-size: 280% 280%;
background-position: var(--background-x, 50%) var(--background-y, 50%);
mix-blend-mode: plus-lighter;
opacity: 0.16;
}
.holo-shine-secondary[data-holo="trainer-gallery"] {
background-image: radial-gradient(
farthest-corner ellipse at calc((var(--pointer-x, 50%) * 0.5) + 25%) calc((var(--pointer-y, 50%) * 0.5) + 25%),
hsla(0, 0%, 100%, 0.32) 0%,
hsla(300, 90%, 75%, 0.14) 38%,
transparent 72%
);
mix-blend-mode: plus-lighter;
opacity: 0.38;
}
/* =========================================================
11) RAINBOW SECRET (weiches Regenbogen-Prisma + Glitzer)
========================================================= */
.holo-shine[data-holo="rainbow"] {
background-image: linear-gradient(
-35deg,
hsl(0, 90%, 66%) 0%,
hsl(42, 95%, 64%) 16%,
hsl(98, 85%, 60%) 33%,
hsl(178, 90%, 60%) 50%,
hsl(220, 92%, 68%) 66%,
hsl(285, 88%, 68%) 83%,
hsl(0, 90%, 66%) 100%
);
background-size: 320% 320%;
background-position: calc(25% + (var(--pointer-x, 50%) / 2)) calc(25% + (var(--pointer-y, 50%) / 2));
mix-blend-mode: plus-lighter;
opacity: 0.2;
}
.holo-shine-secondary[data-holo="rainbow"] {
background-image: var(--glitter-dots);
background-size: 42px 42px, 52px 52px, 38px 38px, 58px 58px, 46px 46px, 34px 34px;
background-position:
calc(var(--pointer-x, 50%) * 0.3) calc(var(--pointer-y, 50%) * 0.3),
calc(var(--pointer-x, 50%) * -0.25) calc(var(--pointer-y, 50%) * 0.25),
center center,
center center,
calc(var(--pointer-x, 50%) * 0.2) calc(var(--pointer-y, 50%) * -0.2),
center center;
mix-blend-mode: plus-lighter;
opacity: 0.55;
}
/* =========================================================
12) GOLD SECRET RARE (Conic-Sunpillar + Gold-Glitzer)
========================================================= */
.holo-shine[data-holo="gold"] {
background-image: conic-gradient(
from calc(var(--pointer-from-left, 0.5) * 180deg) at var(--pointer-x, 50%) var(--pointer-y, 50%),
hsl(46, 100%, 66%),
hsl(36, 100%, 58%),
hsl(54, 100%, 76%),
hsl(176, 85%, 70%),
hsl(46, 100%, 66%)
);
background-size: cover;
background-position: center center;
mix-blend-mode: plus-lighter;
opacity: 0.18;
}
.holo-shine-secondary[data-holo="gold"] {
background-image:
var(--glitter-dots),
linear-gradient(
45deg,
transparent 28%,
hsla(48, 100%, 70%, 0.32) 46%,
hsla(54, 100%, 88%, 0.5) 50%,
hsla(44, 100%, 64%, 0.32) 54%,
transparent 72%
);
background-size: 40px 40px, 50px 50px, 36px 36px, 54px 54px, 44px 44px, 32px 32px, 240% 240%;
background-position:
calc(var(--pointer-x, 50%) * 0.35) calc(var(--pointer-y, 50%) * 0.35),
calc(var(--pointer-x, 50%) * -0.25) calc(var(--pointer-y, 50%) * 0.25),
center center,
center center,
calc(var(--pointer-x, 50%) * 0.2) calc(var(--pointer-y, 50%) * -0.2),
center center,
var(--pointer-x, 50%) var(--pointer-y, 50%);
mix-blend-mode: plus-lighter;
opacity: 0.52;
}
</style>
@@ -1,6 +1,8 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { ChevronRight } from '@lucide/vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
@@ -16,7 +18,7 @@ const props = defineProps<{
:class="cn('[&>svg]:size-3.5', props.class)"
>
<slot>
<Icon name="ph:caret-right-fill" class="cn-rtl-flip" />
<ChevronRight class="cn-rtl-flip" />
</slot>
</li>
</template>
@@ -131,7 +131,7 @@ watch(() => props.color, calculateContrastRatios, { immediate: true, deep: true
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" />
<Icon name="ph:x-bold" v-else class="size-3" />
AA
</Badge>
<Badge
@@ -139,7 +139,7 @@ watch(() => props.color, calculateContrastRatios, { immediate: true, deep: true
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" />
<Icon name="ph:x-bold" v-else class="size-3" />
AAA
</Badge>
</div>
+1 -1
View File
@@ -43,7 +43,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
as-child
>
<Button variant="ghost" class="absolute top-4 right-4 bg-secondary" size="icon-sm">
<Icon name="ph:x-fill" />
<Icon name="ph:x-bold" />
<span class="sr-only">{{ $t('common.close') }}</span>
</Button>
</DialogClose>
@@ -50,7 +50,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<Icon name="ph:x-fill" class="w-4 h-4" />
<Icon name="ph:x-bold" class="w-4 h-4" />
<span class="sr-only">{{ $t('common.close') }}</span>
</DialogClose>
</DialogContent>
@@ -23,7 +23,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-pointer items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
@@ -24,7 +24,7 @@ const forwardedProps = useForwardProps(delegatedProps)
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm px-2 py-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm px-2 py-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-pointer items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
>
<slot />
</DropdownMenuItem>
@@ -24,7 +24,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-pointer items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
@@ -21,7 +21,7 @@ const forwardedProps = useForwardProps(delegatedProps)
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm px-2 py-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 min-h-7 rounded-sm px-2 py-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-pointer items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { NumberFieldRootEmits, NumberFieldRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { NumberFieldRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<NumberFieldRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<NumberFieldRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<NumberFieldRoot v-slot="slotProps" v-bind="forwarded" :class="cn('grid gap-1.5', props.class)">
<slot v-bind="slotProps" />
</NumberFieldRoot>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('relative [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-5 [&>[data-slot=input]]:has-[[data-slot=decrement]]:pl-5', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { NumberFieldDecrementProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { MinusIcon } from '@lucide/vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { NumberFieldDecrement, useForwardProps } from 'reka-ui'
const props = defineProps<NumberFieldDecrementProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<NumberFieldDecrement data-slot="decrement" v-bind="forwarded" :class="cn('absolute top-1/2 -translate-y-1/2 left-0 p-3 disabled:cursor-not-allowed disabled:opacity-20', props.class)">
<slot>
<MinusIcon class="h-4 w-4" />
</slot>
</NumberFieldDecrement>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { NumberFieldIncrementProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { PlusIcon } from '@lucide/vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { NumberFieldIncrement, useForwardProps } from 'reka-ui'
const props = defineProps<NumberFieldIncrementProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<NumberFieldIncrement data-slot="increment" v-bind="forwarded" :class="cn('absolute top-1/2 -translate-y-1/2 right-0 disabled:cursor-not-allowed disabled:opacity-20 p-3', props.class)">
<slot>
<PlusIcon class="h-4 w-4" />
</slot>
</NumberFieldIncrement>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { NumberFieldInput } from 'reka-ui'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<NumberFieldInput
data-slot="input"
:class="cn(
'bg-input/50 border-transparent 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 h-8 rounded-md border px-2.5 py-1 text-base transition-[color,box-shadow] duration-200 file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 text-center outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
/>
</template>
+5
View File
@@ -0,0 +1,5 @@
export { default as NumberField } from './NumberField.vue'
export { default as NumberFieldContent } from './NumberFieldContent.vue'
export { default as NumberFieldDecrement } from './NumberFieldDecrement.vue'
export { default as NumberFieldIncrement } from './NumberFieldIncrement.vue'
export { default as NumberFieldInput } from './NumberFieldInput.vue'
@@ -0,0 +1,62 @@
<script lang="ts" setup>
import type { RangeCalendarRootEmits, RangeCalendarRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { RangeCalendarRoot, useForwardPropsEmits } from 'reka-ui'
import { RangeCalendarCell, RangeCalendarCellTrigger, RangeCalendarGrid, RangeCalendarGridBody, RangeCalendarGridHead, RangeCalendarGridRow, RangeCalendarHeadCell, RangeCalendarHeader, RangeCalendarHeading, RangeCalendarNextButton, RangeCalendarPrevButton } from '.'
const props = defineProps<RangeCalendarRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<RangeCalendarRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<RangeCalendarRoot
v-slot="{ grid, weekDays }"
data-slot="range-calendar"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<RangeCalendarHeader>
<RangeCalendarHeading />
<div class="flex items-center gap-1">
<RangeCalendarPrevButton />
<RangeCalendarNextButton />
</div>
</RangeCalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<RangeCalendarGrid v-for="month in grid" :key="month.value.toString()">
<RangeCalendarGridHead>
<RangeCalendarGridRow>
<RangeCalendarHeadCell
v-for="day in weekDays" :key="day"
>
{{ day }}
</RangeCalendarHeadCell>
</RangeCalendarGridRow>
</RangeCalendarGridHead>
<RangeCalendarGridBody>
<RangeCalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<RangeCalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<RangeCalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
</RangeCalendarCell>
</RangeCalendarGridRow>
</RangeCalendarGridBody>
</RangeCalendarGrid>
</div>
</RangeCalendarRoot>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { RangeCalendarCellProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { RangeCalendarCell, useForwardProps } from 'reka-ui'
const props = defineProps<RangeCalendarCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarCell
data-slot="range-calendar-cell"
:class="cn('relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:bg-accent first:[&:has([data-selected])]:rounded-l-md last:[&:has([data-selected])]:rounded-r-md [&:has([data-selected][data-selection-end])]:rounded-r-md [&:has([data-selected][data-selection-start])]:rounded-l-md', props.class)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarCell>
</template>
@@ -0,0 +1,41 @@
<script lang="ts" setup>
import type { RangeCalendarCellTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { RangeCalendarCellTrigger, useForwardProps } from 'reka-ui'
import { buttonVariants } from '@/components/ui/button'
const props = withDefaults(defineProps<RangeCalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>(), {
as: 'button',
})
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarCellTrigger
data-slot="range-calendar-trigger"
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal data-[selected]:opacity-100',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selection Start
'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',
// Selection End
'data-[selection-end]:bg-primary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-primary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground',
// Outside months
'data-[outside-view]:text-muted-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarCellTrigger>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { RangeCalendarGridProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { RangeCalendarGrid, useForwardProps } from 'reka-ui'
const props = defineProps<RangeCalendarGridProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarGrid
data-slot="range-calendar-grid"
:class="cn('w-full border-collapse space-x-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarGrid>
</template>
@@ -0,0 +1,15 @@
<script lang="ts" setup>
import type { RangeCalendarGridBodyProps } from 'reka-ui'
import { RangeCalendarGridBody } from 'reka-ui'
const props = defineProps<RangeCalendarGridBodyProps>()
</script>
<template>
<RangeCalendarGridBody
data-slot="range-calendar-grid-body"
v-bind="props"
>
<slot />
</RangeCalendarGridBody>
</template>
@@ -0,0 +1,15 @@
<script lang="ts" setup>
import type { RangeCalendarGridHeadProps } from 'reka-ui'
import { RangeCalendarGridHead } from 'reka-ui'
const props = defineProps<RangeCalendarGridHeadProps>()
</script>
<template>
<RangeCalendarGridHead
data-slot="range-calendar-grid-head"
v-bind="props"
>
<slot />
</RangeCalendarGridHead>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { RangeCalendarGridRowProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { RangeCalendarGridRow, useForwardProps } from 'reka-ui'
const props = defineProps<RangeCalendarGridRowProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarGridRow
data-slot="range-calendar-grid-row"
:class="cn('flex', props.class)" v-bind="forwardedProps"
>
<slot />
</RangeCalendarGridRow>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { RangeCalendarHeadCellProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { RangeCalendarHeadCell, useForwardProps } from 'reka-ui'
const props = defineProps<RangeCalendarHeadCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarHeadCell
data-slot="range-calendar-head-cell"
:class="cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarHeadCell>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { RangeCalendarHeaderProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { RangeCalendarHeader, useForwardProps } from 'reka-ui'
const props = defineProps<RangeCalendarHeaderProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarHeader
data-slot="range-calendar-header"
:class="cn('flex justify-center pt-1 relative items-center w-full', props.class)"
v-bind="forwardedProps"
>
<slot />
</RangeCalendarHeader>
</template>
@@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { RangeCalendarHeadingProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { RangeCalendarHeading, useForwardProps } from 'reka-ui'
const props = defineProps<RangeCalendarHeadingProps & { class?: HTMLAttributes['class'] }>()
defineSlots<{
default: (props: { headingValue: string }) => any
}>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarHeading
v-slot="{ headingValue }"
data-slot="range-calendar-heading"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</RangeCalendarHeading>
</template>
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import type { RangeCalendarNextProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { ChevronRightIcon } from '@lucide/vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { RangeCalendarNext, useForwardProps } from 'reka-ui'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<RangeCalendarNextProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarNext
data-slot="range-calendar-next-button"
:class="cn(
buttonVariants({ variant: 'outline' }),
'absolute right-1',
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronRightIcon class="size-4" />
</slot>
</RangeCalendarNext>
</template>
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import type { RangeCalendarPrevProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { ChevronLeftIcon } from '@lucide/vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { RangeCalendarPrev, useForwardProps } from 'reka-ui'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<RangeCalendarPrevProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RangeCalendarPrev
data-slot="range-calendar-prev-button"
:class="cn(
buttonVariants({ variant: 'outline' }),
'absolute left-1',
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronLeftIcon class="size-4" />
</slot>
</RangeCalendarPrev>
</template>
+12
View File
@@ -0,0 +1,12 @@
export { default as RangeCalendar } from './RangeCalendar.vue'
export { default as RangeCalendarCell } from './RangeCalendarCell.vue'
export { default as RangeCalendarCellTrigger } from './RangeCalendarCellTrigger.vue'
export { default as RangeCalendarGrid } from './RangeCalendarGrid.vue'
export { default as RangeCalendarGridBody } from './RangeCalendarGridBody.vue'
export { default as RangeCalendarGridHead } from './RangeCalendarGridHead.vue'
export { default as RangeCalendarGridRow } from './RangeCalendarGridRow.vue'
export { default as RangeCalendarHeadCell } from './RangeCalendarHeadCell.vue'
export { default as RangeCalendarHeader } from './RangeCalendarHeader.vue'
export { default as RangeCalendarHeading } from './RangeCalendarHeading.vue'
export { default as RangeCalendarNextButton } from './RangeCalendarNextButton.vue'
export { default as RangeCalendarPrevButton } from './RangeCalendarPrevButton.vue'
+1 -1
View File
@@ -51,7 +51,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
as-child
>
<Button variant="ghost" class="absolute top-4 right-4 bg-secondary" size="icon-sm">
<Icon name="ph:x-fill" />
<Icon name="ph:x-bold" />
<span class="sr-only">{{ $t('common.close') }}</span>
</Button>
</DialogClose>
+1 -1
View File
@@ -51,7 +51,7 @@ const delegatedProps = reactiveOmit(props, 'class', 'toastOptions')
</div>
</template>
<template #close-icon>
<Icon name="ph:x-fill" class="size-4" />
<Icon name="ph:x-bold" class="size-4" />
</template>
</Sonner>
</template>
+2 -2
View File
@@ -16,9 +16,9 @@ const forwardedProps = useForwardProps(delegatedProps)
<TabsTrigger
data-slot="tabs-trigger"
:class="cn(
'gap-1.5 rounded-2xl border border-transparent! px-1.5 py-0.5 text-sm font-medium group-data-vertical/tabs:px-3 group-data-vertical/tabs:py-0.5 [&_svg:not([class*=size-])]:size-4 relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
'gap-1.5 rounded-md border border-transparent! px-1.5 py-0.5 text-sm font-medium group-data-vertical/tabs:px-3 group-data-vertical/tabs:py-0.5 [&_svg:not([class*=size-])]:size-4 relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'data-active:bg-muted data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
props.class,
)"
+2 -2
View File
@@ -7,11 +7,11 @@ export { default as TabsList } from './TabsList.vue'
export { default as TabsTrigger } from './TabsTrigger.vue'
export const tabsListVariants = cva(
'rounded-2xl p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group-data-vertical/tabs:p-1 group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col',
'rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group-data-vertical/tabs:p-1 group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col',
{
variants: {
variant: {
default: 'bg-muted',
default: 'bg-card',
line: 'gap-1 bg-transparent',
},
},
+1 -1
View File
@@ -24,7 +24,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="cn('data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3.5 py-2 text-sm has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg bg-popover text-popover-foreground border z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)', props.class)"
:class="cn('data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3.5 py-2 text-sm has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg bg-popover text-popover-foreground border z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin) pointer-events-none', props.class)"
>
<slot />
+5
View File
@@ -1,14 +1,19 @@
export interface AuthUser {
id: number
name: string
displayName: string | null
email: string | null
color: string | null
bio: string | null
cardEffect: string | null
cardEffectColor: string | null
cardVignette: boolean
cardBorderEffect: string | null
cardBorderEffectColor: string | null
cardHoloEffect: string | null
avatarUrl: string | null
cardBannerUrl: string | null
cardBannerType: 'image' | 'video' | null
roles: string[]
permissions: string[]
}
+9 -5
View File
@@ -3,6 +3,8 @@ export interface CommentReaction {
count: number
me: boolean
names: string[]
// Sticker-Reactions tragen den Sticker-Namen in emoji
sticker?: boolean
}
export interface ReactionEventPayload {
@@ -12,6 +14,7 @@ export interface ReactionEventPayload {
actorName: string
active: boolean
count: number
sticker?: boolean
}
export function useCommentReactions() {
@@ -40,7 +43,7 @@ export function useCommentReactions() {
let entry = list.find(r => r.emoji === e.emoji)
if (e.active) {
if (!entry) {
entry = { emoji: e.emoji, count: 0, me: false, names: [] }
entry = { emoji: e.emoji, count: 0, me: false, names: [], sticker: e.sticker }
list.push(entry)
}
if (!entry.names.includes(e.actorName)) {
@@ -60,7 +63,7 @@ export function useCommentReactions() {
}
}
async function toggle(commentId: number, emoji: string) {
async function toggle(commentId: number, emoji: string, sticker = false) {
const me = user.value
const before = map.value[commentId]?.find(r => r.emoji === emoji)
// Optimistisch umschalten - die Reaktion ist sofort sichtbar,
@@ -73,20 +76,21 @@ export function useCommentReactions() {
actorName: me.name,
active: !before?.me,
count: (before?.count ?? 0) + (before?.me ? -1 : 1),
sticker,
})
}
try {
const res = await $fetch<{ active: boolean, count: number }>(`/api/comments/${commentId}/reactions`, {
method: 'POST',
body: { emoji },
body: { emoji, sticker },
})
if (me) {
applyEvent({ commentId, emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count })
applyEvent({ commentId, emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count, sticker })
}
}
catch (err) {
if (me) {
applyEvent({ commentId, emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0 })
applyEvent({ commentId, emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0, sticker })
}
throw err
}
+177 -22
View File
@@ -19,13 +19,34 @@ interface DmGroupReadEvent {
at: string
}
export interface FloatingChatTab {
// 'u<id>' für 1:1-Chats, 'g<id>' für Gruppen - deckt sich mit dem URL-Schema
key: string
user: DmUser | null
group: DmGroup | null
minimized: boolean
}
type MessageListener = (msg: DmMessage, other: DmUser | null, group: DmGroup | null) => void
type ReadListener = (partnerId: number, at: string) => void
type ReactionListener = (e: DmReactionEvent) => void
type GroupListener = (group: DmGroup) => void
// Direkt-Callbacks der offenen Chat-Ansicht - Modul-Singletons, weil
// Plugin (WS-Event) und Page (Registrierung) verschiedene Composable-Aufrufe nutzen
const liveMessageHandler = shallowRef<((msg: DmMessage, other: DmUser | null, group: DmGroup | null) => void) | null>(null)
const liveReadHandler = shallowRef<((partnerId: number, at: string) => void) | null>(null)
const liveReactionHandler = shallowRef<((e: DmReactionEvent) => void) | null>(null)
const liveMessageHandler = shallowRef<MessageListener | null>(null)
const liveReadHandler = shallowRef<ReadListener | null>(null)
const liveReactionHandler = shallowRef<ReactionListener | null>(null)
const liveGroupHandler = shallowRef<((group: DmGroup) => void) | null>(null)
// Zusätzliche Listener für schwebende Messenger-Fenster (mehrere gleichzeitig möglich)
const floatingMessageListeners = new Set<MessageListener>()
const floatingReadListeners = new Set<ReadListener>()
const floatingReactionListeners = new Set<ReactionListener>()
const floatingGroupListeners = new Set<GroupListener>()
const MAX_FLOATING_CHATS = 3
export function useDirectMessages() {
const { user } = useAuth()
const conversations = useState<DmConversation[]>('dm:conversations', () => [])
@@ -33,6 +54,83 @@ export function useDirectMessages() {
// Aktuell geöffnete Konversation (messages-Seite setzt/löscht das)
const openPartnerId = useState<number | null>('dm:open', () => null)
const openGroupId = useState<number | null>('dm:open-group', () => null)
// Schwebende Chat-Fenster (Messenger-Stil) - 1:1-Chats und Gruppen
const floatingChats = useState<FloatingChatTab[]>('dm:floating', () => [])
function isPartnerOpen(partnerId: number): boolean {
if (openPartnerId.value === partnerId) return true
return floatingChats.value.some(c => c.user?.id === partnerId && !c.minimized)
}
function isGroupOpen(groupId: number): boolean {
if (openGroupId.value === groupId) return true
return floatingChats.value.some(c => c.group?.id === groupId && !c.minimized)
}
function pushFloatingTab(tab: FloatingChatTab, minimized: boolean) {
const existing = floatingChats.value.find(c => c.key === tab.key)
if (existing) {
existing.user = tab.user
existing.group = tab.group
existing.minimized = minimized
return
}
const next = [...floatingChats.value, tab]
if (next.length > MAX_FLOATING_CHATS) next.shift()
floatingChats.value = next
}
function openFloatingChat(partner: DmUser, minimized = false) {
if (!user.value || partner.id === user.value.id) return
pushFloatingTab({ key: `u${partner.id}`, user: partner, group: null, minimized }, minimized)
}
function openFloatingGroup(group: DmGroup, minimized = false) {
if (!user.value) return
pushFloatingTab({ key: `g${group.id}`, user: null, group, minimized }, minimized)
}
function toggleMinimizeFloatingChat(key: string) {
const tab = floatingChats.value.find(c => c.key === key)
if (tab) tab.minimized = !tab.minimized
}
function closeFloatingChat(key: string) {
floatingChats.value = floatingChats.value.filter(c => c.key !== key)
}
function subscribeFloating(handlers: {
onMessage?: MessageListener
onRead?: ReadListener
onReaction?: ReactionListener
onGroup?: GroupListener
}) {
if (handlers.onMessage) floatingMessageListeners.add(handlers.onMessage)
if (handlers.onRead) floatingReadListeners.add(handlers.onRead)
if (handlers.onReaction) floatingReactionListeners.add(handlers.onReaction)
if (handlers.onGroup) floatingGroupListeners.add(handlers.onGroup)
return () => {
if (handlers.onMessage) floatingMessageListeners.delete(handlers.onMessage)
if (handlers.onRead) floatingReadListeners.delete(handlers.onRead)
if (handlers.onReaction) floatingReactionListeners.delete(handlers.onReaction)
if (handlers.onGroup) floatingGroupListeners.delete(handlers.onGroup)
}
}
function notifyMessageListeners(msg: DmMessage, other: DmUser | null, group: DmGroup | null) {
liveMessageHandler.value?.(msg, other, group)
for (const fn of floatingMessageListeners) fn(msg, other, group)
}
function notifyReadListeners(partnerId: number, at: string) {
liveReadHandler.value?.(partnerId, at)
for (const fn of floatingReadListeners) fn(partnerId, at)
}
function notifyReactionListeners(e: DmReactionEvent) {
liveReactionHandler.value?.(e)
for (const fn of floatingReactionListeners) fn(e)
}
async function fetchConversations() {
const res = await $fetch<{ conversations: DmConversation[], unreadTotal: number }>('/api/messages')
@@ -69,10 +167,10 @@ export function useDirectMessages() {
if (event.group) {
const g = event.group
const isMine = msg.senderId === user.value?.id
const isOpen = openGroupId.value === g.id
const isOpen = isGroupOpen(g.id)
upsertGroup(g, msg, isMine || isOpen ? 0 : 1)
if (!isMine && !isOpen) unreadTotal.value += 1
if (isOpen) liveMessageHandler.value?.(msg, null, g)
if (isOpen) notifyMessageListeners(msg, null, g)
return
}
@@ -80,19 +178,20 @@ export function useDirectMessages() {
const other = isForMe ? event.sender : event.recipient!
if (isForMe) {
const isOpen = openPartnerId.value === msg.senderId
// Wie beim Messenger: eingehende 1:1-Nachricht poppt automatisch als
// Floating-Tab auf, sofern man nicht schon in /messages mit dem User ist
if (openPartnerId.value !== msg.senderId && !floatingChats.value.some(c => c.user?.id === other.id)) {
openFloatingChat(other, false)
}
const isOpen = isPartnerOpen(msg.senderId)
upsertDm(other, msg, isOpen ? 0 : 1)
if (isOpen) {
liveMessageHandler.value?.(msg, other, null)
}
else {
unreadTotal.value += 1
}
if (!isOpen) unreadTotal.value += 1
notifyMessageListeners(msg, other, null)
}
else {
// Eigene Nachricht aus anderem Tab/Fenster synchronisieren
upsertDm(other, msg, 0)
if (openPartnerId.value === msg.recipientId) liveMessageHandler.value?.(msg, other, null)
notifyMessageListeners(msg, other, null)
}
}
@@ -102,8 +201,8 @@ export function useDirectMessages() {
const isMine = e.actorId === user.value?.id
if (e.group) {
const gid = e.group.id
if (openGroupId.value === gid) {
liveReactionHandler.value?.(e)
if (isGroupOpen(gid)) {
notifyReactionListeners(e)
return
}
if (isMine) return
@@ -114,11 +213,8 @@ export function useDirectMessages() {
return
}
const partnerId = isMine ? e.other!.id : e.actorId
if (openPartnerId.value === partnerId) {
liveReactionHandler.value?.(e)
return
}
if (isMine) return
notifyReactionListeners(e)
if (isPartnerOpen(partnerId) || isMine) return
const conv = conversations.value.find(c => c.kind === 'dm' && c.user?.id === partnerId)
const delta = e.active ? 1 : -1
if (conv) conv.unread = Math.max(0, conv.unread + delta)
@@ -136,7 +232,7 @@ export function useDirectMessages() {
}
else if (event.partner === user.value?.id) {
// Gegenüber hat meine Nachrichten gelesen
liveReadHandler.value?.(event.reader, event.at)
notifyReadListeners(event.reader, event.at)
const conv = conversations.value.find(c => c.kind === 'dm' && c.user?.id === event.reader)
if (conv?.lastMessage && conv.lastMessage.senderId === user.value?.id && !conv.lastMessage.readAt) {
conv.lastMessage.readAt = event.at
@@ -153,16 +249,63 @@ export function useDirectMessages() {
unreadTotal.value = Math.max(0, unreadTotal.value - delta)
}
// Offene Gruppen-Einladung: meine Mitgliedschaftszeile ist noch nicht
// akzeptiert - die Konversation zaehlt wie eine ungelesene Nachricht,
// der Verlauf bleibt aber bis zur Annahme verborgen
function isPendingInvite(group: DmGroup | null | undefined): boolean {
return !!group?.members.find(m => m.id === user.value?.id)?.pending
}
function removeGroupConversation(groupId: number) {
const conv = conversations.value.find(c => c.kind === 'group' && c.group?.id === groupId)
if (conv) unreadTotal.value = Math.max(0, unreadTotal.value - conv.unread)
conversations.value = conversations.value.filter(c => !(c.kind === 'group' && c.group?.id === groupId))
floatingChats.value = floatingChats.value.filter(c => c.group?.id !== groupId)
}
// Neue Gruppe bzw. geänderte Mitgliederliste - Konversation anlegen/updaten
// und eine geöffnete Ansicht informieren
function handleGroupUpdate(group: DmGroup) {
upsertGroup(group, null, 0)
const myEntry = group.members.find(m => m.id === user.value?.id)
if (!myEntry) {
// Kein Mitglied mehr (z.B. Einladung in einem anderen Tab abgelehnt)
removeGroupConversation(group.id)
if (openGroupId.value === group.id) liveGroupHandler.value?.(group)
for (const fn of floatingGroupListeners) fn(group)
return
}
const existing = conversations.value.find(c => c.kind === 'group' && c.group?.id === group.id)
const wasPending = !!existing?.group?.members.find(m => m.id === user.value?.id)?.pending
const isPending = !!myEntry.pending
upsertGroup(group, null, existing || !isPending ? 0 : 1)
if (!existing && isPending) {
unreadTotal.value += 1
}
else if (existing && wasPending && !isPending) {
// Einladung gerade angenommen - Invite-Badge weg, Vorgeschichte zaehlt nicht
const conv = conversations.value.find(c => c.kind === 'group' && c.group?.id === group.id)
const delta = conv?.unread ?? 0
if (conv) conv.unread = 0
unreadTotal.value = Math.max(0, unreadTotal.value - delta)
}
if (openGroupId.value === group.id) liveGroupHandler.value?.(group)
for (const fn of floatingGroupListeners) fn(group)
}
// Einladung annehmen/ablehnen - Server pusht danach das dm-group-Update
async function acceptGroupInvite(groupId: number) {
await $fetch(`/api/messages/groups/${groupId}/accept`, { method: 'POST' })
}
async function declineGroupInvite(groupId: number) {
await $fetch(`/api/messages/groups/${groupId}/decline`, { method: 'POST' })
removeGroupConversation(groupId)
}
// Optimistisch: eigene gesendete Nachricht sofort in die Liste
function addOwnMessage(partner: DmUser, msg: DmMessage) {
upsertDm(partner, msg, 0)
notifyMessageListeners(msg, partner, null)
}
function addOwnGroupMessage(group: DmGroup, msg: DmMessage) {
@@ -190,6 +333,7 @@ export function useDirectMessages() {
unreadTotal.value = 0
openPartnerId.value = null
openGroupId.value = null
floatingChats.value = []
liveMessageHandler.value = null
liveReadHandler.value = null
liveReactionHandler.value = null
@@ -201,12 +345,23 @@ export function useDirectMessages() {
unreadTotal,
openPartnerId,
openGroupId,
floatingChats,
openFloatingChat,
openFloatingGroup,
isGroupOpen,
toggleMinimizeFloatingChat,
closeFloatingChat,
subscribeFloating,
fetchConversations,
handleIncoming,
handleReaction,
handleRead,
handleGroupRead,
handleGroupUpdate,
isPendingInvite,
removeGroupConversation,
acceptGroupInvite,
declineGroupInvite,
addOwnMessage,
addOwnGroupMessage,
markConversationRead,
+98
View File
@@ -0,0 +1,98 @@
import type { Ref } from 'vue'
function clamp(v: number, min = 0, max = 100) {
return Math.min(Math.max(v, min), max)
}
function adjust(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) {
return toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin)
}
// Berechnet die CSS-Variablen im Stil von poke-holo.simey.me für
// 3D-Kartenrotation + Holo-Shine/Glare. Solange der Zeiger nicht direkt
// auf der Karte liegt, schwenkt eine sanfte Sinus-Bewegung das Hologramm,
// damit man den 3D-/Holo-Effekt schon beim Öffnen der Hover-Card sieht.
export function useHoloTilt(active: Ref<boolean>) {
const pointerX = ref(50)
const pointerY = ref(50)
const isHovering = ref(false)
let rafId: number | null = null
function tick(now: number) {
if (!active.value) {
rafId = null
return
}
if (!isHovering.value) {
const t = now * 0.0022
pointerX.value = 50 + Math.cos(t) * 32
pointerY.value = 50 + Math.sin(t * 1.3) * 28
}
rafId = requestAnimationFrame(tick)
}
watch(active, (v) => {
if (!import.meta.client) return
if (v && rafId === null) {
rafId = requestAnimationFrame(tick)
}
else if (!v && rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
isHovering.value = false
}
}, { immediate: true })
onBeforeUnmount(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
})
function onPointerMove(e: PointerEvent) {
if (!active.value) return
const el = e.currentTarget as HTMLElement | null
if (!el) return
const rect = el.getBoundingClientRect()
if (!rect.width || !rect.height) return
isHovering.value = true
pointerX.value = clamp(((e.clientX - rect.left) / rect.width) * 100)
pointerY.value = clamp(((e.clientY - rect.top) / rect.height) * 100)
}
function onPointerLeave() {
isHovering.value = false
}
const holoStyle = computed<Record<string, string>>(() => {
if (!active.value) return {}
const px = pointerX.value
const py = pointerY.value
const bgX = adjust(px, 0, 100, 35, 65)
const bgY = adjust(py, 0, 100, 33, 67)
const centerX = px - 50
const centerY = py - 50
const fromCenter = clamp(Math.hypot(centerX, centerY) / 50, 0, 1)
const rotateY = -(centerX / 3.2)
const rotateX = centerY / 2.8
return {
'--pointer-x': `${px.toFixed(2)}%`,
'--pointer-y': `${py.toFixed(2)}%`,
'--background-x': `${bgX.toFixed(2)}%`,
'--background-y': `${bgY.toFixed(2)}%`,
'--pointer-from-center': fromCenter.toFixed(3),
'--pointer-from-top': (py / 100).toFixed(3),
'--pointer-from-left': (px / 100).toFixed(3),
'--rotate-x': `${rotateX.toFixed(2)}deg`,
'--rotate-y': `${rotateY.toFixed(2)}deg`,
transform: `perspective(600px) rotateX(${rotateX.toFixed(2)}deg) rotateY(${rotateY.toFixed(2)}deg) translateZ(0)`,
transformStyle: 'preserve-3d',
willChange: 'transform',
transition: isHovering.value ? 'transform 60ms ease-out, box-shadow 200ms ease' : 'transform 180ms ease-out, box-shadow 300ms ease',
}
})
return { holoStyle, onPointerMove, onPointerLeave }
}
+9
View File
@@ -9,6 +9,15 @@ export interface PendingUploadMedia {
thumbUrl: string | null
}
export function mediaForThread<T extends { id: string }>(
id: string,
loaded: T | null | undefined,
pending: PendingUploadMedia | null | undefined,
): T | PendingUploadMedia | null {
if (loaded?.id === id) return loaded
return pending?.id === id ? pending : null
}
// Beim Tile-Klick schon bekannte Mediendaten, damit die Thread-Seite
// vor Eintreffen der API-Antwort ein benanntes Element für die
// View-Transition rendern kann
+6
View File
@@ -6,8 +6,11 @@ import { LIMIT_DEFAULTS, type AppLimits } from '~~/shared/utils/limits'
export function useLimits() {
const limits = useState<AppLimits>('limits', () => ({ ...LIMIT_DEFAULTS }))
const loaded = useState<boolean>('limits-loaded', () => false)
const loading = useState<boolean>('limits-loading', () => false)
async function fetchLimits() {
if (loading.value) return
loading.value = true
try {
const res = await $fetch<{ limits: AppLimits }>('/api/limits')
limits.value = { ...LIMIT_DEFAULTS, ...res.limits }
@@ -16,6 +19,9 @@ export function useLimits() {
catch {
// Defaults bleiben aktiv
}
finally {
loading.value = false
}
}
function applyRemote(remote: Partial<AppLimits>) {
+1
View File
@@ -9,6 +9,7 @@ export interface LiveComment {
userId: number | null
isBot?: boolean
userColor: string | null
userNameGradient?: boolean
userAvatarUrl: string | null
uploadId: string
attachments?: CommentAttachment[]
+4 -2
View File
@@ -13,10 +13,12 @@ interface PageSizeOptions {
rowHeights?: [number, number, number]
// Abstand zwischen Anker-Unterkante und Grid-Oberkante in px (mb-* der Zeile)
gapBelowAnchorPx?: number
// Zusätzlicher Platzbedarf unter dem Grid in px (Seiten-Padding, Spacer)
bottomReservePx?: number
}
export function usePageSize(anchor: Ref<HTMLElement | null | undefined>, options: PageSizeOptions = {}) {
const { rowHeights = [6.5, 7, 7.5], gapBelowAnchorPx = 8 } = options
const { rowHeights = [6.5, 7, 7.5], gapBelowAnchorPx = 8, bottomReservePx = 0 } = options
const savedVp = useCookie<{ w: number, h: number }>('vp-size', {
default: () => ({ w: DEFAULT_SSR_WIDTH, h: DEFAULT_SSR_HEIGHT }),
sameSite: 'lax',
@@ -84,7 +86,7 @@ export function usePageSize(anchor: Ref<HTMLElement | null | undefined>, options
: 16
const rowH = (w >= 1024 ? rowHeights[2] : w >= 640 ? rowHeights[1] : rowHeights[0]) * rem + 8 // + grid gap
const top = anchorBottom.value + gapBelowAnchorPx
const rows = Math.max(1, Math.floor((h - top - PAGINATION_RESERVE_PX) / rowH))
const rows = Math.max(1, Math.floor((h - top - PAGINATION_RESERVE_PX - bottomReservePx) / rowH))
return cols.value * rows
})
+33
View File
@@ -0,0 +1,33 @@
import type { DecoratedTag } from '~~/shared/utils/tags'
// Deko-Lookup (name -> icon/effect) für Stellen, die nur den Tag-Namen
// kennen (z. B. Filter-Chip). Wird einmalig lazy geladen; Admin-Änderungen
// lassen sich per refresh() nachziehen.
export function useTagDecorations() {
const map = useState<Map<string, Omit<DecoratedTag, 'name'>>>('tag-decorations', () => new Map())
const loaded = useState<boolean>('tag-decorations:loaded', () => false)
async function load() {
if (loaded.value) return
loaded.value = true
try {
const rows = await $fetch<DecoratedTag[]>('/api/tags/decorations')
for (const r of rows) {
map.value.set(r.name, { icon: r.icon, iconPosition: r.iconPosition, effect: r.effect })
}
}
catch {
loaded.value = false
}
}
async function refresh() {
loaded.value = false
map.value.clear()
await load()
}
const get = (name: string | null | undefined) => (name ? map.value.get(name) ?? null : null)
return { load, refresh, get }
}
+12 -2
View File
@@ -1,10 +1,12 @@
export const THEME_NAMES = ['standard', 'discord', '4f', 'cyberpunk', 'spring', 'terminal', 'infoseer', 'homebrew', 'florense', 'kawi-green', 'clonz', 'clawmorphism', 'realmorphism', 'amber', 'sunny-sprout', 'spotify', 'nordic', 'ss', 'amethyst-haze', 'deep-wine', 'my-claude', 'focused', 'neo'] as const
export const THEME_NAMES = ['standard', 'discord', 'discord-dark', 'discord-onyx', '4f', 'cyberpunk', 'spring', 'terminal', 'infoseer', 'homebrew', 'florense', 'kawi-green', 'clonz', 'clawmorphism', 'realmorphism', 'amber', 'sunny-sprout', 'spotify', 'nordic', 'ss', 'amethyst-haze', 'deep-wine', 'my-claude', 'focused', 'claude-plus', '2077', 'astra', 'apotheosis-mint-midnight', 'emerald', 'vulkan', 'sakura', 'minimal-neutral', 'neo'] as const
export type ThemeName = typeof THEME_NAMES[number]
export const THEMES: { value: ThemeName, label: string }[] = [
{ value: 'standard', label: 'Standard' },
{ value: 'discord', label: 'Discord' },
{ value: 'discord', label: 'Discord Ash' },
{ value: 'discord-dark', label: 'Discord Dark' },
{ value: 'discord-onyx', label: 'Discord Onyx' },
{ value: '4f', label: '4f' },
{ value: 'cyberpunk', label: 'Cyberpunk' },
{ value: 'spring', label: 'Spring' },
@@ -25,6 +27,14 @@ export const THEMES: { value: ThemeName, label: string }[] = [
{ value: 'deep-wine', label: 'Deep Wine' },
{ value: 'my-claude', label: 'myClaude' },
{ value: 'focused', label: 'Focused' },
{ value: 'claude-plus', label: 'Claude +' },
{ value: '2077', label: '2077' },
{ value: 'astra', label: 'Astra' },
{ value: 'apotheosis-mint-midnight', label: 'Apotheosis Mint Midnight' },
{ value: 'emerald', label: 'Emerald' },
{ value: 'vulkan', label: 'Vulkan' },
{ value: 'sakura', label: 'Sakura' },
{ value: 'minimal-neutral', label: 'Minimal Neutral' },
{ value: 'neo', label: 'Neo' },
]
+14
View File
@@ -0,0 +1,14 @@
import type { ThreadLocks } from '~~/shared/utils/threadLocks'
// Live-Overrides für Thread-Sperren: werden per WS (thread-settings) gepusht
// und überlagern die beim Laden gelieferten Upload-Flags
export function useThreadLocks() {
const locks = useState<Record<string, ThreadLocks>>('threads:locks', () => ({}))
function applyRemote(uploadId: string, settings: ThreadLocks) {
if (!uploadId || typeof uploadId !== 'string') return
locks.value = { ...locks.value, [uploadId]: settings }
}
return { locks, applyRemote }
}
+58
View File
@@ -0,0 +1,58 @@
// Client-seitiger Filter-State für die Mainseite - bewusst KEINE URL-Params,
// damit die Adresszeile sauber bleibt. Externe Links (?tag=, ?uploader=, ...)
// werden von index.vue einmalig in den State übernommen und aus der URL entfernt.
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const toISODate = (d: Date) => d.toISOString().slice(0, 10)
export function useUploadFilters() {
const tag = useState<string | null>('uploads-filter:tag', () => null)
const types = useState<string[]>('uploads-filter:types', () => [])
const uploaderId = useState<string | null>('uploads-filter:uploader', () => null)
// Datumsbereich als ISO-Datum (YYYY-MM-DD); null = offen
const dateFrom = useState<string | null>('uploads-filter:from', () => null)
const dateTo = useState<string | null>('uploads-filter:to', () => null)
const following = useState<boolean>('uploads:following', () => false)
const hasDateFilter = computed(() => !!(dateFrom.value || dateTo.value))
const activeCount = computed(() =>
(tag.value ? 1 : 0) + types.value.length + (uploaderId.value ? 1 : 0) + (hasDateFilter.value ? 1 : 0) + (following.value ? 1 : 0),
)
const hasActive = computed(() => activeCount.value > 0)
function reset() {
tag.value = null
types.value = []
uploaderId.value = null
dateFrom.value = null
dateTo.value = null
following.value = false
}
// Query-Filter in den State übernehmen - false, wenn keine Filter-Keys da sind.
// ?days=N wird als Bereich [heute-N, heute] interpretiert.
function applyQuery(query: Record<string, unknown>): boolean {
const t = typeof query.tag === 'string' && query.tag ? query.tag : null
const ty = typeof query.type === 'string' ? query.type.split(',').filter(Boolean) : []
const u = typeof query.uploader === 'string' && query.uploader ? query.uploader : null
const from = typeof query.from === 'string' && ISO_DATE_RE.test(query.from) ? query.from : null
const to = typeof query.to === 'string' && ISO_DATE_RE.test(query.to) ? query.to : null
const days = Number(query.days)
const legacyRange = Number.isFinite(days) && days > 0
if (t === null && !ty.length && u === null && from === null && to === null && !legacyRange) return false
tag.value = t
types.value = ty
uploaderId.value = u
if (legacyRange) {
const end = new Date()
dateFrom.value = toISODate(new Date(end.getTime() - days * 86400_000))
dateTo.value = toISODate(end)
}
else {
dateFrom.value = from
dateTo.value = to
}
return true
}
return { tag, types, uploaderId, dateFrom, dateTo, following, activeCount, hasActive, reset, applyQuery }
}
+8 -5
View File
@@ -3,6 +3,7 @@ export interface UploadReaction {
count: number
me: boolean
names: string[]
sticker?: boolean
}
export interface UploadReactionEventPayload {
@@ -12,6 +13,7 @@ export interface UploadReactionEventPayload {
actorName: string
active: boolean
count: number
sticker?: boolean
}
export function useUploadReactions() {
@@ -31,7 +33,7 @@ export function useUploadReactions() {
let entry = list.find(r => r.emoji === e.emoji)
if (e.active) {
if (!entry) {
entry = { emoji: e.emoji, count: 0, me: false, names: [] }
entry = { emoji: e.emoji, count: 0, me: false, names: [], sticker: e.sticker }
list.push(entry)
}
if (!entry.names.includes(e.actorName)) {
@@ -51,7 +53,7 @@ export function useUploadReactions() {
}
}
async function toggle(uploadId: string, emoji: string) {
async function toggle(uploadId: string, emoji: string, sticker = false) {
const me = user.value
const before = map.value[uploadId]?.find(r => r.emoji === emoji)
// Optimistisch umschalten - die Reaktion ist sofort sichtbar,
@@ -64,21 +66,22 @@ export function useUploadReactions() {
actorName: me.name,
active: !before?.me,
count: (before?.count ?? 0) + (before?.me ? -1 : 1),
sticker,
})
}
try {
const res = await $fetch<{ active: boolean, count: number, isSubscribed: boolean }>(`/api/uploads/${uploadId}/reactions`, {
method: 'POST',
body: { emoji },
body: { emoji, sticker },
})
if (me) {
applyEvent({ uploadId, emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count })
applyEvent({ uploadId, emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count, sticker })
}
return res
}
catch (err) {
if (me) {
applyEvent({ uploadId, emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0 })
applyEvent({ uploadId, emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0, sticker })
}
throw err
}
+12 -1
View File
@@ -2,13 +2,21 @@ export interface UserSummary {
id: number
name: string
color: string | null
bio: string | null
roles: string[]
cardEffect: string | null
cardEffectColor: string | null
cardVignette: boolean
cardBorderEffect: string | null
cardBorderEffectColor: string | null
cardHoloEffect: string | null
avatarUrl: string | null
cardBannerUrl: string | null
cardBannerType: 'image' | 'video' | null
createdAt: string
renamedUntil: string | null
flair: string | null
nameGradient: boolean
stats: {
uploads: number
comments: number
@@ -22,7 +30,10 @@ export function useUserSummaries() {
const summaries = useState<Record<number, UserSummary>>('user-summaries', () => ({}))
function ensureSummary(id: number) {
if (summaries.value[id] || inflight.has(id)) return
// Altbestand im Cache ohne Banner-Felder (vor dem Feature geladen) wird
// neu geholt - 'cardBannerUrl' in cached erkennt die alte Form
const cached = summaries.value[id]
if ((cached && Array.isArray(cached.roles) && 'cardBannerUrl' in cached) || inflight.has(id)) return
inflight.set(id, (async () => {
try {
summaries.value[id] = await $fetch<UserSummary>(`/api/users/${id}/summary`)
+4 -2
View File
@@ -13,8 +13,10 @@ export function useViewingThread() {
}
// Vom WS-Plugin aufgerufen, sobald der Socket offen ist - sendet dabei den
// zuletzt bekannten Thread nochmal (Reconnect-Sicherheit)
// zuletzt bekannten Thread nochmal (Reconnect-Sicherheit). Es wird immer
// gesendet, auch bei null: Jeder Socket emittiert so eine Nachricht, die
// serverseitig die Peer-User-Registrierung bei Bedarf nachholt
export function bindViewingSocket(send: (payload: unknown) => void) {
sender = send
if (current) send({ type: 'viewing', uploadId: current })
send({ type: 'viewing', uploadId: current })
}
+2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import CommentsSidebar from '@/components/CommentsSidebar.vue'
import CommandPalette from '@/components/CommandPalette.vue'
import DmFloatingContainer from '@/components/DmFloatingContainer.vue'
import GlobalDropZone from '@/components/GlobalDropZone.vue'
import MotdBanner from '@/components/MotdBanner.vue'
import SiteBreadcrumb from '@/components/SiteBreadcrumb.vue'
@@ -44,6 +45,7 @@ watch(() => route.path, (to, from) => {
</div>
</SidebarInset>
<CommentsSidebar v-if="!locked" />
<DmFloatingContainer v-if="!locked" />
<CommandPalette v-if="!locked" />
<GlobalDropZone v-if="!locked" />
</SidebarProvider>
+6
View File
@@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware(() => {
const { can } = useAuth()
if (!can('mod.access')) {
return navigateTo('/')
}
})
+467 -65
View File
@@ -2,6 +2,8 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { PERMISSIONS, SYSTEM_ROLES } from '~~/shared/utils/permissions'
import { LIMIT_DEFAULTS, LIMIT_KEYS, LIMIT_MAX, type AppLimits, type LimitKey } from '~~/shared/utils/limits'
import { SHOP_PRICE_DEFAULTS, SHOP_PRICE_KEYS, SHOP_PRICE_MAX, type ShopPriceKey, type ShopPrices } from '~~/shared/utils/shop'
import { TAG_EFFECTS, TAG_ICON_POSITIONS, tagEffectClass, type TagEffectName, type TagIconPosition } from '~~/shared/utils/tags'
import type { DataTableFeatures } from '@/lib/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -9,7 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
@@ -18,7 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput } from '@/components/ui/number-field'
import { Spinner } from '@/components/ui/spinner'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -41,6 +43,7 @@ interface AdminUser {
}
const { user, can } = useAuth()
const { invalidate: invalidateUserSummary } = useUserSummaries()
const { t } = useI18n()
useHead({ title: computed(() => t('nav.admin')) })
@@ -197,6 +200,7 @@ async function addRole(userId: number) {
try {
await $fetch(`/api/admin/users/${userId}/roles`, { method: 'POST', body: { roleId } })
selectedRole.value[userId] = undefined
invalidateUserSummary(userId)
await refreshAll()
}
catch (err) {
@@ -210,6 +214,7 @@ async function removeRole(userId: number, roleId: number) {
try {
await $fetch(`/api/admin/users/${userId}/roles/${roleId}`, { method: 'DELETE' })
roleRemoveConfirm.value = null
invalidateUserSummary(userId)
await refreshAll()
}
catch (err) {
@@ -220,6 +225,27 @@ async function removeRole(userId: number, roleId: number) {
}
}
// Punkte verschenken (Testen/Moderation): negative Buchung in point_purchases
const pointsGiftUser = ref<number | null>(null)
const pointsGiftAmount = ref<Record<number, number | undefined>>({})
const pointsGifting = ref<number | null>(null)
async function giftPoints(userId: number) {
const amount = pointsGiftAmount.value[userId]
if (!amount || !Number.isInteger(amount) || amount < 1) return
errorMessage.value = ''
pointsGifting.value = userId
try {
await $fetch(`/api/admin/users/${userId}/points`, { method: 'POST', body: { amount } })
pointsGiftUser.value = null
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
pointsGifting.value = null
}
}
function permissionsDraft(role: Role) {
return editedPermissions.value[role.id] ?? role.permissions
}
@@ -276,52 +302,25 @@ async function saveMotd() {
}
}
// Limits: Drafts sind Strings (Input), leeres Feld = Override löschen → Default
// Limits: leeres Zahlenfeld = Override löschen → Default
const { data: limitsData, refresh: refreshLimits } = await useFetch<{ limits: AppLimits, overridden: LimitKey[] }>(
'/api/admin/limits',
{ default: () => ({ limits: { ...LIMIT_DEFAULTS }, overridden: [] }), immediate: can('site.manage') },
)
const limitDrafts = ref<Record<LimitKey, string>>({} as Record<LimitKey, string>)
const limitDrafts = ref<Record<LimitKey, number | undefined>>({} as Record<LimitKey, number | undefined>)
const overriddenSet = ref<Set<LimitKey>>(new Set())
const limitsSaving = ref(false)
watch(limitsData, (d) => {
if (!d) return
limitDrafts.value = Object.fromEntries(LIMIT_KEYS.map(k => [k, String(d.limits[k])])) as Record<LimitKey, string>
limitDrafts.value = { ...d.limits }
overriddenSet.value = new Set(d.overridden)
}, { immediate: true })
// Slider-Maxima sind UI-Komfort (kurze, gut einstellbare Skala) - der Server
// akzeptiert bis LIMIT_MAX, über das Zahlenfeld erreichbar
const limitSliderMax: Record<LimitKey, number> = {
uploadMaxSizeMb: 1024,
stickerMaxSizeMb: 64,
avatarMaxSizeMb: 20,
commentMaxLength: 10_000,
dmMaxLength: 10_000,
commentMaxAttachments: 20,
dmMaxAttachments: 20,
maxTagsPerUpload: 50,
maxTagLength: 128,
voiceMaxSeconds: 900,
memeSizePx: 1600,
youtubeCheckIntervalMinutes: 120,
}
function limitDraftNumber(key: LimitKey) {
return Number(limitDrafts.value[key]) || LIMIT_DEFAULTS[key]
}
function setLimitDraft(key: LimitKey, v: number[] | undefined) {
if (v?.length) limitDrafts.value[key] = String(v[0])
}
function limitSliderMaxFor(key: LimitKey) {
return Math.max(limitSliderMax[key], limitDraftNumber(key))
}
const limitsDirty = computed(() =>
LIMIT_KEYS.some((key) => {
const draft = limitDrafts.value[key]?.trim() ?? ''
const effective = String(limitsData.value.limits[key])
if (draft === '') return overriddenSet.value.has(key)
return draft !== effective
const draft = limitDrafts.value[key]
if (draft === undefined) return overriddenSet.value.has(key)
return draft !== limitsData.value.limits[key]
}),
)
@@ -330,19 +329,30 @@ async function saveLimits() {
errorMessage.value = ''
try {
const body: Record<string, number | null> = {}
const nextLimits = { ...limitsData.value.limits }
let thumbnailLimitsTouched = false
for (const key of LIMIT_KEYS) {
const draft = (limitDrafts.value[key] ?? '').trim()
if (draft === '') {
const draft = limitDrafts.value[key]
const isThumbnailLimit = key === 'thumbnailMediumPoints' || key === 'thumbnailLargePoints'
if (draft === undefined) {
nextLimits[key] = LIMIT_DEFAULTS[key]
if (overriddenSet.value.has(key)) body[key] = null
thumbnailLimitsTouched ||= isThumbnailLimit
continue
}
const n = Number(draft)
const n = draft
if (!Number.isInteger(n) || n <= 0) {
errorMessage.value = t('admin.limitInvalid', { field: t(`admin.limitFields.${key}`) })
return
}
nextLimits[key] = n
thumbnailLimitsTouched ||= isThumbnailLimit && n !== limitsData.value.limits[key]
if (n !== limitsData.value.limits[key]) body[key] = n
}
if (thumbnailLimitsTouched && nextLimits.thumbnailMediumPoints >= nextLimits.thumbnailLargePoints) {
errorMessage.value = t('admin.limitOrder')
return
}
if (Object.keys(body).length) {
await $fetch<{ limits: AppLimits }>('/api/admin/limits', { method: 'PUT', body })
}
@@ -366,30 +376,200 @@ async function resetLimit(key: LimitKey) {
errorMessage.value = errText(err)
}
}
// Shop-Preise: leeres Zahlenfeld = Override löschen → Default
const { data: pricesData, refresh: refreshPrices } = await useFetch<{ prices: ShopPrices, overridden: ShopPriceKey[] }>(
'/api/admin/prices',
{ default: () => ({ prices: { ...SHOP_PRICE_DEFAULTS }, overridden: [] }), immediate: can('site.manage') },
)
const priceDrafts = ref<Record<ShopPriceKey, number | undefined>>({} as Record<ShopPriceKey, number | undefined>)
const overriddenPriceSet = ref<Set<ShopPriceKey>>(new Set())
const pricesSaving = ref(false)
watch(pricesData, (d) => {
if (!d) return
priceDrafts.value = { ...d.prices }
overriddenPriceSet.value = new Set(d.overridden)
}, { immediate: true })
const pricesDirty = computed(() =>
SHOP_PRICE_KEYS.some((key) => {
const draft = priceDrafts.value[key]
if (draft === undefined) return overriddenPriceSet.value.has(key)
return draft !== pricesData.value.prices[key]
}),
)
async function savePrices() {
pricesSaving.value = true
errorMessage.value = ''
try {
const body: Record<string, number | null> = {}
for (const key of SHOP_PRICE_KEYS) {
const draft = priceDrafts.value[key]
if (draft === undefined) {
if (overriddenPriceSet.value.has(key)) body[key] = null
continue
}
const n = draft
if (!Number.isInteger(n) || n <= 0) {
errorMessage.value = t('admin.limitInvalid', { field: t(`admin.priceFields.${key}`) })
return
}
if (n !== pricesData.value.prices[key]) body[key] = n
}
if (Object.keys(body).length) {
await $fetch<{ prices: ShopPrices }>('/api/admin/prices', { method: 'PUT', body })
}
await refreshPrices()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
pricesSaving.value = false
}
}
async function resetPrice(key: ShopPriceKey) {
errorMessage.value = ''
try {
await $fetch('/api/admin/prices', { method: 'PUT', body: { [key]: null } })
await refreshPrices()
}
catch (err) {
errorMessage.value = errText(err)
}
}
// Tag-Deko: pro Tag Icon (Emoji oder Iconify-Name), Icon-Position und
// Animationseffekt. 'none'/'-'-Sentinel werden beim Speichern zu null.
interface AdminTagRow {
id: number
name: string
count: number
icon: string
iconPosition: TagIconPosition
effect: TagEffectName | 'none'
}
const { data: adminTags } = await useFetch<AdminTagRow[]>(
'/api/admin/tags',
{
default: () => [],
immediate: can('tag.manage'),
transform: rows => rows.map(r => ({ ...r, icon: r.icon ?? '', effect: r.effect ?? 'none' }) as AdminTagRow),
},
)
const tagDecoSearch = ref('')
const filteredAdminTags = computed(() => {
const q = tagDecoSearch.value.trim().toLowerCase()
return q ? adminTags.value.filter(t => t.name.includes(q)) : adminTags.value
})
const tagDecoSaving = ref<number | null>(null)
// Welcher Tag hat den Icon-Picker offen / lädt gerade ein Icon hoch
const tagIconPicker = ref<number | null>(null)
const tagIconUploading = ref(false)
const tagIconUploadFor = ref<number | null>(null)
const iconFileInput = ref<HTMLInputElement | null>(null)
function pickTagIcon(tag: AdminTagRow, value: string, sticker: boolean) {
// Sticker werden als :name:-Shortcode gespeichert
tag.icon = sticker ? `:${value}:` : value
tagIconPicker.value = null
saveTagDeco(tag)
}
function startTagIconUpload(tag: AdminTagRow) {
tagIconUploadFor.value = tag.id
iconFileInput.value?.click()
}
async function onTagIconFile(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const tag = adminTags.value.find(t => t.id === tagIconUploadFor.value)
tagIconUploadFor.value = null
if (!file || !tag) return
errorMessage.value = ''
tagIconUploading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const res = await $fetch<{ url: string }>('/api/admin/tags/icon', { method: 'POST', body: fd })
tag.icon = res.url
tagIconPicker.value = null
await saveTagDeco(tag)
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
tagIconUploading.value = false
}
}
async function saveTagDeco(tag: AdminTagRow) {
if (tagDecoSaving.value === tag.id) return
tagDecoSaving.value = tag.id
errorMessage.value = ''
try {
await $fetch('/api/admin/tags', {
method: 'PUT',
body: {
id: tag.id,
icon: tag.icon || null,
iconPosition: tag.iconPosition,
effect: tag.effect === 'none' ? null : tag.effect,
},
})
// Deko-Lookup für Filter-Chips etc. aktualisieren
useTagDecorations().refresh()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
tagDecoSaving.value = null
}
}
</script>
<template>
<div class="px-4 lg:px-6">
<div class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<Tabs default-value="roles" class="gap-4">
<TabsList>
<TabsTrigger value="roles" class="cursor-pointer">
<Icon name="ph:shield-fill" />
{{ $t('admin.roles') }}
</TabsTrigger>
<TabsTrigger v-if="can('users.manage')" value="users" class="cursor-pointer">
<Icon name="ph:users-fill" />
{{ $t('admin.users') }}
</TabsTrigger>
<TabsTrigger v-if="can('sticker.standard')" value="stickers" class="cursor-pointer">
<Icon name="ph:sticker-fill" />
{{ $t('admin.stickers') }}
</TabsTrigger>
<TabsTrigger v-if="can('meme.standard')" value="memes" class="cursor-pointer">
<Icon name="ph:smiley-sticker-fill" />
{{ $t('admin.memes') }}
</TabsTrigger>
<TabsTrigger v-if="can('tag.manage')" value="tags" class="cursor-pointer">
<Icon name="ph:tag-fill" />
{{ $t('admin.tags') }}
</TabsTrigger>
<TabsTrigger v-if="can('site.manage')" value="site" class="cursor-pointer">
<Icon name="ph:globe-fill" />
{{ $t('admin.site') }}
</TabsTrigger>
<TabsTrigger v-if="can('site.manage')" value="limits" class="cursor-pointer">
<Icon name="ph:sliders-horizontal-fill" />
{{ $t('admin.limits') }}
</TabsTrigger>
<TabsTrigger v-if="can('site.manage')" value="prices" class="cursor-pointer">
<Icon name="ph:coins-fill" />
{{ $t('admin.prices') }}
</TabsTrigger>
</TabsList>
<TabsContent value="roles">
@@ -400,7 +580,7 @@ async function resetLimit(key: LimitKey) {
</CardHeader>
<CardContent class="space-y-4">
<ul class="space-y-2">
<li v-for="role in rolesList" :key="role.id" class="rounded-md border px-3 py-2">
<li v-for="role in rolesList" :key="role.id" class="rounded-md bg-muted/40 px-3 py-2">
<div class="flex items-center gap-3">
<span class="font-medium">{{ role.name }}</span>
<span class="text-sm text-muted-foreground">{{ $t('admin.memberCount', { n: role.memberCount }) }}</span>
@@ -474,7 +654,7 @@ async function resetLimit(key: LimitKey) {
</li>
</ul>
<form v-if="can('roles.manage')" class="flex gap-2" @submit.prevent="createRole">
<Input v-model="newRoleName" :placeholder="$t('admin.newRole')" maxlength="50" />
<Input v-model="newRoleName" :placeholder="$t('admin.newRole')" :aria-label="$t('admin.newRole')" maxlength="50" />
<Button type="submit" :disabled="!newRoleName.trim()">
{{ $t('admin.create') }}
</Button>
@@ -514,7 +694,7 @@ async function resetLimit(key: LimitKey) {
class="opacity-60 hover:opacity-100"
@click="roleRemoveConfirm = `${row.original.id}-${r.id}`"
>
<Icon name="ph:x-fill" class="size-3" />
<Icon name="ph:x-bold" class="size-3" />
<span class="sr-only">{{ $t('admin.removeRole', { name: r.name }) }}</span>
</button>
</PopoverAnchor>
@@ -570,6 +750,60 @@ async function resetLimit(key: LimitKey) {
>
{{ $t('admin.assign') }}
</Button>
<Popover
:open="pointsGiftUser === row.original.id"
@update:open="v => { if (pointsGifting !== row.original.id) pointsGiftUser = v ? row.original.id : null }"
>
<PopoverAnchor as-child>
<span class="inline-flex">
<Tooltip>
<TooltipTrigger as-child>
<Button
size="sm"
variant="outline"
:aria-label="$t('admin.giftPoints')"
@click="pointsGiftUser = row.original.id"
>
<Icon name="ph:coins-fill" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ $t('admin.giftPoints') }}
</TooltipContent>
</Tooltip>
</span>
</PopoverAnchor>
<PopoverContent side="bottom" align="end" class="w-56">
<p class="text-sm font-medium">
{{ $t('admin.giftPointsTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('admin.giftPointsDesc', { user: row.original.name }) }}
</p>
<div class="mt-3 flex gap-2">
<NumberField
v-model="pointsGiftAmount[row.original.id]"
:min="1"
:max="100000"
class="min-w-0 flex-1"
>
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput :aria-label="$t('admin.pointsAmount')" />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
<Button
size="sm"
:disabled="pointsGifting === row.original.id || !pointsGiftAmount[row.original.id] || (pointsGiftAmount[row.original.id] ?? 0) < 1"
@click="giftPoints(row.original.id)"
>
<Spinner v-if="pointsGifting === row.original.id" />
{{ $t('admin.gift') }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</template>
</DataTable>
@@ -600,7 +834,7 @@ async function resetLimit(key: LimitKey) {
<li
v-for="e in group.list"
:key="e.id"
class="flex flex-col gap-2 rounded-md border p-2.5"
class="flex flex-col gap-2 rounded-md bg-muted/40 p-2"
>
<StickerMedia :sticker="e" variant="card" />
<div class="flex items-center justify-between gap-2">
@@ -675,7 +909,7 @@ async function resetLimit(key: LimitKey) {
<li
v-for="m in group.list"
:key="m.id"
class="flex flex-col gap-2 rounded-md border p-2.5"
class="flex flex-col gap-2 rounded-md bg-muted/40 p-2"
>
<img :src="m.url" :alt="m.name" class="aspect-square w-full rounded-md bg-muted/40 object-contain">
<div class="flex items-start justify-between gap-1.5">
@@ -683,13 +917,13 @@ async function resetLimit(key: LimitKey) {
<span class="truncate text-sm font-medium">{{ m.name }}</span>
<span class="truncate text-xs text-muted-foreground">{{ m.userName ?? '-' }}</span>
<div class="mt-1 flex flex-wrap gap-1">
<Badge v-if="m.isStandard" variant="default" class="px-1.5 py-0 text-[10px]">
<Badge v-if="m.isStandard" variant="default" class="px-1.5 py-0 text-xs">
{{ $t('memes.badgeStandard') }}
</Badge>
<Badge v-else-if="m.isPublic" variant="secondary" class="px-1.5 py-0 text-[10px]">
<Badge v-else-if="m.isPublic" variant="secondary" class="px-1.5 py-0 text-xs">
{{ $t('memes.visibilityPublic') }}
</Badge>
<Badge v-else variant="outline" class="px-1.5 py-0 text-[10px]">
<Badge v-else variant="outline" class="px-1.5 py-0 text-xs">
{{ $t('memes.badgePrivate') }}
</Badge>
</div>
@@ -800,7 +1034,7 @@ async function resetLimit(key: LimitKey) {
@update:model-value="setSiteFlag('registrationOpen', $event)"
/>
</div>
<p v-if="siteFlagError" class="text-sm text-destructive">
<p v-if="siteFlagError" role="alert" class="text-sm text-destructive">
{{ siteFlagError }}
</p>
</CardContent>
@@ -815,6 +1049,7 @@ async function resetLimit(key: LimitKey) {
<Textarea
v-model="motdDraft"
:placeholder="$t('admin.motdPlaceholder')"
:aria-label="$t('admin.motd')"
maxlength="500"
class="min-h-16"
/>
@@ -844,23 +1079,19 @@ async function resetLimit(key: LimitKey) {
<label :for="`limit-${key}`" class="w-56 shrink-0 text-sm font-medium">
{{ $t(`admin.limitFields.${key}`) }}
</label>
<Slider
:model-value="[limitDraftNumber(key)]"
:min="1"
:max="limitSliderMaxFor(key)"
:step="1"
class="min-w-40 max-w-64 flex-1"
:aria-label="$t(`admin.limitFields.${key}`)"
@update:model-value="setLimitDraft(key, $event)"
/>
<Input
:id="`limit-${key}`"
<NumberField
v-model="limitDrafts[key]"
type="text"
inputmode="numeric"
class="w-24"
:placeholder="String(LIMIT_DEFAULTS[key])"
/>
:min="1"
:max="LIMIT_MAX[key]"
:step="1"
class="w-36"
>
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput :id="`limit-${key}`" :placeholder="String(LIMIT_DEFAULTS[key])" :aria-label="$t(`admin.limitFields.${key}`)" />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
<span class="text-xs text-muted-foreground">
{{ $t('admin.limitDefault', { n: LIMIT_DEFAULTS[key] }) }}
</span>
@@ -882,10 +1113,181 @@ async function resetLimit(key: LimitKey) {
</div>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent v-if="can('tag.manage')" value="tags">
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.tags') }}</CardTitle>
<CardDescription>{{ $t('admin.tagsDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<Input
v-model="tagDecoSearch"
:placeholder="$t('admin.tagSearch')"
:aria-label="$t('admin.tagSearch')"
class="mb-4 max-w-72"
/>
<input
ref="iconFileInput"
type="file"
accept="image/svg+xml,image/png,image/jpeg,image/gif,image/webp,image/avif,image/bmp,.svg"
class="hidden"
@change="onTagIconFile"
>
<div class="space-y-3">
<div
v-for="tag in filteredAdminTags"
:key="tag.id"
class="flex flex-wrap items-center gap-2 sm:gap-3"
>
<span class="w-56 shrink-0 truncate text-sm font-medium" :title="tag.name">
{{ tag.name }}
<span class="ml-1.5 text-xs font-normal text-muted-foreground">({{ tag.count }})</span>
</span>
<!-- Live-Vorschau der Pill -->
<span
class="relative inline-flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1 text-sm"
:class="tagEffectClass(tag.effect === 'none' ? null : tag.effect)"
>
<TagIcon v-if="tag.icon && tag.iconPosition === 'before'" :icon="tag.icon" />
{{ tag.name }}
<TagIcon v-if="tag.icon && tag.iconPosition === 'after'" :icon="tag.icon" />
<TagEffect v-if="tag.effect !== 'none'" :effect="tag.effect" />
</span>
<Popover
:open="tagIconPicker === tag.id"
@update:open="(o: boolean) => { if (!o) tagIconPicker = null }"
>
<PopoverAnchor as-child>
<Input
v-model="tag.icon"
:placeholder="$t('admin.tagIconPlaceholder')"
:aria-label="$t('admin.tagIconPlaceholder')"
class="w-40"
maxlength="64"
@focus="tagIconPicker = tag.id"
@click="tagIconPicker = tag.id"
@change="saveTagDeco(tag)"
/>
</PopoverAnchor>
<PopoverContent class="w-auto p-0" align="start" @open-auto-focus.prevent>
<EmojiStickerPicker
show-icons
@select="(v: string, s: boolean) => pickTagIcon(tag, v, s)"
/>
<div class="border-t p-2">
<Button
variant="outline"
size="sm"
class="w-full cursor-pointer gap-1.5"
:disabled="tagIconUploading"
@click="startTagIconUpload(tag)"
>
<Spinner v-if="tagIconUploading" />
<Icon v-else name="ph:upload-simple" class="size-4" />
{{ $t('admin.tagIconUpload') }}
</Button>
<p class="mt-1.5 text-center text-[11px] text-muted-foreground">
{{ $t('admin.tagIconUploadHint') }}
</p>
</div>
</PopoverContent>
</Popover>
<Select
:model-value="tag.iconPosition"
:aria-label="$t('admin.iconPosLabel')"
@update:model-value="tag.iconPosition = $event as TagIconPosition; saveTagDeco(tag)"
>
<SelectTrigger class="w-36 cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="p in TAG_ICON_POSITIONS" :key="p" :value="p">
{{ $t(`admin.iconPos.${p}`) }}
</SelectItem>
</SelectContent>
</Select>
<Select
:model-value="tag.effect"
:aria-label="$t('admin.tagEffectLabel')"
@update:model-value="tag.effect = $event as AdminTagRow['effect']; saveTagDeco(tag)"
>
<SelectTrigger class="w-32 cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{{ $t('admin.tagEffects.none') }}</SelectItem>
<SelectItem v-for="e in TAG_EFFECTS" :key="e" :value="e">
{{ $t(`admin.tagEffects.${e}`) }}
</SelectItem>
</SelectContent>
</Select>
<Spinner v-if="tagDecoSaving === tag.id" />
</div>
</div>
<p v-if="!filteredAdminTags.length" class="text-sm text-muted-foreground">
{{ $t('admin.noTags') }}
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent v-if="can('site.manage')" value="prices">
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.prices') }}</CardTitle>
<CardDescription>{{ $t('admin.pricesDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-3" @submit.prevent="savePrices">
<div
v-for="key in SHOP_PRICE_KEYS"
:key="key"
class="flex flex-wrap items-center gap-2 sm:gap-3"
>
<label :for="`price-${key}`" class="w-56 shrink-0 text-sm font-medium">
{{ $t(`admin.priceFields.${key}`) }}
</label>
<NumberField
v-model="priceDrafts[key]"
:min="1"
:max="SHOP_PRICE_MAX"
:step="1"
class="w-36"
>
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput :id="`price-${key}`" :placeholder="String(SHOP_PRICE_DEFAULTS[key])" :aria-label="$t(`admin.priceFields.${key}`)" />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
<span class="text-xs text-muted-foreground">
{{ $t('admin.limitDefault', { n: SHOP_PRICE_DEFAULTS[key] }) }}
</span>
<Button
v-if="overriddenPriceSet.has(key)"
type="button"
variant="ghost"
size="sm"
@click="resetPrice(key)"
>
{{ $t('admin.limitReset') }}
</Button>
</div>
<div class="pt-2">
<Button type="submit" :disabled="pricesSaving || !pricesDirty">
<Spinner v-if="pricesSaving" />
{{ pricesSaving ? $t('common.saving') : $t('common.save') }}
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<p v-if="errorMessage" class="mt-4 text-sm text-destructive">
<p v-if="errorMessage" role="alert" class="mt-4 text-sm text-destructive">
{{ errorMessage }}
</p>
</div>
+13 -9
View File
@@ -3,6 +3,7 @@ import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
interface ChannelSubscriber {
@@ -25,7 +26,7 @@ const { t } = useI18n()
useHead({ title: computed(() => t('nav.channels')) })
const { data: channels, refresh } = await useFetch<TrackedChannel[]>('/api/youtube/channels', {
const { data: channels, status, refresh } = await useFetch<TrackedChannel[]>('/api/youtube/channels', {
key: 'youtube-channels',
default: () => [],
})
@@ -90,20 +91,20 @@ async function unsubscribe(channel: TrackedChannel) {
</script>
<template>
<div class="mx-auto w-full max-w-3xl px-4 lg:px-6">
<div class="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Icon name="ph:youtube-logo-fill" class="size-5" />
<CardTitle>
{{ $t('nav.channels') }}
</CardTitle>
<CardDescription>{{ $t('channels.desc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<CardContent class="space-y-4">
<form v-if="user" class="flex gap-2" @submit.prevent="addChannel">
<Input
v-model="input"
:placeholder="$t('channels.placeholder')"
:aria-label="$t('channels.placeholder')"
class="flex-1"
/>
<Button type="submit" :disabled="submitting || !input.trim()">
@@ -112,17 +113,20 @@ async function unsubscribe(channel: TrackedChannel) {
</Button>
</form>
<p v-if="!channels.length" class="text-sm text-muted-foreground">
<ul v-if="status === 'pending'" class="flex flex-col gap-2">
<Skeleton v-for="i in 3" :key="i" class="h-14 rounded-md" />
</ul>
<p v-else-if="!channels.length" class="text-sm text-muted-foreground">
{{ $t('channels.empty') }}
</p>
<ul v-else class="space-y-3">
<ul v-else class="flex flex-col gap-2">
<li
v-for="channel in channels"
:key="channel.id"
class="flex items-center gap-3 rounded-md border px-3 py-2.5"
class="flex items-center gap-3 rounded-md bg-muted/40 px-3 py-2"
>
<Icon name="ph:youtube-logo-fill" class="size-5 shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1">
<a
:href="channel.url"
+468
View File
@@ -0,0 +1,468 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import HallCoverStack from '@/components/HallCoverStack.vue'
import UploadTile from '@/components/UploadTile.vue'
import UserHoverCard from '@/components/UserHoverCard.vue'
import type { HallVisibility } from '@/components/AddToHallPopover.vue'
import type { ThumbnailSize } from '~~/shared/utils/activityScore'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner'
import { Textarea } from '@/components/ui/textarea'
interface HallItemRow {
id: string
position: number
addedAt: string
type: 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link'
url: string
thumbUrl: string | null
title: string
commentCount: number
activityScore: number
thumbnailSize: ThumbnailSize
createdAt: string
userId?: number
userName: string
userColor: string | null
userAvatarUrl: string | null
}
interface HallDetailResponse {
hall: {
id: string
title: string
description: string | null
visibility: HallVisibility
isStandard: boolean
userId: number
userName: string
userColor: string | null
userAvatarUrl: string | null
itemCount: number
coverThumbUrl: string | null
coverThumbUrls?: string[]
firstUploadId: string | null
canEdit: boolean
canModifyItems: boolean
canToggleStandard: boolean
createdAt: string
updatedAt: string
}
items: HallItemRow[]
}
const route = useRoute()
const { t } = useI18n()
const hallId = computed(() => route.params.id as string)
const { data, error, refresh } = await useFetch<HallDetailResponse>(
() => `/api/halls/${hallId.value}`,
)
useHead({
title: computed(() => data.value?.hall.title ?? t('nav.halls')),
})
const editOpen = ref(false)
const editTitle = ref('')
const editDescription = ref('')
const editVisibility = ref<HallVisibility>('public')
const editIsStandard = ref(false)
const savingEdit = ref(false)
const manageMode = ref(false)
const deleteConfirmOpen = ref(false)
const deleting = ref(false)
const removingUploadId = ref<string | null>(null)
const reordering = ref(false)
function openEditDialog() {
if (!data.value) return
editTitle.value = data.value.hall.title
editDescription.value = data.value.hall.description ?? ''
editVisibility.value = data.value.hall.visibility
editIsStandard.value = data.value.hall.isStandard
editOpen.value = true
}
async function saveEdit() {
if (!data.value || !editTitle.value.trim() || savingEdit.value) return
savingEdit.value = true
try {
await $fetch(`/api/halls/${hallId.value}`, {
method: 'PATCH',
body: {
title: editTitle.value.trim(),
description: editDescription.value.trim() || null,
visibility: editIsStandard.value ? 'public' : editVisibility.value,
...(data.value.hall.canToggleStandard ? { isStandard: editIsStandard.value } : {}),
},
})
editOpen.value = false
toast.success(t('halls.saved'))
await refresh()
}
catch {
toast.error(t('halls.updateFailed'))
}
finally {
savingEdit.value = false
}
}
async function deleteHall() {
if (deleting.value) return
deleting.value = true
try {
await $fetch(`/api/halls/${hallId.value}`, { method: 'DELETE' })
toast.success(t('halls.deleted'))
await navigateTo('/halls')
}
catch {
toast.error(t('halls.deleteFailed'))
}
finally {
deleting.value = false
}
}
async function removeUploadFromHall(uploadId: string) {
if (removingUploadId.value) return
removingUploadId.value = uploadId
try {
await $fetch(`/api/halls/${hallId.value}/items`, {
method: 'POST',
body: { uploadId, action: 'remove' },
})
await refresh()
}
catch {
toast.error(t('halls.updateFailed'))
}
finally {
removingUploadId.value = null
}
}
async function moveItem(index: number, delta: -1 | 1) {
if (!data.value || reordering.value) return
const list = [...data.value.items]
const target = index + delta
if (target < 0 || target >= list.length) return
const [moved] = list.splice(index, 1)
list.splice(target, 0, moved!)
data.value = { ...data.value, items: list }
reordering.value = true
try {
await $fetch(`/api/halls/${hallId.value}`, {
method: 'PATCH',
body: { order: list.map(i => i.id) },
})
}
catch {
toast.error(t('halls.updateFailed'))
await refresh()
}
finally {
reordering.value = false
}
}
const visibilityIcon = computed(() => {
const h = data.value?.hall
if (!h || h.isStandard || h.visibility === 'public') return 'ph:globe-fill'
if (h.visibility === 'unlisted') return 'ph:link-bold'
return 'ph:lock-simple-fill'
})
</script>
<template>
<div class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<p v-if="error" class="text-sm text-muted-foreground">
{{ $t('halls.notFound') }}
</p>
<Card v-else-if="data" class="max-w-4xl">
<CardContent class="flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="relative aspect-video w-full shrink-0 overflow-hidden rounded-md bg-muted sm:w-56">
<HallCoverStack
:thumbs="data.hall.coverThumbUrls"
:fallback-thumb="data.hall.coverThumbUrl"
:alt="data.hall.title"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<h1 class="truncate text-2xl font-semibold tracking-tight">
{{ data.hall.title }}
</h1>
<Badge v-if="data.hall.isStandard" variant="default" class="gap-1">
<Icon name="ph:star-fill" class="size-3" />
{{ $t('halls.standardBadge') }}
</Badge>
<Badge v-else variant="secondary" class="gap-1">
<Icon :name="visibilityIcon" class="size-3" />
{{ $t(`halls.visibility.${data.hall.visibility}`) }}
</Badge>
</div>
<p v-if="data.hall.description" class="whitespace-pre-line text-sm text-muted-foreground">
{{ data.hall.description }}
</p>
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<template v-if="!data.hall.isStandard">
<UserHoverCard
:user-id="data.hall.userId"
:user-name="data.hall.userName"
:user-color="data.hall.userColor"
:user-avatar-url="data.hall.userAvatarUrl"
>
<NuxtLink
:to="`/user/${data.hall.userId}`"
class="font-medium hover:underline"
:style="data.hall.userColor ? { color: data.hall.userColor } : undefined"
>
{{ data.hall.userName }}
</NuxtLink>
</UserHoverCard>
<span>•</span>
</template>
<span>{{ $t('halls.itemsCount', { n: data.items.length }) }}</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<Button
v-if="data.hall.canModifyItems && data.items.length"
variant="outline"
size="sm"
class="cursor-pointer gap-1.5"
@click="manageMode = !manageMode"
>
<Icon :name="manageMode ? 'ph:check-bold' : 'ph:arrows-down-up-bold'" class="size-4" />
{{ manageMode ? $t('common.done') : $t('halls.organize') }}
</Button>
<Button
v-if="data.hall.canEdit"
variant="outline"
size="sm"
class="cursor-pointer gap-1.5"
@click="openEditDialog"
>
{{ $t('halls.edit') }}
</Button>
<Popover v-if="data.hall.canEdit" v-model:open="deleteConfirmOpen">
<PopoverAnchor as-child>
<Button
variant="ghost"
size="icon-sm"
class="cursor-pointer text-muted-foreground hover:text-destructive"
:aria-label="$t('common.delete')"
@click="deleteConfirmOpen = true"
>
<Icon name="ph:trash-fill" class="size-4" />
</Button>
</PopoverAnchor>
<PopoverContent v-if="deleteConfirmOpen" side="bottom" align="end" class="w-60">
<p class="text-sm font-medium">
{{ $t('halls.deleteTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('halls.deleteDesc', { title: data.hall.title }) }}
</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="deleting" @click="deleteConfirmOpen = false">
{{ $t('common.cancel') }}
</Button>
<Button variant="destructive" size="sm" :disabled="deleting" @click="deleteHall">
<Spinner v-if="deleting" />
{{ $t('common.delete') }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Item-Grid vollbreit wie auf der Main - die Center-Kappe oben würde
die Zellen (12 Spalten) gegenüber der Startseite schmaler machen -->
<template v-if="data && !error">
<div class="px-4 pb-8 lg:px-6">
<section class="mt-6">
<p v-if="!data.items.length" class="text-sm text-muted-foreground">
{{ $t('halls.empty') }}
</p>
<!-- Sortier-/Verwaltungsansicht -->
<div v-else-if="manageMode" class="flex max-w-3xl flex-col gap-2">
<div
v-for="(item, idx) in data.items"
:key="item.id"
class="flex items-center gap-3 rounded-md bg-muted/40 px-3 py-2 text-sm"
>
<span class="w-6 text-center text-xs tabular-nums text-muted-foreground">
{{ idx + 1 }}
</span>
<NuxtImg
v-if="item.thumbUrl"
:src="item.thumbUrl"
alt=""
width="56"
height="56"
fit="cover"
class="size-12 shrink-0 rounded-sm object-cover"
/>
<span v-else class="flex size-12 shrink-0 items-center justify-center rounded-sm bg-muted">
<Icon name="ph:file-fill" class="size-5 text-muted-foreground" />
</span>
<div class="min-w-0 flex-1">
<NuxtLink :to="`/t/${item.id}`" class="block truncate font-medium hover:underline">
{{ item.title }}
</NuxtLink>
<span
class="text-xs"
:style="item.userColor ? { color: item.userColor } : undefined"
>
{{ item.userName }}
</span>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
:disabled="idx === 0 || reordering"
:aria-label="$t('halls.moveUp')"
@click="moveItem(idx, -1)"
>
<Icon name="ph:arrow-up-bold" class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
:disabled="idx === data.items.length - 1 || reordering"
:aria-label="$t('halls.moveDown')"
@click="moveItem(idx, 1)"
>
<Icon name="ph:arrow-down-bold" class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-muted-foreground hover:text-destructive"
:disabled="removingUploadId === item.id"
:aria-label="$t('common.remove')"
@click="removeUploadFromHall(item.id)"
>
<Icon name="ph:x-bold" class="size-4" />
</Button>
</div>
</div>
</div>
<!-- Normales Grid -->
<div
v-else
class="grid grid-flow-dense grid-cols-4 gap-2 [grid-auto-rows:6.5rem] sm:grid-cols-6 sm:[grid-auto-rows:7rem] md:grid-cols-8 lg:grid-cols-12 lg:[grid-auto-rows:7.5rem]"
>
<UploadTile
v-for="upload in data.items"
:key="upload.id"
:upload="upload"
:from-path="`/halls/${data.hall.id}`"
:from-label="data.hall.title"
force-uniform
/>
</div>
</section>
</div>
<Dialog v-model:open="editOpen">
<DialogContent v-if="editOpen" class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('halls.edit') }}</DialogTitle>
<DialogDescription>{{ $t('halls.createDesc') }}</DialogDescription>
</DialogHeader>
<form class="flex flex-col gap-4" @submit.prevent="saveEdit">
<div class="flex flex-col gap-1.5">
<Label for="edit-hall-title">{{ $t('common.title') }}</Label>
<Input id="edit-hall-title" v-model="editTitle" maxlength="100" />
</div>
<div class="flex flex-col gap-1.5">
<Label for="edit-hall-desc">{{ $t('halls.description') }}</Label>
<Textarea
id="edit-hall-desc"
v-model="editDescription"
maxlength="500"
rows="3"
class="resize-none"
/>
</div>
<div v-if="!editIsStandard" class="flex flex-col gap-1.5">
<Label>{{ $t('halls.visibilityLabel') }}</Label>
<Select v-model="editVisibility">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="public">
{{ $t('halls.visibility.public') }}
</SelectItem>
<SelectItem value="unlisted">
{{ $t('halls.visibility.unlisted') }}
</SelectItem>
<SelectItem value="private">
{{ $t('halls.visibility.private') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<label
v-if="data.hall.canToggleStandard"
class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground"
>
<Checkbox v-model="editIsStandard" />
{{ $t('halls.asStandard') }}
</label>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" :disabled="savingEdit" @click="editOpen = false">
{{ $t('common.cancel') }}
</Button>
<Button type="submit" :disabled="!editTitle.trim() || savingEdit">
<Spinner v-if="savingEdit" />
{{ $t('common.save') }}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</template>
</template>
+239
View File
@@ -0,0 +1,239 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import HallCard, { type HallCardItem } from '@/components/HallCard.vue'
import type { HallVisibility } from '@/components/AddToHallPopover.vue'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
import { Textarea } from '@/components/ui/textarea'
const { user, can } = useAuth()
const { t } = useI18n()
useHead({ title: computed(() => t('nav.halls')) })
const { data, pending, refresh } = await useFetch<{
standard: HallCardItem[]
mine: HallCardItem[]
community: HallCardItem[]
}>('/api/halls', {
default: () => ({ standard: [], mine: [], community: [] }),
})
const createDialogOpen = ref(false)
const newTitle = ref('')
const newDescription = ref('')
const newVisibility = ref<HallVisibility>('public')
const newIsStandard = ref(false)
const creating = ref(false)
const canManageStandard = computed(() => can('hall.standard'))
function openCreateDialog() {
newTitle.value = ''
newDescription.value = ''
newVisibility.value = 'public'
newIsStandard.value = false
createDialogOpen.value = true
}
async function createHall() {
const title = newTitle.value.trim()
if (!title || creating.value) return
creating.value = true
try {
const res = await $fetch<{ hall: HallCardItem }>('/api/halls', {
method: 'POST',
body: {
title,
description: newDescription.value.trim() || null,
visibility: newIsStandard.value ? 'public' : newVisibility.value,
isStandard: newIsStandard.value,
},
})
createDialogOpen.value = false
toast.success(t('halls.created'))
await refresh()
if (res.hall?.id) {
await navigateTo(`/halls/${res.hall.id}`)
}
}
catch {
toast.error(t('halls.createFailed'))
}
finally {
creating.value = false
}
}
</script>
<template>
<div class="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-semibold tracking-tight">
{{ $t('halls.title') }}
</h1>
<p class="text-sm text-muted-foreground">
{{ $t('halls.subtitle') }}
</p>
</div>
<Button v-if="user" class="cursor-pointer gap-1.5" @click="openCreateDialog()">
<Icon name="ph:plus-bold" class="size-4" />
{{ $t('halls.newHall') }}
</Button>
</div>
<div v-if="pending" class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="aspect-video rounded-lg" />
</div>
<template v-else>
<!-- Sektion 1: Standard-Hallen (immer für alle sichtbar) -->
<section class="flex flex-col gap-3">
<h2 class="text-lg font-semibold tracking-tight">
{{ $t('halls.standardSection') }}
</h2>
<p v-if="!data.standard.length" class="text-sm text-muted-foreground">
{{ $t('halls.noStandard') }}
</p>
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<HallCard
v-for="hall in data.standard"
:key="hall.id"
:hall="hall"
/>
</div>
</section>
<!-- Sektion 2: Eigene Hallen -->
<section v-if="user" class="flex flex-col gap-3">
<h2 class="text-lg font-semibold tracking-tight">
{{ $t('halls.mineSection') }}
</h2>
<p v-if="!data.mine.length" class="text-sm text-muted-foreground">
{{ $t('halls.noOwnYet') }}
</p>
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<HallCard
v-for="hall in data.mine"
:key="hall.id"
:hall="hall"
/>
</div>
</section>
<!-- Sektion 3: Öffentliche Hallen anderer User -->
<section class="flex flex-col gap-3">
<h2 class="text-lg font-semibold tracking-tight">
{{ $t('halls.communitySection') }}
</h2>
<p v-if="!data.community.length" class="text-sm text-muted-foreground">
{{ $t('halls.noCommunity') }}
</p>
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<HallCard
v-for="hall in data.community"
:key="hall.id"
:hall="hall"
/>
</div>
</section>
</template>
<Dialog v-model:open="createDialogOpen">
<DialogContent v-if="createDialogOpen" class="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{{ $t('halls.newHall') }}
</DialogTitle>
<DialogDescription>
{{ $t('halls.createDesc') }}
</DialogDescription>
</DialogHeader>
<form class="flex flex-col gap-4" @submit.prevent="createHall">
<div class="flex flex-col gap-1.5">
<Label for="hall-title">{{ $t('common.title') }}</Label>
<Input
id="hall-title"
v-model="newTitle"
:placeholder="$t('halls.titlePlaceholder')"
maxlength="100"
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="hall-desc">{{ $t('halls.description') }}</Label>
<Textarea
id="hall-desc"
v-model="newDescription"
:placeholder="$t('halls.descriptionPlaceholder')"
maxlength="500"
rows="3"
class="resize-none"
/>
</div>
<div v-if="!newIsStandard" class="flex flex-col gap-1.5">
<Label>{{ $t('halls.visibilityLabel') }}</Label>
<Select v-model="newVisibility">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="public">
{{ $t('halls.visibility.public') }} - {{ $t('halls.visibilityHint.public') }}
</SelectItem>
<SelectItem value="unlisted">
{{ $t('halls.visibility.unlisted') }} - {{ $t('halls.visibilityHint.unlisted') }}
</SelectItem>
<SelectItem value="private">
{{ $t('halls.visibility.private') }} - {{ $t('halls.visibilityHint.private') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<label
v-if="canManageStandard"
class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground"
>
<Checkbox v-model="newIsStandard" />
{{ $t('halls.asStandard') }}
</label>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" :disabled="creating" @click="createDialogOpen = false">
{{ $t('common.cancel') }}
</Button>
<Button type="submit" :disabled="!newTitle.trim() || creating">
<Spinner v-if="creating" />
{{ $t('halls.create') }}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</template>
+27 -7
View File
@@ -3,6 +3,7 @@ import { useDebounceFn, useEventListener } from '@vueuse/core'
import { toast } from 'vue-sonner'
import UploadFilters from '@/components/UploadFilters.vue'
import UploadTile from '@/components/UploadTile.vue'
import type { ThumbnailSize } from '~~/shared/utils/activityScore'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
@@ -25,6 +26,8 @@ interface UploadItem {
userColor: string | null
userAvatarUrl: string | null
commentCount: number
activityScore: number
thumbnailSize: ThumbnailSize
createdAt: string
}
@@ -42,12 +45,24 @@ interface UploadList {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const FILTER_KEYS = ['tag', 'type', 'uploader', 'days'] as const
const hasFilters = computed(() => FILTER_KEYS.some(k => typeof route.query[k] === 'string' && route.query[k]))
const FILTER_KEYS = ['tag', 'type', 'uploader', 'days', 'from', 'to'] as const
// Filter leben im Client-State (useUploadFilters) - nicht in der URL.
// Externe Links (?tag=, ?uploader=, ...) werden einmalig übernommen + entfernt.
const filters = useUploadFilters()
if (import.meta.client) {
watch(() => route.query, (query) => {
if (!filters.applyQuery(query)) return
const clean = { ...query }
for (const key of FILTER_KEYS) delete clean[key]
router.replace({ query: clean })
}, { immediate: true })
}
const hasFilters = filters.hasActive
const filterRowEl = ref<HTMLElement>()
const { cells, cols, ready } = usePageSize(filterRowEl)
const { uniform } = useGridMode()
const { limits } = useLimits()
// Bento-Skeleton-Layout: Wenn Bento aktiv ist, füllen wir das exakt gleiche
// Zeilen×Spalten-Budget mit einer Mischung aus 3x3-, 2x2- und 1x1-Blöcken,
@@ -105,16 +120,18 @@ const page = computed<number>({
})
// Filter-Wechsel setzt zurück auf Seite 1
watch(() => FILTER_KEYS.map(k => route.query[k]).join('|'), () => {
watch(() => [filters.tag.value, filters.types.value.join(','), filters.uploaderId.value, filters.dateFrom.value, filters.dateTo.value, filters.following.value], () => {
if (page.value !== 1) page.value = 1
})
const fetchUrl = computed(() => {
const params = new URLSearchParams()
for (const key of FILTER_KEYS) {
const value = route.query[key]
if (typeof value === 'string' && value) params.set(key, value)
}
if (filters.tag.value) params.set('tag', filters.tag.value)
if (filters.types.value.length) params.set('type', filters.types.value.join(','))
if (filters.uploaderId.value) params.set('uploader', filters.uploaderId.value)
if (filters.dateFrom.value) params.set('from', filters.dateFrom.value)
if (filters.dateTo.value) params.set('to', filters.dateTo.value)
if (filters.following.value) params.set('following', '1')
params.set('page', String(page.value))
params.set('cells', String(cells.value))
params.set('cols', String(cols.value))
@@ -142,6 +159,9 @@ watch([ready, fetchUrl], ([isReady], [wasReady]) => {
if (!wasReady) execute()
else refetch()
}, { immediate: true })
watch(() => [limits.value.thumbnailMediumPoints, limits.value.thumbnailLargePoints], () => {
if (ready.value) refetch()
})
const uploads = computed(() => result.value?.items ?? [])
const total = computed(() => result.value?.total ?? 0)
const pageCount = computed(() => result.value?.pageCount ?? 0)
+1 -1
View File
@@ -34,7 +34,7 @@ async function onSubmit() {
</script>
<template>
<div class="mx-auto w-full max-w-3xl px-4 lg:px-6">
<div class="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<Card>
<CardHeader>
<CardTitle>{{ $t('auth.loginTitle') }}</CardTitle>
+19 -29
View File
@@ -1022,19 +1022,17 @@ function canEditTemplate(tpl: MemeTemplate) {
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-4 lg:px-6">
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-4 sm:px-6 lg:px-8">
<div class="flex shrink-0 flex-wrap items-center justify-between gap-2">
<div>
<h1 class="font-heading text-xl font-bold">{{ $t('memes.title') }}</h1>
<h1 class="font-heading text-xl font-semibold tracking-tight">{{ $t('memes.title') }}</h1>
<p class="text-xs text-muted-foreground">{{ $t('memes.subtitle') }}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button v-if="user" size="sm" variant="outline" @click="openAddDialog">
<Icon name="ph:upload-simple-bold" class="size-4" />
{{ $t('memes.uploadTemplate') }}
</Button>
<Button size="sm" variant="outline" :disabled="!activeImageSrc" @click="downloadMeme">
<Icon name="ph:download-simple-bold" class="size-4" />
{{ $t('memes.download') }}
</Button>
<Button size="sm" :disabled="!activeImageSrc || publishing || !user" @click="publishMemeToMainPage">
@@ -1047,14 +1045,11 @@ function canEditTemplate(tpl: MemeTemplate) {
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-hidden lg:grid-cols-12">
<!-- LEFT: Template Gallery + Text Layer Controls -->
<div class="flex min-h-0 flex-col gap-3 overflow-hidden lg:col-span-6 xl:col-span-5">
<div class="flex min-h-0 flex-col gap-4 overflow-hidden lg:col-span-6 xl:col-span-5">
<Card class="flex min-h-0 flex-1 flex-col gap-2 py-3">
<CardHeader class="shrink-0 gap-1.5 px-4 pb-0">
<div class="flex items-center justify-between gap-2">
<CardTitle class="text-sm">{{ $t('memes.templates') }}</CardTitle>
<Button v-if="user" size="icon-sm" variant="ghost" :title="$t('memes.uploadTemplate')" @click="openAddDialog">
<Icon name="ph:plus-bold" class="size-4" />
</Button>
</div>
<Input
v-model="query"
@@ -1083,7 +1078,7 @@ function canEditTemplate(tpl: MemeTemplate) {
<div
v-for="i in 8"
:key="i"
class="flex flex-col overflow-hidden rounded-md border bg-muted/20"
class="flex flex-col overflow-hidden rounded-md bg-muted/20"
>
<Skeleton class="aspect-square w-full rounded-none" />
<div class="space-y-1 px-2 py-1.5">
@@ -1097,8 +1092,8 @@ function canEditTemplate(tpl: MemeTemplate) {
v-for="tpl in filteredTemplates"
:key="tpl.id"
:class="cn(
'group relative flex flex-col overflow-hidden rounded-md border bg-muted/30 text-left transition-all hover:border-primary',
selectedTemplate?.id === tpl.id && !customImageUrl && 'ring-2 ring-primary border-primary',
'group relative flex flex-col overflow-hidden rounded-md bg-muted/30 text-left transition-all hover:bg-muted/60',
selectedTemplate?.id === tpl.id && !customImageUrl && 'ring-2 ring-primary',
)"
>
<button
@@ -1123,10 +1118,10 @@ function canEditTemplate(tpl: MemeTemplate) {
@error="markThumbLoaded(tpl.id)"
>
<div class="absolute top-1 left-1 flex flex-wrap gap-1">
<Badge v-if="tpl.isStandard" variant="default" class="px-1.5 py-0 text-[10px]">
<Badge v-if="tpl.isStandard" variant="default" class="px-1.5 py-0 text-xs">
{{ $t('memes.badgeStandard') }}
</Badge>
<Badge v-else-if="!tpl.isPublic" variant="secondary" class="px-1.5 py-0 text-[10px]">
<Badge v-else-if="!tpl.isPublic" variant="secondary" class="px-1.5 py-0 text-xs">
<Icon name="ph:lock-simple-fill" class="mr-0.5 size-2.5" />
{{ $t('memes.badgePrivate') }}
</Badge>
@@ -1135,7 +1130,7 @@ function canEditTemplate(tpl: MemeTemplate) {
<div class="flex items-center justify-between gap-1 px-2 py-1.5">
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium">{{ tpl.name }}</p>
<p class="truncate text-[10px] text-muted-foreground">{{ tpl.userName ?? '-' }}</p>
<p class="truncate text-xs text-muted-foreground">{{ tpl.userName ?? '-' }}</p>
</div>
<div v-if="canEditTemplate(tpl)" class="flex shrink-0 items-center gap-0.5">
<Tooltip v-if="tpl.userId === user?.id && !tpl.isStandard">
@@ -1228,7 +1223,7 @@ function canEditTemplate(tpl: MemeTemplate) {
{{ $t('memes.oracle.button') }}
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-80 space-y-3 p-3">
<PopoverContent align="end" class="w-80 space-y-3 p-4">
<div class="space-y-0.5">
<p class="text-sm font-semibold">{{ $t('memes.oracle.title') }}</p>
<p class="text-xs text-muted-foreground">{{ $t('memes.oracle.desc', { site: siteName }) }}</p>
@@ -1242,22 +1237,21 @@ function canEditTemplate(tpl: MemeTemplate) {
@keydown.enter.prevent="applyOracle(oracleVariant)"
/>
</div>
<div class="grid grid-cols-1 gap-1.5">
<div class="grid grid-cols-1 gap-2">
<button
v-for="v in (['cornersYesNo', 'corners8Ball', 'percentScale'] as const)"
:key="v"
type="button"
:class="cn(
'flex items-center justify-between gap-2 rounded-md border px-2.5 py-2 text-left text-xs transition-colors cursor-pointer',
'flex items-center gap-2 rounded-md border px-3 py-2 text-left text-xs transition-colors cursor-pointer',
oracleActive && oracleVariant === v ? 'border-primary bg-primary/10' : 'hover:bg-accent',
)"
@click="applyOracle(v)"
>
<div>
<p class="font-medium">{{ $t(`memes.oracle.variants.${v}.title`) }}</p>
<p class="text-[11px] text-muted-foreground">{{ $t(`memes.oracle.variants.${v}.desc`) }}</p>
<p class="text-xs text-muted-foreground">{{ $t(`memes.oracle.variants.${v}.desc`) }}</p>
</div>
<Icon name="ph:shuffle-bold" class="size-4 shrink-0 text-muted-foreground" />
</button>
</div>
<div class="flex gap-2">
@@ -1278,17 +1272,16 @@ function canEditTemplate(tpl: MemeTemplate) {
</PopoverContent>
</Popover>
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="addLayer">
<Icon name="ph:plus-bold" class="size-3.5" />
{{ $t('memes.addLayer') }}
</Button>
</div>
</div>
<p v-if="oracleActive" class="flex items-center gap-1.5 text-[11px] text-primary">
<p v-if="oracleActive" class="flex items-center gap-1.5 text-xs text-primary">
<Icon name="ph:sparkle-fill" class="size-3.5 shrink-0" />
{{ $t('memes.oracle.secretHint', { site: siteName }) }}
</p>
</CardHeader>
<CardContent class="min-h-0 flex-1 space-y-2.5 overflow-y-auto px-4">
<CardContent class="min-h-0 flex-1 space-y-3 overflow-y-auto px-4">
<!-- Layer list pills -->
<div class="flex flex-wrap gap-1.5">
<button
@@ -1296,9 +1289,9 @@ function canEditTemplate(tpl: MemeTemplate) {
:key="layer.id"
type="button"
:class="cn(
'flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer',
'flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer',
activeLayerId === layer.id
? 'border-primary bg-primary text-primary-foreground'
? 'bg-primary text-primary-foreground'
: 'bg-muted/40 hover:bg-accent',
)"
@click="activeLayerId = layer.id"
@@ -1310,7 +1303,7 @@ function canEditTemplate(tpl: MemeTemplate) {
<Separator />
<div v-if="activeLayer" class="space-y-2.5">
<div v-if="activeLayer" class="space-y-3">
<div class="space-y-1">
<div class="flex items-center justify-between">
<Label class="text-xs">{{ $t('memes.textLabel') }}</Label>
@@ -1321,7 +1314,6 @@ function canEditTemplate(tpl: MemeTemplate) {
class="h-5 px-1.5 text-xs text-destructive hover:text-destructive"
@click="removeLayer(activeLayer.id)"
>
<Icon name="ph:trash-fill" class="size-3" />
{{ $t('common.delete') }}
</Button>
</div>
@@ -1469,7 +1461,6 @@ function canEditTemplate(tpl: MemeTemplate) {
<CardDescription class="text-xs">{{ $t('memes.stageHint') }}</CardDescription>
</div>
<Button size="sm" variant="ghost" class="h-7 px-2.5 text-xs" @click="resetLayers">
<Icon name="ph:arrow-counter-clockwise-bold" class="size-3.5" />
{{ $t('common.reset') }}
</Button>
</div>
@@ -1723,12 +1714,11 @@ function canEditTemplate(tpl: MemeTemplate) {
@update:model-value="v => { if (v?.[0] != null) cropZoom = v[0] / 100 }"
/>
</div>
<div class="flex justify-end gap-2 pt-1">
<div class="flex justify-end gap-2 pt-2">
<Button size="sm" variant="ghost" @click="closeCropper(true)">
{{ $t('common.cancel') }}
</Button>
<Button size="sm" @click="applyCrop">
<Icon name="ph:crop-bold" class="size-4" />
{{ $t('memes.crop.apply') }}
</Button>
</div>
+306 -55
View File
@@ -26,6 +26,7 @@ const { t } = useI18n()
const { user } = useAuth()
const dms = useDirectMessages()
const timeAgo = useTimeAgo()
const { byName } = useStickers()
const { limits } = useLimits()
const maxDmAttachments = computed(() => limits.value.dmMaxAttachments)
const maxAttachmentSize = computed(() => limits.value.uploadMaxSizeMb * 1024 * 1024)
@@ -81,6 +82,9 @@ const pendingFiles = ref<File[]>([])
const voicePending = ref(new Set<File>())
// Serverseitige Rauschunterdrückung - nsPending markiert die Dateien dafür
const { enabled: nsEnabled, toggle: nsToggle } = useNoiseSuppression()
// Sticker-Popover rendert per Portal ausserhalb des Composers - der
// Hover-Reveal des NS-Buttons darf dann nicht einklappen
const stickerPickerOpen = ref(false)
const nsPending = ref(new Set<File>())
const pendingPreviews = new Map<File, string>()
const fileInputRef = ref<HTMLInputElement>()
@@ -233,6 +237,9 @@ async function markGroupRead() {
const id = groupId.value
if (!id) return
dms.markGroupRead(id)
// Offene Einladung: lokal als gesehen markieren, aber keinen
// Read-State setzen (dazu waere Mitgliedschaft noetig)
if (pendingInvite.value) return
await $fetch(`/api/messages/groups/${id}/read`, { method: 'POST' }).catch(() => {})
}
@@ -361,16 +368,62 @@ function onLiveMessage(msg: DmMessage, other: DmUser | null, grp: DmGroup | null
// Mitglieder-Updates der geöffneten Gruppe (Einladung eines neuen Nutzers)
function onGroupUpdate(g: DmGroup) {
if (g.id === groupId.value) group.value = g
if (g.id !== groupId.value) return
group.value = g
// Entfernt worden / anderweitig ausgetreten → Ansicht schließen
if (!g.members.some(m => m.id === user.value?.id)) closeConversation()
}
// Mitgliederliste: Kick nur für den Ersteller, Verlassen für alle
const membersOpen = ref(false)
const isGroupCreator = computed(() => !!user.value && group.value?.createdBy === user.value.id)
const memberBusyId = ref<number | null>(null)
const leavingGroup = ref(false)
async function kickMember(memberId: number) {
const gid = groupId.value
if (!gid || memberBusyId.value !== null) return
memberBusyId.value = memberId
try {
const updated = await $fetch<DmGroup>(`/api/messages/groups/${gid}/members/${memberId}`, { method: 'DELETE' })
group.value = updated
dms.handleGroupUpdate(updated)
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('dm.kickFailed'))
}
finally {
memberBusyId.value = null
}
}
async function leaveGroup() {
const gid = groupId.value
if (!gid || leavingGroup.value) return
leavingGroup.value = true
try {
await $fetch(`/api/messages/groups/${gid}/leave`, { method: 'POST' })
membersOpen.value = false
dms.removeGroupConversation(gid)
closeConversation()
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('dm.leaveFailed'))
}
finally {
leavingGroup.value = false
}
}
// Reaction-State pro Nachricht - gleiche Semantik wie useCommentReactions
function applyDmReaction(m: DmMessage, e: { emoji: string, actorId: number, actorName: string, active: boolean, count: number }) {
function applyDmReaction(m: DmMessage, e: { emoji: string, actorId: number, actorName: string, active: boolean, count: number, sticker?: boolean }) {
m.reactions ??= []
let entry = m.reactions.find(r => r.emoji === e.emoji)
if (e.active) {
if (!entry) {
entry = { emoji: e.emoji, count: 0, me: false, names: [] }
entry = { emoji: e.emoji, count: 0, me: false, names: [], sticker: e.sticker }
m.reactions.push(entry)
}
if (!entry.names.includes(e.actorName)) entry.names.push(e.actorName)
@@ -393,7 +446,7 @@ function onLiveReaction(e: DmReactionEvent) {
}
}
async function toggleReaction(m: DmMessage, emoji: string) {
async function toggleReaction(m: DmMessage, emoji: string, sticker = false) {
const me = user.value
if (!me) return
const before = m.reactions?.find(r => r.emoji === emoji)
@@ -404,16 +457,17 @@ async function toggleReaction(m: DmMessage, emoji: string) {
actorName: me.name,
active: !before?.me,
count: (before?.count ?? 0) + (before?.me ? -1 : 1),
sticker,
})
try {
const res = await $fetch<{ active: boolean, count: number }>(`/api/messages/reactions/${m.id}`, {
method: 'POST',
body: { emoji },
body: { emoji, sticker },
})
applyDmReaction(m, { emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count })
applyDmReaction(m, { emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count, sticker })
}
catch {
applyDmReaction(m, { emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0 })
applyDmReaction(m, { emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0, sticker })
toast.error(t('comments.reactionFailed'))
}
}
@@ -529,6 +583,52 @@ const groupTitle = computed(() => {
return (others.length ? others : g.members).map(m => m.name).join(', ')
})
// Offene Einladung in die geöffnete Gruppe - zeigt die Invite-Card
// statt Verlauf/Composer an
const pendingInvite = computed(() => dms.isPendingInvite(group.value))
const inviterName = computed(() => {
const me_ = group.value?.members.find(m => m.id === user.value?.id)
return me_?.invitedBy ? (group.value?.members.find(m => m.id === me_.invitedBy)?.name ?? null) : null
})
const inviteResponding = ref(false)
async function acceptInvite() {
const gid = groupId.value
if (!gid || inviteResponding.value) return
inviteResponding.value = true
try {
const updated = await $fetch<DmGroup>(`/api/messages/groups/${gid}/accept`, { method: 'POST' })
group.value = updated
dms.handleGroupUpdate(updated)
await loadGroup(gid)
markGroupRead()
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('dm.inviteAcceptFailed'))
}
finally {
inviteResponding.value = false
}
}
async function declineInvite() {
const gid = groupId.value
if (!gid || inviteResponding.value) return
inviteResponding.value = true
try {
await dms.declineGroupInvite(gid)
closeConversation()
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('dm.inviteDeclineFailed'))
}
finally {
inviteResponding.value = false
}
}
// Tab-Titel: Gruppenname, Chatpartner oder generisch „Nachrichten"
useHead({
title: computed(() => {
@@ -639,7 +739,7 @@ async function saveGroupEdit() {
<Icon name="ph:arrow-left" class="size-5" />
</Button>
<template v-if="group">
<Tooltip>
<Tooltip v-if="!pendingInvite">
<TooltipTrigger as-child>
<button
type="button"
@@ -653,62 +753,141 @@ async function saveGroupEdit() {
:alt="groupTitle"
class="size-full object-cover"
>
<Icon v-else name="ph:users-three-fill" class="size-4.5" />
<Icon v-else name="ph:users-three-fill" class="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.editGroup') }}</TooltipContent>
</Tooltip>
<span
v-else
class="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted text-muted-foreground"
>
<img
v-if="group.avatarUrl"
:src="group.avatarUrl"
:alt="groupTitle"
class="size-full object-cover"
>
<Icon v-else name="ph:users-three-fill" class="size-4" />
</span>
<div class="flex min-w-0 flex-col">
<span
class="truncate font-medium"
class="truncate font-semibold tracking-tight"
:style="group.color ? { color: group.color } : undefined"
>{{ groupTitle }}</span>
<span class="truncate text-xs text-muted-foreground">
{{ $t('dm.memberCount', { n: group.members.length }) }}
</span>
<button
type="button"
class="w-fit cursor-pointer truncate text-xs text-muted-foreground hover:text-foreground hover:underline"
@click="membersOpen = true"
>
{{ $t('dm.memberCount', { n: group.members.filter(m => !m.pending).length }) }}
</button>
</div>
<div v-if="!pendingInvite" class="ml-auto flex items-center gap-1">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-8 text-muted-foreground"
:aria-label="$t('dm.invite')"
@click="inviteOpen = true"
>
<Icon name="ph:user-plus-fill" class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.invite') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-8 cursor-pointer text-muted-foreground"
:aria-label="$t('dm.openFloating')"
@click="dms.openFloatingGroup(group); navigateTo('/')"
>
<Icon name="ph:picture-in-picture-fill" class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.openFloating') }}</TooltipContent>
</Tooltip>
</div>
</template>
<template v-else-if="partner">
<UserHoverCard
:user-id="partner.id"
:user-name="partner.name"
:user-color="partner.color"
:user-avatar-url="partner.avatarUrl"
>
<NuxtLink :to="`/user/${partner.id}`" class="flex items-center gap-2">
<Avatar class="size-8">
<AvatarImage v-if="partner.avatarUrl" :src="partner.avatarUrl" :alt="partner.name" />
<AvatarFallback
class="text-xs"
:style="partner.color ? { backgroundColor: partner.color, color: '#fff' } : undefined"
>
{{ partner.name.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span
class="font-semibold tracking-tight hover:underline"
:style="partner.color ? { color: partner.color } : undefined"
>{{ partner.name }}</span>
</NuxtLink>
</UserHoverCard>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="ml-auto size-8 text-muted-foreground"
:aria-label="$t('dm.invite')"
@click="inviteOpen = true"
class="ml-auto size-8 cursor-pointer text-muted-foreground"
:aria-label="$t('dm.openFloating')"
@click="dms.openFloatingChat(partner); navigateTo('/')"
>
<Icon name="ph:user-plus-fill" class="size-4" />
<Icon name="ph:picture-in-picture-fill" class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('dm.invite') }}</TooltipContent>
<TooltipContent>{{ $t('dm.openFloating') }}</TooltipContent>
</Tooltip>
</template>
<UserHoverCard
v-else-if="partner"
:user-id="partner.id"
:user-name="partner.name"
:user-color="partner.color"
:user-avatar-url="partner.avatarUrl"
>
<NuxtLink :to="`/user/${partner.id}`" class="flex items-center gap-2.5">
<Avatar class="size-8">
<AvatarImage v-if="partner.avatarUrl" :src="partner.avatarUrl" :alt="partner.name" />
<AvatarFallback
class="text-xs"
:style="partner.color ? { backgroundColor: partner.color, color: '#fff' } : undefined"
>
{{ partner.name.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span
class="font-medium hover:underline"
:style="partner.color ? { color: partner.color } : undefined"
>{{ partner.name }}</span>
</NuxtLink>
</UserHoverCard>
<h1 v-else class="text-sm font-semibold">{{ $t('dm.title') }}</h1>
<h1 v-else class="text-base font-semibold tracking-tight">{{ $t('dm.title') }}</h1>
</header>
<template v-if="partnerId || groupId">
<div v-if="group && pendingInvite" class="flex flex-1 flex-col items-center justify-center gap-4 px-4">
<div class="flex w-full max-w-sm flex-col items-center gap-4 rounded-lg border bg-card p-6 text-center shadow-sm">
<span class="flex size-12 items-center justify-center overflow-hidden rounded-md bg-muted text-muted-foreground">
<img
v-if="group.avatarUrl"
:src="group.avatarUrl"
:alt="groupTitle"
class="size-full object-cover"
>
<Icon v-else name="ph:users-three-fill" class="size-6" />
</span>
<div class="flex flex-col gap-1">
<p
class="font-semibold tracking-tight"
:style="group.color ? { color: group.color } : undefined"
>{{ groupTitle }}</p>
<p class="text-sm text-muted-foreground">
{{ inviterName ? $t('dm.invitedBy', { name: inviterName }) : $t('dm.invitedToGroup') }}
</p>
</div>
<div class="flex items-center gap-2">
<Button size="sm" :disabled="inviteResponding" @click="acceptInvite">
<Spinner v-if="inviteResponding" />
{{ $t('dm.acceptInvite') }}
</Button>
<Button size="sm" variant="outline" :disabled="inviteResponding" @click="declineInvite">
{{ $t('dm.declineInvite') }}
</Button>
</div>
</div>
</div>
<template v-else>
<div ref="listEl" class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 py-3">
<div v-if="hasMore" class="flex justify-center">
<Button variant="ghost" size="sm" :disabled="loadingOlder" @click="loadOlder">
@@ -749,7 +928,7 @@ async function saveGroupEdit() {
</AvatarFallback>
</Avatar>
<span
class="text-[11px] font-medium"
class="text-xs font-medium"
:style="senderOf(m)?.color ? { color: senderOf(m)!.color! } : undefined"
>{{ senderOf(m)?.name }}</span>
</div>
@@ -771,7 +950,7 @@ async function saveGroupEdit() {
</div>
<CommentContent v-if="m.content" :content="m.content" />
</div>
<DmReactionPicker @select="toggleReaction(m, $event)" />
<DmReactionPicker @select="(e, s) => toggleReaction(m, e, s)" />
</div>
<div
v-if="m.reactions?.length"
@@ -788,21 +967,29 @@ async function saveGroupEdit() {
? 'border-foreground/40 bg-secondary hover:border-foreground/60'
: 'border-border bg-muted/60 hover:border-foreground/30',
)"
@click="toggleReaction(m, r.emoji)"
@click="toggleReaction(m, r.emoji, r.sticker)"
>
<span class="text-base leading-none">{{ r.emoji }}</span>
<StickerMedia
v-if="r.sticker && byName.get(r.emoji)"
:sticker="byName.get(r.emoji)!"
variant="reaction"
/>
<span v-else-if="r.sticker" class="text-[10px] leading-none text-muted-foreground">:{{ r.emoji }}:</span>
<span v-else class="text-base leading-none">{{ r.emoji }}</span>
<span class="text-xs font-medium tabular-nums" :class="r.me ? 'text-foreground' : 'text-muted-foreground'">{{ r.count }}</span>
</button>
</TooltipTrigger>
<TooltipContent class="flex items-center gap-2.5 px-3 py-2">
<span class="text-4xl leading-none">{{ r.emoji }}</span>
<StickerMedia v-if="r.sticker && byName.get(r.emoji)" :sticker="byName.get(r.emoji)!" variant="compact" />
<span v-else-if="r.sticker" class="text-xl leading-none">:{{ r.emoji }}:</span>
<span v-else class="text-4xl leading-none">{{ r.emoji }}</span>
<span class="flex flex-col leading-snug">
<span v-for="n in r.names" :key="n">{{ n }}</span>
</span>
</TooltipContent>
</Tooltip>
</div>
<div class="mt-0.5 flex items-center gap-1 px-1 text-[11px] text-muted-foreground">
<div class="mt-0.5 flex items-center gap-1 px-1 text-xs text-muted-foreground">
<span>{{ timeAgo(m.createdAt) }}</span>
<template v-if="!groupId && m.id === lastOwnId">
·
@@ -815,11 +1002,12 @@ async function saveGroupEdit() {
</div>
<form ref="composerFormRef" class="border-t p-3" @submit.prevent="send" @keydown.enter.exact="onEnter" @keydown.ctrl.enter.exact="onEnter">
<div :class="cn('rounded-md transition-shadow', isOverComposerDrop && 'shadow-[0_0_0_2px_var(--color-primary)]')">
<div :class="cn('group/composer rounded-md transition-shadow', isOverComposerDrop && 'shadow-[0_0_0_2px_var(--color-primary)]')">
<MentionTextarea
ref="mentionAreaRef"
v-model="draft"
:placeholder="$t('dm.placeholder')"
@paste-files="addPendingFiles"
>
<div class="absolute right-2 bottom-2 flex items-center gap-0.5">
<Tooltip>
@@ -853,24 +1041,28 @@ async function saveGroupEdit() {
</TooltipTrigger>
<TooltipContent>{{ recording ? $t('comments.recordStop') : $t('comments.recordStart') }}</TooltipContent>
</Tooltip>
<!-- Rauschunterdrückung taucht erst bei Hover/Fokus im
Eingabefeld (oder während einer Aufnahme) auf; der
Aktiv-Zustand ist nur über die Füllung sichtbar -->
<Tooltip v-if="canRecord">
<TooltipTrigger as-child>
<button
type="button"
:class="cn(
'flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-all duration-100 hover:bg-accent hover:text-accent-foreground active:scale-90',
'flex h-8 cursor-pointer items-center justify-center overflow-hidden rounded-md text-muted-foreground transition-all duration-150 hover:bg-accent hover:text-accent-foreground active:scale-90',
recording || stickerPickerOpen ? 'w-8' : 'pointer-events-none w-0 opacity-0 group-hover/composer:pointer-events-auto group-hover/composer:w-8 group-hover/composer:opacity-100 group-focus-within/composer:pointer-events-auto group-focus-within/composer:w-8 group-focus-within/composer:opacity-100',
nsEnabled && 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 hover:text-primary-foreground',
)"
:aria-label="$t('comments.noiseSuppression')"
:aria-pressed="nsEnabled"
@click="nsToggle"
>
<Icon name="ph:waveform" class="size-5" />
<Icon name="ph:waveform" class="size-5 shrink-0" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('comments.noiseSuppression') }}</TooltipContent>
</Tooltip>
<StickerPicker @select="insertSticker" />
<StickerPicker v-model:open="stickerPickerOpen" @select="insertSticker" />
<Button
type="submit"
size="icon"
@@ -916,15 +1108,16 @@ async function saveGroupEdit() {
:aria-label="$t('comments.removeAttachment')"
@click="removePendingFile(i)"
>
<Icon name="ph:x" class="size-3.5" />
<Icon name="ph:x-bold" class="size-3.5" />
</button>
</span>
</div>
<div v-if="sending && sendProgress > 0" class="mt-2 flex items-center gap-2.5">
<div v-if="sending && sendProgress > 0" class="mt-2 flex items-center gap-2">
<Progress :model-value="sendProgress" class="flex-1" />
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ sendProgress }}&nbsp;%</span>
</div>
</form>
</template>
</template>
<div v-else class="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
@@ -934,6 +1127,64 @@ async function saveGroupEdit() {
</SidebarInset>
</SidebarProvider>
<!-- Mitgliederliste -->
<Dialog v-model:open="membersOpen">
<DialogContent v-if="group" class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{{ $t('dm.members') }}</DialogTitle>
</DialogHeader>
<ul class="flex max-h-72 flex-col gap-0.5 overflow-y-auto">
<li
v-for="m in group.members"
:key="m.id"
class="flex items-center gap-2 rounded-md px-2 py-1.5"
>
<Avatar class="size-7 shrink-0">
<AvatarImage v-if="m.avatarUrl" :src="m.avatarUrl" :alt="m.name" />
<AvatarFallback
class="text-[10px]"
:style="m.color ? { backgroundColor: m.color, color: '#fff' } : undefined"
>{{ m.name.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<span
class="min-w-0 flex-1 truncate text-sm font-medium"
:style="m.color ? { color: m.color } : undefined"
>{{ m.name }}</span>
<span
v-if="m.id === group.createdBy"
class="shrink-0 rounded-sm bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary-foreground"
>{{ $t('dm.creator') }}</span>
<span
v-else-if="m.pending"
class="shrink-0 rounded-sm border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>{{ $t('dm.pending') }}</span>
<button
v-if="isGroupCreator && m.id !== user?.id"
type="button"
class="shrink-0 cursor-pointer rounded-md p-1 text-muted-foreground hover:text-destructive disabled:opacity-50"
:disabled="memberBusyId !== null"
:aria-label="$t('dm.kickMember')"
@click="kickMember(m.id)"
>
<Spinner v-if="memberBusyId === m.id" />
<Icon v-else name="ph:x-bold" class="size-3.5" />
</button>
</li>
</ul>
<div v-if="!pendingInvite" class="flex justify-end border-t pt-3">
<Button
variant="destructive"
size="sm"
:disabled="leavingGroup"
@click="leaveGroup"
>
<Spinner v-if="leavingGroup" />
{{ $t('dm.leaveGroup') }}
</Button>
</div>
</DialogContent>
</Dialog>
<!-- Mitglied einladen -->
<Dialog v-model:open="inviteOpen">
<DialogContent class="sm:max-w-sm">
@@ -1001,7 +1252,7 @@ async function saveGroupEdit() {
:aria-label="$t('common.delete')"
@click="editColor = null"
>
<Icon name="ph:x" class="size-4" />
<Icon name="ph:x-bold" class="size-4" />
</button>
</div>
<div class="flex justify-end gap-2">
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import type { ModReport } from '~~/shared/utils/reports'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
definePageMeta({ middleware: 'mod' })
const { t } = useI18n()
const { can } = useAuth()
const timeAgo = useTimeAgo()
useHead({ title: computed(() => t('mod.title')) })
const status = ref<'open' | 'closed'>('open')
const reports = ref<ModReport[]>([])
const loading = ref(false)
const actingId = ref<number | null>(null)
const TARGET_ICONS = {
upload: 'ph:image-fill',
comment: 'ph:chat-fill',
user: 'ph:user-fill',
} as const
async function loadReports() {
loading.value = true
try {
const res = await $fetch<{ reports: ModReport[] }>('/api/mod/reports', {
query: { status: status.value },
})
reports.value = res.reports
}
catch {
toast.error(t('mod.loadFailed'))
}
finally {
loading.value = false
}
}
watch(status, loadReports)
onMounted(loadReports)
async function act(report: ModReport, action: 'resolve' | 'dismiss') {
if (actingId.value !== null) return
actingId.value = report.id
try {
await $fetch(`/api/mod/reports/${report.id}`, { method: 'POST', body: { action } })
reports.value = reports.value.filter(r => r.id !== report.id)
toast.success(t(action === 'resolve' ? 'mod.resolved' : 'mod.dismissed'))
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? t('mod.actionFailed'))
}
finally {
actingId.value = null
}
}
</script>
<template>
<div class="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-4 flex items-center justify-between gap-4">
<h1 class="text-xl font-semibold tracking-tight">{{ $t('mod.title') }}</h1>
<Tabs v-model="status">
<TabsList>
<TabsTrigger value="open" class="cursor-pointer">
{{ $t('mod.open') }}
</TabsTrigger>
<TabsTrigger value="closed" class="cursor-pointer">
{{ $t('mod.closed') }}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div v-if="loading" class="flex justify-center py-16">
<Spinner class="size-6" />
</div>
<p v-else-if="!reports.length" class="py-16 text-center text-sm text-muted-foreground">
{{ $t('mod.empty') }}
</p>
<ul v-else class="flex flex-col gap-3">
<li
v-for="report in reports"
:key="report.id"
class="flex flex-col gap-3 rounded-lg border bg-card p-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary" class="gap-1.5">
<Icon :name="TARGET_ICONS[report.target.type]" class="size-3.5" />
{{ $t(`mod.targetTypes.${report.target.type}`) }}
</Badge>
<Badge variant="outline">{{ $t(`report.reasons.${report.reason}`) }}</Badge>
<Badge v-if="report.status !== 'open'" :variant="report.status === 'resolved' ? 'default' : 'secondary'">
{{ $t(`mod.status.${report.status}`) }}
</Badge>
<span class="ml-auto text-xs text-muted-foreground">{{ timeAgo(report.createdAt) }}</span>
</div>
<div class="flex items-start gap-3">
<NuxtLink
v-if="report.target.thumb && report.target.url"
:to="report.target.url"
class="shrink-0"
>
<img
:src="report.target.thumb"
:alt="report.target.label"
class="size-14 rounded-md border object-cover"
>
</NuxtLink>
<div class="flex min-w-0 flex-col gap-1">
<NuxtLink
v-if="report.target.url"
:to="report.target.url"
class="truncate text-sm font-medium hover:underline"
>{{ report.target.label }}</NuxtLink>
<span v-else class="truncate text-sm font-medium">{{ report.target.label }}</span>
<span v-if="report.target.user" class="text-xs text-muted-foreground">
{{ $t('mod.reportedUser') }}:
<NuxtLink
:to="`/user/${report.target.user.id}`"
class="font-medium hover:underline"
:style="report.target.user.color ? { color: report.target.user.color } : undefined"
>{{ report.target.user.name }}</NuxtLink>
</span>
<p v-if="report.details" class="mt-1 rounded-md bg-muted/50 px-2 py-1 text-xs whitespace-pre-wrap">
{{ report.details }}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 border-t pt-3 text-xs text-muted-foreground">
<Avatar class="size-5">
<AvatarImage v-if="report.reporter.avatarUrl" :src="report.reporter.avatarUrl" :alt="report.reporter.name" />
<AvatarFallback
class="text-[8px]"
:style="report.reporter.color ? { backgroundColor: report.reporter.color, color: '#fff' } : undefined"
>{{ report.reporter.name.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<span>
{{ $t('mod.reportedBy') }}
<NuxtLink
:to="`/user/${report.reporter.id}`"
class="font-medium text-foreground hover:underline"
>{{ report.reporter.name }}</NuxtLink>
</span>
<span v-if="report.resolver" class="ml-2">
· {{ $t('mod.handledBy') }} {{ report.resolver.name }}
</span>
<div v-if="report.status === 'open' && can('report.moderate')" class="ml-auto flex items-center gap-2">
<Button size="sm" variant="outline" :disabled="actingId !== null" @click="act(report, 'dismiss')">
{{ $t('mod.dismiss') }}
</Button>
<Button size="sm" :disabled="actingId !== null" @click="act(report, 'resolve')">
<Spinner v-if="actingId === report.id" />
{{ $t('mod.resolve') }}
</Button>
</div>
</div>
</li>
</ul>
</div>
</template>
+422 -189
View File
@@ -1,11 +1,15 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { toast } from 'vue-sonner'
import { CARD_BANNER_ACCEPT, CARD_BANNER_MAX_SIZE_MB } from '~~/shared/utils/cardBanner'
import { CARD_BORDER_EFFECTS, type CardBorderEffectKey, isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
import { CARD_EFFECTS, type CardEffectKey } from '~~/shared/utils/cardEffects'
import { CARD_HOLO_EFFECTS, type CardHoloEffectKey, isCardHoloEffect } from '~~/shared/utils/cardHoloEffects'
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
import CardHoloEffect from '@/components/effects/CardHoloEffect.vue'
import CardVignette from '@/components/effects/CardVignette.vue'
import FileDropzone from '@/components/FileDropzone.vue'
import UserCardBanner from '@/components/UserCardBanner.vue'
import EffectAcidSquares from '@/components/effects/EffectAcidSquares.vue'
import EffectAurora from '@/components/effects/EffectAurora.vue'
import EffectCrtWarp from '@/components/effects/EffectCrtWarp.vue'
@@ -35,6 +39,9 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
const { user, refresh } = useAuth()
const { invalidate } = useUserSummaries()
@@ -63,7 +70,9 @@ async function deleteSticker(stickerId: number) {
}
}
const displayName = ref(user.value?.displayName ?? '')
const color = ref(user.value?.color ?? '#6366f1')
const bio = ref(user.value?.bio ?? '')
const cardEffect = ref<string>(user.value?.cardEffect ?? 'none')
const cardEffectColor = ref<string>(user.value?.cardEffectColor ?? '#6366f1')
const cardEffectColorEnabled = ref(!!user.value?.cardEffectColor)
@@ -71,8 +80,54 @@ const cardVignette = ref(user.value?.cardVignette ?? false)
const cardBorderEffect = ref<string>(user.value?.cardBorderEffect ?? 'none')
const cardBorderEffectColor = ref<string>(user.value?.cardBorderEffectColor ?? '#6366f1')
const cardBorderEffectColorEnabled = ref(!!user.value?.cardBorderEffectColor)
const cardHoloEffect = ref<string>(user.value?.cardHoloEffect ?? 'none')
const avatar = ref<File | null>(null)
const cardBanner = ref<File | null>(null)
const cardBannerRemoved = ref(false)
const saving = ref(false)
const settingsTab = ref<string | number>('general')
function updateLocale(value: unknown) {
if (value === 'de' || value === 'en') void setLocale(value)
}
// Live-Vorschau der Banner-Auswahl per Object-URL; widerrufen bei
// Ersetzen/Entfernen/Unmount, damit keine URLs leaken
const cardBannerObjectUrl = ref<string | null>(null)
watch(cardBanner, (file, _prev, onCleanup) => {
let url: string | null = null
if (file) {
// Komfort-Checks - der Server snifft die Bytes ohnehin nochmal
if (!CARD_BANNER_ACCEPT.split(',').includes(file.type)) {
toast.error(t('settings.cardBannerInvalid'))
cardBanner.value = null
return
}
if (file.size > CARD_BANNER_MAX_SIZE_MB * 1024 * 1024) {
toast.error(t('settings.cardBannerTooLarge', { n: CARD_BANNER_MAX_SIZE_MB }))
cardBanner.value = null
return
}
// Neue Auswahl hat Vorrang vor einer vorgemerkten Entfernung
cardBannerRemoved.value = false
url = URL.createObjectURL(file)
}
cardBannerObjectUrl.value = url
onCleanup(() => { if (url) URL.revokeObjectURL(url) })
})
onUnmounted(() => {
if (cardBannerObjectUrl.value) URL.revokeObjectURL(cardBannerObjectUrl.value)
})
const cardBannerPreview = computed(() => {
if (cardBanner.value && cardBannerObjectUrl.value) {
return { src: cardBannerObjectUrl.value, type: cardBanner.value.type.startsWith('video/') ? 'video' as const : 'image' as const }
}
if (!cardBannerRemoved.value && user.value?.cardBannerUrl && user.value.cardBannerType) {
return { src: user.value.cardBannerUrl, type: user.value.cardBannerType }
}
return null
})
const EFFECT_COMPONENTS: Record<CardEffectKey, Component> = {
'gradient-waves': EffectGradientWaves,
@@ -98,6 +153,11 @@ const previewEffect = computed(() =>
const previewBorder = computed<CardBorderEffectKey | null>(() =>
isCardBorderEffect(cardBorderEffect.value) ? cardBorderEffect.value : null,
)
const previewHolo = computed<CardHoloEffectKey | null>(() =>
isCardHoloEffect(cardHoloEffect.value) ? cardHoloEffect.value : null,
)
const previewHoloActive = computed(() => !!previewHolo.value)
const { holoStyle: previewHoloStyle, onPointerMove: onPreviewMove, onPointerLeave: onPreviewLeave } = useHoloTilt(previewHoloActive)
// Effekt-Farben sind optional - ohne Häkchen fällt der Effekt auf die Namensfarbe
const previewEffectColor = computed(() =>
cardEffectColorEnabled.value ? cardEffectColor.value : color.value,
@@ -105,11 +165,17 @@ const previewEffectColor = computed(() =>
const previewBorderColor = computed(() =>
cardBorderEffectColorEnabled.value ? cardBorderEffectColor.value : color.value,
)
const showPreview = computed(() => previewEffect.value || previewBorder.value)
const showPreview = computed(() => previewEffect.value || previewBorder.value || previewHolo.value || !!cardBannerPreview.value)
watch(() => user.value?.displayName, (n) => {
displayName.value = n ?? ''
})
watch(() => user.value?.color, (c) => {
if (c) color.value = c
})
watch(() => user.value?.bio, (b) => {
bio.value = b ?? ''
})
watch(() => user.value?.cardEffect, (e) => {
cardEffect.value = e ?? 'none'
})
@@ -127,6 +193,9 @@ watch(() => user.value?.cardBorderEffectColor, (c) => {
cardBorderEffectColorEnabled.value = !!c
if (c) cardBorderEffectColor.value = c
})
watch(() => user.value?.cardHoloEffect, (e) => {
cardHoloEffect.value = e ?? 'none'
})
async function save() {
if (saving.value || !user.value) return
@@ -134,12 +203,16 @@ async function save() {
try {
const formData = new FormData()
formData.append('color', color.value)
formData.append('bio', bio.value.trim())
formData.append('cardEffect', cardEffect.value === 'none' ? '' : cardEffect.value)
formData.append('cardEffectColor', cardEffectColorEnabled.value ? cardEffectColor.value : '')
formData.append('cardVignette', cardVignette.value ? '1' : '')
formData.append('cardBorderEffect', cardBorderEffect.value === 'none' ? '' : cardBorderEffect.value)
formData.append('cardBorderEffectColor', cardBorderEffectColorEnabled.value ? cardBorderEffectColor.value : '')
formData.append('cardHoloEffect', cardHoloEffect.value === 'none' ? '' : cardHoloEffect.value)
if (avatar.value) formData.append('avatar', avatar.value)
if (cardBannerRemoved.value) formData.append('removeCardBanner', '1')
if (cardBanner.value) formData.append('cardBanner', cardBanner.value)
await $fetch('/api/users/me', { method: 'POST', body: formData })
await refresh()
// Gecachte Darstellungen (Hover-Card, Profil, Nutzerliste, Uploader-Karte)
@@ -147,6 +220,8 @@ async function save() {
if (user.value) invalidate(user.value.id)
await refreshNuxtData()
avatar.value = null
cardBanner.value = null
cardBannerRemoved.value = false
toast.success(t('settings.saved'))
}
catch (err) {
@@ -160,200 +235,358 @@ async function save() {
</script>
<template>
<div class="mx-auto w-full max-w-2xl px-4 lg:px-6">
<Card v-if="user">
<CardHeader>
<CardTitle>{{ $t('settings.title') }}</CardTitle>
<CardDescription>{{ $t('settings.desc') }}</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" @submit.prevent="save">
<div class="space-y-1.5">
<Label>{{ $t('settings.language') }}</Label>
<Select :model-value="locale" @update:model-value="(v) => setLocale(v as string)">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="de">
Deutsch
</SelectItem>
<SelectItem value="en">
English
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label>{{ $t('settings.nameColor') }}</Label>
<div class="flex items-center gap-3">
<ColorPicker :value="(color as `#${string}`)" type="hex" @value-change="(val: any) => color = val.hex">
<button
type="button"
class="h-9 w-14 cursor-pointer rounded-md border border-input shadow-sm"
:style="{ backgroundColor: color }"
:aria-label="$t('settings.pickColor')"
/>
</ColorPicker>
<span class="text-sm font-medium" :style="{ color }">{{ user.name }}</span>
</div>
</div>
<div class="space-y-1.5">
<Label>{{ $t('settings.cardEffect') }}</Label>
<Select v-model="cardEffect">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('settings.noEffectPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{{ $t('settings.noEffect') }}
</SelectItem>
<SelectItem v-for="e in CARD_EFFECTS" :key="e.key" :value="e.key">
{{ e.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="cardEffect !== 'none'" class="flex flex-wrap items-center gap-x-5 gap-y-2 pt-1">
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox v-model="cardEffectColorEnabled" />
{{ $t('settings.ownEffectColor') }}
</label>
<ColorPicker v-if="cardEffectColorEnabled" :value="(cardEffectColor as `#${string}`)" type="hex" @value-change="(val: any) => cardEffectColor = val.hex">
<button
type="button"
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
:style="{ backgroundColor: cardEffectColor }"
:aria-label="$t('settings.pickEffectColor')"
/>
</ColorPicker>
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox v-model="cardVignette" />
{{ $t('settings.vignette') }}
</label>
</div>
</div>
<div class="space-y-1.5">
<Label>{{ $t('settings.cardBorder') }}</Label>
<Select v-model="cardBorderEffect">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('settings.noEffectPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{{ $t('settings.noEffect') }}
</SelectItem>
<SelectItem v-for="e in CARD_BORDER_EFFECTS" :key="e.key" :value="e.key">
{{ e.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="cardBorderEffect !== 'none'" class="flex items-center gap-3 pt-1">
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox v-model="cardBorderEffectColorEnabled" />
{{ $t('settings.ownBorderColor') }}
</label>
<ColorPicker v-if="cardBorderEffectColorEnabled" :value="(cardBorderEffectColor as `#${string}`)" type="hex" @value-change="(val: any) => cardBorderEffectColor = val.hex">
<button
type="button"
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
:style="{ backgroundColor: cardBorderEffectColor }"
:aria-label="$t('settings.pickBorderColor')"
/>
</ColorPicker>
</div>
</div>
<div v-if="showPreview" class="space-y-1.5">
<Label>{{ $t('settings.preview') }}</Label>
<div class="relative h-24 overflow-hidden rounded-md border bg-popover">
<component :is="previewEffect" v-if="previewEffect" :color="previewEffectColor" />
<div v-if="previewEffect" class="absolute inset-0 bg-popover/60" />
<CardVignette v-if="previewEffect && cardVignette" />
<span class="absolute inset-0 flex items-center justify-center text-sm font-medium" :style="{ color }">
{{ user.name }}
</span>
<CardBorderEffect v-if="previewBorder" :effect="previewBorder" :color="previewBorderColor" />
</div>
<p class="text-xs text-muted-foreground">
{{ $t('settings.previewHint') }}
</p>
</div>
<div class="space-y-1.5">
<Label>{{ $t('settings.avatar') }}</Label>
<div v-if="user.avatarUrl && !avatar" class="mb-2 flex items-center gap-2">
<NuxtImg
:src="user.avatarUrl"
:alt="$t('settings.currentAvatar')"
width="48"
height="48"
fit="cover"
class="size-12 rounded-lg"
/>
<span class="text-xs text-muted-foreground">{{ $t('settings.currentAvatar') }}</span>
</div>
<FileDropzone v-model="avatar" accept="image/*" :hint="t('upload.avatarHint', { n: limits.avatarMaxSizeMb })" />
</div>
<Button type="submit" class="w-full" :disabled="saving">
<div class="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<div v-if="user" class="space-y-4">
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight">{{ $t('settings.title') }}</h1>
<p class="text-sm text-muted-foreground">{{ $t('settings.desc') }}</p>
</div>
<Tabs v-model="settingsTab" class="gap-4">
<TabsList class="w-full">
<TabsTrigger value="general" class="cursor-pointer">
<Icon name="ph:gear-six-fill" />
{{ $t('settings.general') }}
</TabsTrigger>
<TabsTrigger value="card" class="cursor-pointer">
<Icon name="ph:identification-card-fill" />
{{ $t('settings.hoverCard') }}
</TabsTrigger>
<TabsTrigger value="media" class="cursor-pointer">
<Icon name="ph:image-fill" />
{{ $t('settings.media') }}
</TabsTrigger>
<TabsTrigger value="stickers" class="cursor-pointer">
<Icon name="ph:sticker-fill" />
{{ $t('settings.stickers') }}
</TabsTrigger>
</TabsList>
<form @submit.prevent="save">
<TabsContent value="general">
<Card>
<CardHeader>
<CardTitle>{{ $t('settings.general') }}</CardTitle>
<CardDescription>{{ $t('settings.generalDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-1.5">
<Label>{{ $t('settings.language') }}</Label>
<Select :model-value="locale" @update:model-value="updateLocale">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="de">
Deutsch
</SelectItem>
<SelectItem value="en">
English
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label>{{ $t('settings.nameColor') }}</Label>
<div class="flex items-center gap-3">
<ColorPicker :value="(color as `#${string}`)" type="hex" @value-change="(val: any) => color = val.hex">
<button
type="button"
class="h-9 w-14 cursor-pointer rounded-md border border-input shadow-sm"
:style="{ backgroundColor: color }"
:aria-label="$t('settings.pickColor')"
/>
</ColorPicker>
<span class="text-sm font-medium" :style="{ color }">{{ displayName || user.name }}</span>
</div>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<Label for="bio">{{ $t('settings.bio') }}</Label>
<span class="text-xs tabular-nums text-muted-foreground">{{ bio.length }} / 160</span>
</div>
<Textarea
id="bio"
v-model="bio"
:placeholder="$t('settings.bioPlaceholder')"
maxlength="160"
rows="3"
class="resize-none"
/>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="card">
<Card>
<CardHeader>
<CardTitle>{{ $t('settings.hoverCard') }}</CardTitle>
<CardDescription>{{ $t('settings.hoverCardDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-1.5">
<Label>{{ $t('settings.cardEffect') }}</Label>
<Select v-model="cardEffect">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('settings.noEffectPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{{ $t('settings.noEffect') }}
</SelectItem>
<SelectItem v-for="e in CARD_EFFECTS" :key="e.key" :value="e.key">
{{ e.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="cardEffect !== 'none'" class="flex flex-wrap items-center gap-x-5 gap-y-2 pt-1">
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox v-model="cardEffectColorEnabled" />
{{ $t('settings.ownEffectColor') }}
</label>
<ColorPicker v-if="cardEffectColorEnabled" :value="(cardEffectColor as `#${string}`)" type="hex" @value-change="(val: any) => cardEffectColor = val.hex">
<button
type="button"
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
:style="{ backgroundColor: cardEffectColor }"
:aria-label="$t('settings.pickEffectColor')"
/>
</ColorPicker>
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox v-model="cardVignette" />
{{ $t('settings.vignette') }}
</label>
</div>
</div>
<div class="space-y-1.5">
<Label>{{ $t('settings.cardBorder') }}</Label>
<Select v-model="cardBorderEffect">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('settings.noEffectPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{{ $t('settings.noEffect') }}
</SelectItem>
<SelectItem v-for="e in CARD_BORDER_EFFECTS" :key="e.key" :value="e.key">
{{ e.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="cardBorderEffect !== 'none'" class="flex items-center gap-3 pt-1">
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox v-model="cardBorderEffectColorEnabled" />
{{ $t('settings.ownBorderColor') }}
</label>
<ColorPicker v-if="cardBorderEffectColorEnabled" :value="(cardBorderEffectColor as `#${string}`)" type="hex" @value-change="(val: any) => cardBorderEffectColor = val.hex">
<button
type="button"
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
:style="{ backgroundColor: cardBorderEffectColor }"
:aria-label="$t('settings.pickBorderColor')"
/>
</ColorPicker>
</div>
</div>
<div class="space-y-1.5">
<Label>{{ $t('settings.cardHolo') }}</Label>
<Select v-model="cardHoloEffect">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('settings.noEffectPlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{{ $t('settings.noEffect') }}
</SelectItem>
<SelectItem v-for="e in CARD_HOLO_EFFECTS" :key="e.key" :value="e.key">
{{ e.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="showPreview" class="space-y-1.5">
<Label>{{ $t('settings.preview') }}</Label>
<div
:class="cn(
'relative mx-auto w-72 max-w-full overflow-hidden rounded-md border-2 bg-popover',
previewBorder && 'border-transparent',
previewHolo && 'shadow-[0_14px_28px_-6px_rgba(0,0,0,0.65)]',
)"
:style="{
...(!previewBorder ? { borderColor: color || 'var(--color-border)' } : {}),
...previewHoloStyle,
}"
@pointermove="onPreviewMove"
@pointerleave="onPreviewLeave"
>
<div v-if="cardBannerPreview" class="relative h-24">
<UserCardBanner :src="cardBannerPreview.src" :type="cardBannerPreview.type" />
<NuxtImg
v-if="user.avatarUrl"
:src="user.avatarUrl"
:alt="displayName || user.name"
width="56"
height="56"
fit="cover"
class="absolute -bottom-7 left-4 z-10 size-14 rounded-md ring-4 ring-popover"
/>
<span
v-else
class="absolute -bottom-7 left-4 z-10 flex size-14 items-center justify-center rounded-md text-lg font-medium ring-4 ring-popover"
:style="{ backgroundColor: color, color: '#fff' }"
>{{ (displayName || user.name).slice(0, 2).toUpperCase() }}</span>
</div>
<div
:class="cn('relative isolate overflow-hidden', cardBannerPreview ? 'p-4 pt-10' : 'h-28')"
>
<component :is="previewEffect" v-if="previewEffect" :color="previewEffectColor" />
<div v-if="previewEffect" class="absolute inset-0 bg-popover/60" />
<CardVignette v-if="previewEffect && cardVignette" />
<span
:class="cn(
cardBannerPreview
? 'relative z-10 block text-sm font-medium'
: 'absolute inset-0 z-10 flex items-center justify-center text-sm font-medium',
)"
:style="{ color }"
>
{{ displayName || user.name }}
</span>
</div>
<CardHoloEffect v-if="previewHolo" :effect="previewHolo" />
<CardBorderEffect v-if="previewBorder" :effect="previewBorder" :color="previewBorderColor" />
</div>
<p class="text-xs text-muted-foreground">
{{ $t('settings.previewHint') }}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="media">
<Card>
<CardHeader>
<CardTitle>{{ $t('settings.media') }}</CardTitle>
<CardDescription>{{ $t('settings.mediaDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-1.5">
<Label>{{ $t('settings.avatar') }}</Label>
<div v-if="user.avatarUrl && !avatar" class="mb-2 flex items-center gap-2">
<NuxtImg
:src="user.avatarUrl"
:alt="$t('settings.currentAvatar')"
width="48"
height="48"
fit="cover"
class="size-12 rounded-lg"
/>
<span class="text-xs text-muted-foreground">{{ $t('settings.currentAvatar') }}</span>
</div>
<FileDropzone v-model="avatar" accept="image/*" :hint="t('upload.avatarHint', { n: limits.avatarMaxSizeMb })" />
</div>
<div class="flex flex-col gap-1.5">
<Label>{{ $t('settings.cardBanner') }}</Label>
<div v-if="cardBannerRemoved" class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ $t('settings.cardBannerWillRemove') }}</span>
<button
type="button"
class="cursor-pointer text-primary underline"
@click="cardBannerRemoved = false"
>
{{ $t('common.cancel') }}
</button>
</div>
<div v-else-if="user.cardBannerUrl && !cardBanner" class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{{ $t('settings.currentCardBanner') }}</span>
<Button
type="button"
variant="ghost"
size="sm"
@click="cardBannerRemoved = true"
>
<Icon name="ph:trash-fill" class="size-4" />
{{ $t('common.remove') }}
</Button>
</div>
<FileDropzone v-model="cardBanner" :accept="CARD_BANNER_ACCEPT" :hint="t('settings.cardBannerHint', { n: CARD_BANNER_MAX_SIZE_MB })" />
</div>
</CardContent>
</Card>
</TabsContent>
<Button v-if="settingsTab !== 'stickers'" type="submit" class="mt-4 w-full" :disabled="saving">
<Spinner v-if="saving" />
{{ saving ? $t('common.saving') : $t('common.save') }}
</Button>
</form>
</CardContent>
</Card>
<Card v-if="user" class="mt-6">
<CardHeader>
<CardTitle>{{ $t('settings.stickers') }}</CardTitle>
<CardDescription>{{ $t('settings.stickersDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<ul class="flex flex-col gap-2">
<li
v-for="e in ownStickers"
:key="e.id"
class="flex items-center gap-3 rounded-md border px-3 py-2"
>
<StickerMedia :sticker="e" variant="tile" />
<span class="min-w-0 flex-1 truncate text-sm font-medium">:{{ e.name }}:</span>
<Popover
:open="stickerDeleteConfirm === e.id"
@update:open="v => { if (!deletingSticker) stickerDeleteConfirm = v ? e.id : null }"
>
<PopoverAnchor as-child>
<Button
variant="ghost"
size="icon-sm"
class="shrink-0 cursor-pointer text-muted-foreground hover:text-destructive"
:aria-label="$t('stickers.delete')"
@click="stickerDeleteConfirm = e.id"
<TabsContent value="stickers">
<Card>
<CardHeader>
<CardTitle>{{ $t('settings.stickers') }}</CardTitle>
<CardDescription>{{ $t('settings.stickersDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<ul class="flex flex-col gap-2">
<li
v-for="e in ownStickers"
:key="e.id"
class="flex items-center gap-3 rounded-md border px-3 py-2"
>
<Icon name="ph:trash-fill" class="size-4" />
</Button>
</PopoverAnchor>
<PopoverContent side="bottom" align="end" class="w-56">
<p class="text-sm font-medium">
{{ $t('stickers.deleteTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('stickers.deleteDesc', { name: e.name }) }}
</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="deletingSticker === e.id" @click="stickerDeleteConfirm = null">
{{ $t('common.cancel') }}
</Button>
<Button variant="destructive" size="sm" :disabled="deletingSticker === e.id" @click="deleteSticker(e.id)">
<Spinner v-if="deletingSticker === e.id" />
{{ deletingSticker === e.id ? $t('common.deleting') : $t('common.delete') }}
</Button>
</div>
</PopoverContent>
</Popover>
</li>
</ul>
<p v-if="!ownStickers.length" class="text-sm text-muted-foreground">
{{ $t('settings.stickersEmpty') }}
</p>
</CardContent>
</Card>
<StickerMedia :sticker="e" variant="tile" />
<span class="min-w-0 flex-1 truncate text-sm font-medium">:{{ e.name }}:</span>
<Popover
:open="stickerDeleteConfirm === e.id"
@update:open="v => { if (!deletingSticker) stickerDeleteConfirm = v ? e.id : null }"
>
<PopoverAnchor as-child>
<Button
variant="ghost"
size="icon-sm"
class="shrink-0 cursor-pointer text-muted-foreground hover:text-destructive"
:aria-label="$t('stickers.delete')"
@click="stickerDeleteConfirm = e.id"
>
<Icon name="ph:trash-fill" class="size-4" />
</Button>
</PopoverAnchor>
<PopoverContent side="bottom" align="end" class="w-56">
<p class="text-sm font-medium">
{{ $t('stickers.deleteTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('stickers.deleteDesc', { name: e.name }) }}
</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="deletingSticker === e.id" @click="stickerDeleteConfirm = null">
{{ $t('common.cancel') }}
</Button>
<Button variant="destructive" size="sm" :disabled="deletingSticker === e.id" @click="deleteSticker(e.id)">
<Spinner v-if="deletingSticker === e.id" />
{{ deletingSticker === e.id ? $t('common.deleting') : $t('common.delete') }}
</Button>
</div>
</PopoverContent>
</Popover>
</li>
</ul>
<p v-if="!ownStickers.length" class="text-sm text-muted-foreground">
{{ $t('settings.stickersEmpty') }}
</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
<p v-else class="text-center text-muted-foreground">
<NuxtLink to="/login" class="text-primary underline">
{{ $t('common.loginLink') }}
+518
View File
@@ -0,0 +1,518 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'vue-sonner'
import {
DISPLAY_NAME_DURATION_MS,
FLAIR_DURATION_MS,
FLAIR_MAX_LENGTH,
GOLDEN_FRAME_EFFECTS,
type GoldenFrameEffect,
NAME_GRADIENT_DURATION_MS,
SHOP_PRICE_DEFAULTS,
type ShopPriceKey,
type ShopPrices,
SPOTLIGHT_MAX_ACTIVE,
UPLOAD_BOOST_DURATION_MS,
UPLOAD_GOLDEN_DURATION_MS,
UPLOAD_SPOTLIGHT_DURATION_MS,
} from '~~/shared/utils/shop'
const { user, refresh } = useAuth()
const { t } = useI18n()
if (!user.value) {
await navigateTo('/')
}
interface ShopData {
balance: number
prices: ShopPrices
displayName: { name: string, expiresAt: string | null } | null
flair: { text: string, expiresAt: string } | null
nameGradientUntil: string | null
boostedUntil: string | null
goldenUntil: string | null
goldenEffect: GoldenFrameEffect
spotlightUntil: string | null
stickerSlots: { used: number, total: number, bonus: number }
spotlight: { active: number, max: number }
}
interface ShopUpload {
id: string
title: string
thumbUrl: string | null
}
const { data, status, refresh: refreshShop } = await useFetch<ShopData>('/api/shop')
const { data: uploadsData } = await useFetch<{ items: ShopUpload[] }>('/api/shop/uploads')
const ownUploads = computed(() => uploadsData.value?.items ?? [])
const spotlightPickerOpen = ref(false)
// Picker paginiert clientseitig - alle eigenen Uploads sind schon geladen
const PICKER_PAGE_SIZE = 24
const pickerPage = ref(1)
const pickerPageCount = computed(() => Math.max(1, Math.ceil(ownUploads.value.length / PICKER_PAGE_SIZE)))
const pagedUploads = computed(() =>
ownUploads.value.slice((pickerPage.value - 1) * PICKER_PAGE_SIZE, pickerPage.value * PICKER_PAGE_SIZE),
)
watch(spotlightPickerOpen, (open) => { if (open) pickerPage.value = 1 })
async function pickSpotlightUpload(uploadId: string) {
spotlightPickerOpen.value = false
await buy('upload_spotlight', { uploadId })
}
const hours = (ms: number) => Math.round(ms / 3_600_000)
const balance = computed(() => data.value?.balance ?? 0)
// Effektive Preise kommen vom Server (Admin-Overrides) - Defaults als Fallback
const price = (key: ShopPriceKey) => data.value?.prices?.[key] ?? SHOP_PRICE_DEFAULTS[key]
const activeName = computed(() => data.value?.displayName ?? null)
const activeFlair = computed(() => data.value?.flair ?? null)
const buying = ref<string | null>(null)
function until(until: string | null | undefined) {
return until ? new Date(until).toLocaleString() : ''
}
async function buy(item: string, body: Record<string, unknown> = {}) {
if (buying.value) return
buying.value = item
try {
const res = await $fetch<{ outcome?: { kind: string, detail?: string } | null }>('/api/shop/purchase', {
method: 'POST',
body: { item, ...body },
})
await Promise.all([refreshShop(), refresh()])
if (item === 'mystery_box' && res.outcome) {
toast.success(t(`shop.mystery.${res.outcome.kind}`, { n: res.outcome.detail ?? '' }))
}
else {
toast.success(t('shop.purchased'))
}
}
catch (e: any) {
toast.error(e?.data?.statusMessage || e?.statusMessage || t('shop.purchaseFailed'))
}
finally {
buying.value = null
}
}
// Effekt vor Ablauf abschalten - Punkte werden nicht erstattet
async function deactivate(item: string) {
if (buying.value) return
buying.value = `deactivate_${item}`
try {
await $fetch('/api/shop/deactivate', { method: 'POST', body: { item } })
await Promise.all([refreshShop(), refresh()])
toast.success(t('shop.deactivated'))
}
catch (e: any) {
toast.error(e?.data?.statusMessage || e?.statusMessage || t('shop.purchaseFailed'))
}
finally {
buying.value = null
}
}
// Anzeigename laeuft weiterhin ueber den eigenen Endpunkt (Namenskollisionen)
const newName = ref('')
const nameInput = ref<{ $el: HTMLElement } | null>(null)
async function buyDisplayName() {
if (buying.value || activeName.value) return
if (!newName.value.trim()) {
nameInput.value?.$el?.focus()
return
}
buying.value = 'display_name'
try {
await $fetch('/api/shop/display-name', {
method: 'POST',
body: { name: newName.value.trim() },
})
newName.value = ''
await Promise.all([refreshShop(), refresh()])
toast.success(t('shop.purchased'))
}
catch (e: any) {
toast.error(e?.data?.statusMessage || e?.statusMessage || t('shop.purchaseFailed'))
}
finally {
buying.value = null
}
}
// Nutzerweite Upload-Items: Key -> Payload-Feld + Item-Name + Preis-Key
const uploadItem = {
boost: { item: 'upload_boost', field: 'boostedUntil', priceKey: 'uploadBoost' },
golden: { item: 'upload_golden', field: 'goldenUntil', priceKey: 'uploadGolden' },
spotlight: { item: 'upload_spotlight', field: 'spotlightUntil', priceKey: 'uploadSpotlight' },
} as const
type UploadItemKey = keyof typeof uploadItem
function uploadItemUntil(key: UploadItemKey) {
return data.value?.[uploadItem[key].field] ?? null
}
const flairText = ref('')
const mysterySpinning = computed(() => buying.value === 'mystery_box')
// Animationsvariante des goldenen Rahmens - Praeferenz, kein Kauf
const goldenEffect = ref<GoldenFrameEffect>(data.value?.goldenEffect ?? 'beam')
watch(() => data.value?.goldenEffect, (e) => { if (e) goldenEffect.value = e })
async function saveGoldenEffect(effect: GoldenFrameEffect) {
goldenEffect.value = effect
try {
await $fetch('/api/shop/golden-effect', { method: 'POST', body: { effect } })
await refreshShop()
}
catch (e: any) {
toast.error(e?.data?.statusMessage || e?.statusMessage || t('shop.purchaseFailed'))
}
}
</script>
<template>
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<h1 class="text-xl font-semibold tracking-tight">{{ $t('shop.title') }}</h1>
<span class="text-sm text-muted-foreground">
{{ $t('shop.balance') }}: {{ balance }} {{ $t('misc.points') }}
</span>
</div>
<div v-if="status === 'pending'" class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Skeleton v-for="i in 6" :key="i" class="h-44 rounded-lg" />
</div>
<div v-else class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Anzeigename -->
<div class="flex flex-col gap-3 rounded-lg border bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t('shop.displayNameTitle') }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t('shop.displayNameDesc', { hours: hours(DISPLAY_NAME_DURATION_MS) }) }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ price('displayName') }}
</span>
</div>
<div v-if="activeName" class="space-y-1">
<p class="text-sm font-medium" :style="user?.color ? { color: user.color } : undefined">
{{ activeName.name }}
</p>
<div class="flex items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">
{{ $t('shop.activeUntil', { time: until(activeName.expiresAt) || '–' }) }}
</p>
<Button
variant="ghost"
size="sm"
class="h-auto cursor-pointer gap-1 px-2 py-1 text-xs text-muted-foreground"
:disabled="!!buying"
@click="deactivate('display_name')"
>
<Icon name="ph:power-bold" class="size-3.5" />
{{ $t('shop.deactivate') }}
</Button>
</div>
</div>
<div v-else class="mt-auto space-y-2">
<Input
ref="nameInput"
v-model="newName"
:placeholder="$t('shop.namePlaceholder')"
maxlength="50"
@keydown.enter="buyDisplayName"
/>
<Button class="w-full cursor-pointer font-semibold" :disabled="!!buying" @click="buyDisplayName">
{{ $t('shop.buy') }}
</Button>
</div>
</div>
<!-- Upload-Items: Boost / Goldener Rahmen / Spotlight -->
<div
v-for="key in (['boost', 'golden', 'spotlight'] as UploadItemKey[])"
:key="key"
class="flex flex-col gap-3 rounded-lg border bg-card p-4"
>
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t(`shop.${key}Title`) }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t(`shop.${key}Desc`, { hours: key === 'boost' ? hours(UPLOAD_BOOST_DURATION_MS) : key === 'golden' ? hours(UPLOAD_GOLDEN_DURATION_MS) : hours(UPLOAD_SPOTLIGHT_DURATION_MS) }) }}
</p>
<p v-if="key === 'spotlight'" class="text-xs text-muted-foreground">
{{ $t('shop.spotlightSlots', { n: data?.spotlight.active ?? 0, max: SPOTLIGHT_MAX_ACTIVE }) }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ price(uploadItem[key].priceKey) }}
</span>
</div>
<div v-if="key === 'golden'" class="mt-2 space-y-1">
<p class="text-xs text-muted-foreground">{{ $t('shop.goldenEffect') }}</p>
<Select :model-value="goldenEffect" @update:model-value="saveGoldenEffect($event as GoldenFrameEffect)">
<SelectTrigger class="h-8 w-full cursor-pointer text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="e in GOLDEN_FRAME_EFFECTS" :key="e" :value="e">
{{ $t(`shop.goldenEffect_${e}`) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="mt-auto space-y-2">
<div v-if="uploadItemUntil(key)" class="flex items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">
{{ $t('shop.activeUntil', { time: until(uploadItemUntil(key)) }) }}
</p>
<Button
variant="ghost"
size="sm"
class="h-auto cursor-pointer gap-1 px-2 py-1 text-xs text-muted-foreground"
:disabled="!!buying"
@click="deactivate(uploadItem[key].item)"
>
<Icon name="ph:power-bold" class="size-3.5" />
{{ $t('shop.deactivate') }}
</Button>
</div>
<Button
class="w-full cursor-pointer font-semibold"
:disabled="!!buying || !!uploadItemUntil(key) || (key === 'spotlight' && ((data?.spotlight.active ?? 0) >= SPOTLIGHT_MAX_ACTIVE || !ownUploads.length))"
@click="key === 'spotlight' ? (spotlightPickerOpen = true) : buy(uploadItem[key].item)"
>
{{ $t('shop.buy') }}
</Button>
</div>
</div>
<!-- Flair-Badge -->
<div class="flex flex-col gap-3 rounded-lg border bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t('shop.flairTitle') }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t('shop.flairDesc', { hours: hours(FLAIR_DURATION_MS) }) }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ price('flair') }}
</span>
</div>
<div v-if="activeFlair" class="space-y-1">
<p class="text-sm font-medium">{{ activeFlair.text }}</p>
<div class="flex items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">
{{ $t('shop.activeUntil', { time: until(activeFlair.expiresAt) }) }}
</p>
<Button
variant="ghost"
size="sm"
class="h-auto cursor-pointer gap-1 px-2 py-1 text-xs text-muted-foreground"
:disabled="!!buying"
@click="deactivate('flair')"
>
<Icon name="ph:power-bold" class="size-3.5" />
{{ $t('shop.deactivate') }}
</Button>
</div>
</div>
<div v-else class="mt-auto space-y-2">
<Input
v-model="flairText"
:placeholder="$t('shop.flairPlaceholder')"
:maxlength="FLAIR_MAX_LENGTH"
@keydown.enter="flairText.trim() && buy('flair', { flair: flairText.trim() })"
/>
<Button
class="w-full cursor-pointer font-semibold"
:disabled="!!buying || !flairText.trim()"
@click="buy('flair', { flair: flairText.trim() })"
>
{{ $t('shop.buy') }}
</Button>
</div>
</div>
<!-- Namensverlauf -->
<div class="flex flex-col gap-3 rounded-lg border bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t('shop.gradientTitle') }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t('shop.gradientDesc', { hours: hours(NAME_GRADIENT_DURATION_MS) }) }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ price('nameGradient') }}
</span>
</div>
<div v-if="data?.nameGradientUntil" class="flex items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">
{{ $t('shop.activeUntil', { time: until(data.nameGradientUntil) }) }}
</p>
<Button
variant="ghost"
size="sm"
class="h-auto cursor-pointer gap-1 px-2 py-1 text-xs text-muted-foreground"
:disabled="!!buying"
@click="deactivate('name_gradient')"
>
<Icon name="ph:power-bold" class="size-3.5" />
{{ $t('shop.deactivate') }}
</Button>
</div>
<Button
class="mt-auto w-full cursor-pointer font-semibold"
:disabled="!!buying || !!data?.nameGradientUntil"
@click="buy('name_gradient')"
>
{{ $t('shop.buy') }}
</Button>
</div>
<!-- Sticker-Slot -->
<div class="flex flex-col gap-3 rounded-lg border bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t('shop.stickerSlotTitle') }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t('shop.stickerSlotDesc') }}
</p>
<p class="text-xs text-muted-foreground">
{{ $t('shop.stickerSlots', { n: data?.stickerSlots.used ?? 0, max: data?.stickerSlots.total ?? 0 }) }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ price('stickerSlot') }}
</span>
</div>
<Button
class="mt-auto w-full cursor-pointer font-semibold"
:disabled="!!buying"
@click="buy('sticker_slot')"
>
{{ $t('shop.buy') }}
</Button>
</div>
<!-- Glueckskiste -->
<div class="flex flex-col gap-3 rounded-lg border border-primary/40 bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t('shop.mysteryTitle') }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t('shop.mysteryDesc') }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ price('mysteryBox') }}
</span>
</div>
<Button
class="mt-auto w-full cursor-pointer font-semibold"
:disabled="!!buying"
@click="buy('mystery_box')"
>
<Icon v-if="mysterySpinning" name="ph:circle-notch-bold" class="size-4 animate-spin" />
{{ $t('shop.mysteryBuy') }}
</Button>
</div>
</div>
<!-- Spotlight-Upload-Auswahl: großes Dialogfenster mit Thumbnail-Grid -->
<Dialog v-model:open="spotlightPickerOpen">
<DialogContent class="flex max-h-[90vh] w-[95vw] flex-col overflow-hidden sm:max-w-6xl">
<DialogHeader>
<DialogTitle>{{ $t('shop.spotlightPickTitle') }}</DialogTitle>
<DialogDescription>{{ $t('shop.spotlightPickDesc') }}</DialogDescription>
</DialogHeader>
<div class="grid min-h-0 flex-1 grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6">
<button
v-for="u in pagedUploads"
:key="u.id"
type="button"
class="group relative aspect-video cursor-pointer overflow-hidden rounded-md ring-accent transition hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
@click="pickSpotlightUpload(u.id)"
>
<NuxtImg
v-if="u.thumbUrl"
:src="u.thumbUrl"
:alt="u.title"
width="260"
height="146"
fit="cover"
class="h-full w-full object-cover"
/>
<span v-else class="flex h-full w-full items-center justify-center bg-muted px-1 text-xs text-muted-foreground">
{{ u.title }}
</span>
</button>
</div>
<Pagination
v-if="pickerPageCount > 1"
v-model:page="pickerPage"
:items-per-page="PICKER_PAGE_SIZE"
:total="ownUploads.length"
:sibling-count="1"
show-edges
class="justify-center pt-3"
>
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, i) in items" :key="i">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === pickerPage"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else />
</template>
<PaginationNext />
</PaginationContent>
</Pagination>
</DialogContent>
</Dialog>
</div>
</template>
+19 -21
View File
@@ -34,11 +34,11 @@ const fmt = (n: number) => numberFmt.value.format(n)
const statCards = computed(() => stats.value
? [
{ icon: 'ph:upload-simple-fill', label: t('stats.uploads'), value: stats.value.uploadsTotal },
{ icon: 'ph:chats-circle-fill', label: t('stats.comments'), value: stats.value.commentsTotal },
{ icon: 'ph:smiley-fill', label: t('stats.reactions'), value: stats.value.reactionsTotal },
{ icon: 'ph:heart-fill', label: t('stats.favorites'), value: stats.value.favoritesTotal },
{ icon: 'ph:users-fill', label: t('stats.users'), value: stats.value.usersTotal },
{ label: t('stats.uploads'), value: stats.value.uploadsTotal },
{ label: t('stats.comments'), value: stats.value.commentsTotal },
{ label: t('stats.reactions'), value: stats.value.reactionsTotal },
{ label: t('stats.favorites'), value: stats.value.favoritesTotal },
{ label: t('stats.users'), value: stats.value.usersTotal },
]
: [])
@@ -81,7 +81,7 @@ const typeLabel = (type: string) => te(`stats.type.${type}`) ? t(`stats.type.${t
const typeColor = (d: StatsTypePoint) => TYPE_COLORS[d.type] ?? 'var(--muted-foreground)'
const donutTriggers = computed(() => ({
[VisDonutSelectors.segment]: (d: { data: StatsTypePoint }) =>
`<div class="bg-popover text-popover-foreground ring-foreground/10 flex items-center gap-2 rounded-xl px-2.5 py-1.5 text-xs shadow-lg ring-1">
`<div class="bg-popover text-popover-foreground ring-foreground/10 flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs shadow-lg ring-1">
<span class="size-2.5 rounded-xs" style="background:${typeColor(d.data)}"></span>
<span class="font-medium">${typeLabel(d.data.type)}</span>
<span class="font-mono tabular-nums">${fmt(d.data.count)}</span>
@@ -110,17 +110,17 @@ const boardSections = computed(() => stats.value
key: 'weekly',
title: t('stats.weekly'),
boards: [
{ key: 'uploads', icon: 'ph:upload-simple-fill', title: t('stats.topUploaders'), list: stats.value.payload.weeklyUploaders ?? [] },
{ key: 'comments', icon: 'ph:chats-circle-fill', title: t('stats.topCommenters'), list: stats.value.payload.weeklyCommenters ?? [] },
{ key: 'uploads', title: t('stats.topUploaders'), list: stats.value.payload.weeklyUploaders ?? [] },
{ key: 'comments', title: t('stats.topCommenters'), list: stats.value.payload.weeklyCommenters ?? [] },
],
},
{
key: 'allTime',
title: t('stats.allTime'),
boards: [
{ key: 'uploads', icon: 'ph:upload-simple-fill', title: t('stats.topUploaders'), list: stats.value.payload.topUploaders },
{ key: 'comments', icon: 'ph:chats-circle-fill', title: t('stats.topCommenters'), list: stats.value.payload.topCommenters },
{ key: 'favorites', icon: 'ph:heart-fill', title: t('stats.topFavoriters'), list: stats.value.payload.topFavoriters },
{ key: 'uploads', title: t('stats.topUploaders'), list: stats.value.payload.topUploaders },
{ key: 'comments', title: t('stats.topCommenters'), list: stats.value.payload.topCommenters },
{ key: 'favorites', title: t('stats.topFavoriters'), list: stats.value.payload.topFavoriters },
],
},
]
@@ -128,9 +128,9 @@ const boardSections = computed(() => stats.value
</script>
<template>
<div class="px-4 pb-6 lg:px-6">
<div class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 class="text-xl font-semibold">
<h1 class="text-xl font-semibold tracking-tight">
{{ $t('stats.title') }}
</h1>
<span v-if="stats" class="text-xs text-muted-foreground">
@@ -146,15 +146,14 @@ const boardSections = computed(() => stats.value
<template v-else-if="stats">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
<Card v-for="card in statCards" :key="card.icon">
<CardHeader class="flex-row items-center gap-2 space-y-0 pb-2">
<Icon :name="card.icon" class="size-4 text-primary" />
<Card v-for="card in statCards" :key="card.label">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-medium text-muted-foreground">
{{ card.label }}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-bold tabular-nums">
<p class="text-2xl font-semibold tabular-nums">
{{ fmt(card.value) }}
</p>
</CardContent>
@@ -204,7 +203,7 @@ const boardSections = computed(() => stats.value
/>
</VisSingleContainer>
</ChartContainer>
<ul class="mt-3 space-y-1.5">
<ul class="mt-3 flex flex-col gap-1.5">
<li v-for="d in byType" :key="d.type" class="flex items-center gap-2 text-sm">
<span class="size-2.5 shrink-0 rounded-xs" :style="{ background: typeColor(d) }" />
<span class="min-w-0 flex-1 text-muted-foreground">{{ typeLabel(d.type) }}</span>
@@ -245,8 +244,7 @@ const boardSections = computed(() => stats.value
>
<Card v-for="board in section.boards" :key="board.key">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-sm font-medium">
<Icon :name="board.icon" class="size-4 text-primary" />
<CardTitle class="text-sm font-medium">
{{ board.title }}
</CardTitle>
</CardHeader>
@@ -264,7 +262,7 @@ const boardSections = computed(() => stats.value
<Avatar class="size-7 shrink-0">
<AvatarImage v-if="u.avatarUrl" :src="u.avatarUrl" :alt="u.name" />
<AvatarFallback
class="text-[10px]"
class="text-xs"
:style="u.color ? { backgroundColor: u.color, color: '#fff' } : undefined"
>
{{ u.name.slice(0, 2).toUpperCase() }}

Some files were not shown because too many files have changed in this diff Show More