Compare commits
58
Commits
a7a10fd16d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e679e316 | ||
|
|
e72413fd1f | ||
|
|
16c5ff8ea4 | ||
|
|
ccfbbd7c61 | ||
|
|
1d4ded2b99 | ||
|
|
44968b1557 | ||
|
|
3705a29c2b | ||
|
|
1461e5d3b4 | ||
|
|
7ef5652719 | ||
|
|
674e1b0ffd | ||
|
|
10319c77bf | ||
|
|
50f82f43bd | ||
|
|
83b32b7628 | ||
|
|
013e4c8add | ||
|
|
71d8432885 | ||
|
|
8924bcf688 | ||
|
|
4800080a04 | ||
|
|
de39c20dea | ||
|
|
94cf0aaa23 | ||
|
|
c13fe604f3 | ||
|
|
943f1d2ab4 | ||
|
|
8253682f6b | ||
|
|
244fa24c20 | ||
|
|
7d5f44b22d | ||
|
|
dace93c28a | ||
|
|
54a81c4642 | ||
|
|
8a0c3f57d5 | ||
|
|
ab0734a7dd | ||
|
|
f61f862e82 | ||
|
|
2c1af595e6 | ||
|
|
f82f28bee8 | ||
|
|
cbf1661a3b | ||
|
|
6041350f8a | ||
|
|
bf4bd38e84 | ||
|
|
34a1f803c7 | ||
|
|
e9561ddb2a | ||
|
|
1940ec29be | ||
|
|
d850e94161 | ||
|
|
9e0ff478ae | ||
|
|
1c187cce6e | ||
|
|
9e0c6bc06a | ||
|
|
6e7bcc9219 | ||
|
|
4b36cda2af | ||
|
|
fa462ffd97 | ||
|
|
3c6d11109e | ||
|
|
16c41dde3b | ||
|
|
db3b09e445 | ||
|
|
aedadd56ad | ||
|
|
47cb2c3c8e | ||
|
|
93aaef143f | ||
|
|
12b5aa2ad2 | ||
|
|
598df59671 | ||
|
|
305245c40f | ||
|
|
113af5f28f | ||
|
|
22639fe770 | ||
|
|
76f07f64d8 | ||
|
|
37e6ad3e04 | ||
|
|
b6eee52cc4 |
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
uploads
|
||||
data
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
docker-compose*.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -96,7 +96,6 @@ onUnmounted(() => {
|
||||
size="sm"
|
||||
@click.stop="select(null)"
|
||||
>
|
||||
<Icon name="ph:x-fill" class="size-4" />
|
||||
{{ $t('common.remove') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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') })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)"
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,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>) {
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LiveComment {
|
||||
userId: number | null
|
||||
isBot?: boolean
|
||||
userColor: string | null
|
||||
userNameGradient?: boolean
|
||||
userAvatarUrl: string | null
|
||||
uploadId: string
|
||||
attachments?: CommentAttachment[]
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
const { can } = useAuth()
|
||||
if (!can('mod.access')) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
+467
-65
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }} %</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">
|
||||
|
||||
@@ -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
@@ -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') }}
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user