feat: listen analytics — track who heard what and when
Add listen_events table to record opens, plays and listen duration per share link. Public POST/PATCH endpoints for fire-and-forget tracking from the listen page; authenticated GET analytics endpoint aggregates per version. Listen page gains optional name prompt after first play and sendBeacon on unload. Track page gains Analytics tab with stats grid and per-listener event list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { createShareLinkSchema, guestCommentSchema } from '@music-hub/shared';
|
||||
import { eq, and, asc, desc, inArray } from 'drizzle-orm';
|
||||
import { createShareLinkSchema, guestCommentSchema, updateListenEventSchema } from '@music-hub/shared';
|
||||
import {
|
||||
shareLinks,
|
||||
listenEvents,
|
||||
versions,
|
||||
tracks,
|
||||
projects,
|
||||
@@ -13,6 +14,11 @@ import {
|
||||
} from '@music-hub/db';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createDownloadUrl } from '../storage/s3.js';
|
||||
|
||||
async function hashIp(ip: string): Promise<string> {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip + 'musichub-salt'));
|
||||
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
||||
}
|
||||
import type { AppEnv } from '../types.js';
|
||||
|
||||
function generateToken(): string {
|
||||
@@ -262,4 +268,122 @@ export const shareRoutes = new Hono<AppEnv>()
|
||||
.returning();
|
||||
|
||||
return c.json({ comment }, 201);
|
||||
})
|
||||
|
||||
// --- Listen tracking (public, no auth) ---
|
||||
.post('/public/:token/listen', async (c) => {
|
||||
const db = c.get('db');
|
||||
const token = c.req.param('token');
|
||||
|
||||
const [link] = await db
|
||||
.select({ id: shareLinks.id, expiresAt: shareLinks.expiresAt })
|
||||
.from(shareLinks)
|
||||
.where(eq(shareLinks.token, token))
|
||||
.limit(1);
|
||||
if (!link) return c.json({ error: 'Not found' }, 404);
|
||||
if (link.expiresAt && link.expiresAt < new Date()) return c.json({ error: 'Expired' }, 410);
|
||||
|
||||
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('cf-connecting-ip') ?? 'unknown';
|
||||
const ipHash = await hashIp(ip);
|
||||
const userAgent = (c.req.header('user-agent') ?? '').slice(0, 500);
|
||||
|
||||
const [event] = await db
|
||||
.insert(listenEvents)
|
||||
.values({ shareLinkId: link.id, ipHash, userAgent })
|
||||
.returning({ id: listenEvents.id });
|
||||
|
||||
return c.json({ eventId: event.id }, 201);
|
||||
})
|
||||
|
||||
.patch('/public/:token/listen/:eventId', zValidator('json', updateListenEventSchema), async (c) => {
|
||||
const db = c.get('db');
|
||||
const token = c.req.param('token');
|
||||
const eventId = c.req.param('eventId');
|
||||
const input = c.req.valid('json');
|
||||
|
||||
const [link] = await db
|
||||
.select({ id: shareLinks.id })
|
||||
.from(shareLinks)
|
||||
.where(eq(shareLinks.token, token))
|
||||
.limit(1);
|
||||
if (!link) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [event] = await db
|
||||
.select()
|
||||
.from(listenEvents)
|
||||
.where(and(eq(listenEvents.id, eventId), eq(listenEvents.shareLinkId, link.id)))
|
||||
.limit(1);
|
||||
if (!event) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
await db
|
||||
.update(listenEvents)
|
||||
.set({
|
||||
...(input.listenerName !== undefined ? { listenerName: input.listenerName } : {}),
|
||||
...(input.firstPlay && !event.firstPlayAt ? { firstPlayAt: new Date() } : {}),
|
||||
...(input.listenSeconds !== undefined ? { listenSeconds: input.listenSeconds } : {}),
|
||||
...(input.completed !== undefined ? { completed: input.completed } : {}),
|
||||
})
|
||||
.where(eq(listenEvents.id, eventId));
|
||||
|
||||
return c.json({ ok: true });
|
||||
})
|
||||
|
||||
// --- Analytics (authenticated) ---
|
||||
.get('/version/:versionId/analytics', requireAuth, async (c) => {
|
||||
const db = c.get('db');
|
||||
const userId = c.get('userId');
|
||||
const versionId = c.req.param('versionId');
|
||||
|
||||
const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1);
|
||||
if (!version) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [track] = await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1);
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(projectMembers)
|
||||
.where(and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)))
|
||||
.limit(1);
|
||||
if (!membership) return c.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const links = await db
|
||||
.select({ id: shareLinks.id })
|
||||
.from(shareLinks)
|
||||
.where(eq(shareLinks.versionId, versionId));
|
||||
|
||||
if (links.length === 0) return c.json({ totalOpens: 0, totalPlays: 0, uniqueListeners: 0, avgListenSeconds: 0, completionRate: 0, events: [] });
|
||||
|
||||
const linkIds = links.map((l) => l.id);
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(listenEvents)
|
||||
.where(inArray(listenEvents.shareLinkId, linkIds))
|
||||
.orderBy(desc(listenEvents.openedAt));
|
||||
|
||||
const totalOpens = events.length;
|
||||
const played = events.filter((e) => e.firstPlayAt !== null);
|
||||
const totalPlays = played.length;
|
||||
const uniqueListeners = new Set(events.map((e) => e.ipHash)).size;
|
||||
const avgListenSeconds = played.length > 0
|
||||
? Math.round(played.reduce((s, e) => s + e.listenSeconds, 0) / played.length)
|
||||
: 0;
|
||||
const completionRate = totalPlays > 0
|
||||
? Math.round((events.filter((e) => e.completed).length / totalPlays) * 100)
|
||||
: 0;
|
||||
|
||||
return c.json({
|
||||
totalOpens,
|
||||
totalPlays,
|
||||
uniqueListeners,
|
||||
avgListenSeconds,
|
||||
completionRate,
|
||||
events: events.map((e) => ({
|
||||
id: e.id,
|
||||
listenerName: e.listenerName,
|
||||
openedAt: e.openedAt,
|
||||
firstPlayAt: e.firstPlayAt,
|
||||
listenSeconds: e.listenSeconds,
|
||||
completed: e.completed,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
onTimeClick,
|
||||
onReady,
|
||||
onSeek,
|
||||
onPlay,
|
||||
onPause,
|
||||
onFinish,
|
||||
}: {
|
||||
url: string;
|
||||
markers?: CommentMarker[];
|
||||
@@ -33,6 +36,9 @@
|
||||
onTimeClick?: (time: number) => void;
|
||||
onReady?: (duration: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onFinish?: () => void;
|
||||
} = $props();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
@@ -86,8 +92,9 @@
|
||||
onSeek?.(time);
|
||||
});
|
||||
|
||||
ws.on('play', () => (isPlaying = true));
|
||||
ws.on('pause', () => (isPlaying = false));
|
||||
ws.on('play', () => { isPlaying = true; onPlay?.(); });
|
||||
ws.on('pause', () => { isPlaying = false; onPause?.(); });
|
||||
ws.on('finish', () => { isPlaying = false; onFinish?.(); });
|
||||
|
||||
ws.on('click', (relativeX) => {
|
||||
if (onTimeClick) {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
import ShareModal from './components/ShareModal.svelte';
|
||||
import CommentSection from './components/CommentSection.svelte';
|
||||
import StemList, { type Stem } from './components/StemList.svelte';
|
||||
import AnalyticsPanel from './components/AnalyticsPanel.svelte';
|
||||
|
||||
type Version = {
|
||||
id: string;
|
||||
@@ -96,7 +97,7 @@
|
||||
let branchLabelInput = $state('');
|
||||
let shareOpen = $state(false);
|
||||
let stems = $state<Stem[]>([]);
|
||||
let panelTab = $state<'versions' | 'comments' | 'stems'>('versions');
|
||||
let panelTab = $state<'versions' | 'comments' | 'stems' | 'analytics'>('versions');
|
||||
let panelOpen = $state(true);
|
||||
let editVersionOpen = $state(false);
|
||||
let editVersionLabel = $state('');
|
||||
@@ -585,6 +586,9 @@
|
||||
<button class:active={panelTab === 'stems'} onclick={() => (panelTab = 'stems')}>
|
||||
STEMs <span class="badge">{stems.length}</span>
|
||||
</button>
|
||||
<button class:active={panelTab === 'analytics'} onclick={() => (panelTab = 'analytics')}>
|
||||
Analytik
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
@@ -610,6 +614,8 @@
|
||||
currentUserId={$user?.id ?? null}
|
||||
{role}
|
||||
/>
|
||||
{:else if panelTab === 'analytics' && selectedVersion}
|
||||
<AnalyticsPanel versionId={selectedVersion.id} />
|
||||
{:else if selectedVersion}
|
||||
<CommentSection
|
||||
{comments}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api/client.js';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
type ListenEvent = {
|
||||
id: string;
|
||||
listenerName: string | null;
|
||||
openedAt: string;
|
||||
firstPlayAt: string | null;
|
||||
listenSeconds: number;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
type Analytics = {
|
||||
totalOpens: number;
|
||||
totalPlays: number;
|
||||
uniqueListeners: number;
|
||||
avgListenSeconds: number;
|
||||
completionRate: number;
|
||||
events: ListenEvent[];
|
||||
};
|
||||
|
||||
let { versionId }: { versionId: string } = $props();
|
||||
|
||||
let data = $state<Analytics | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (versionId) load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
data = await api.get<Analytics>(`/share/version/${versionId}/analytics`);
|
||||
} catch {
|
||||
error = 'Analytics konnten nicht geladen werden.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
if (s < 60) return `${s}s`;
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="analytics">
|
||||
{#if loading}
|
||||
<p class="muted">Lädt…</p>
|
||||
{:else if error}
|
||||
<p class="muted">{error}</p>
|
||||
{:else if data}
|
||||
{#if data.totalOpens === 0}
|
||||
<p class="muted">Noch keine Aufrufe. Teile einen Link um Analytics zu sehen.</p>
|
||||
{:else}
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{data.totalOpens}</span>
|
||||
<span class="stat-label">Geöffnet</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{data.totalPlays}</span>
|
||||
<span class="stat-label">Abgespielt</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{data.uniqueListeners}</span>
|
||||
<span class="stat-label">Personen</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{data.completionRate}%</span>
|
||||
<span class="stat-label">Abschluss</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="event-list">
|
||||
{#each data.events as e (e.id)}
|
||||
<div class="event-row">
|
||||
<div class="event-info">
|
||||
<span class="listener-name">{e.listenerName ?? 'Anonym'}</span>
|
||||
<span class="event-meta">
|
||||
{formatDate(e.openedAt)}
|
||||
{#if e.firstPlayAt}
|
||||
· {formatDuration(e.listenSeconds)} gehört
|
||||
{#if e.completed}<span class="completed-badge">✓ Komplett</span>{/if}
|
||||
{:else}
|
||||
· nicht abgespielt
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.analytics { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.muted { color: var(--color-text-tertiary); font-size: var(--text-sm); }
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.stat {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.event-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.event-row {
|
||||
padding: var(--space-3) var(--space-3);
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.event-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.listener-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.event-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.completed-badge {
|
||||
color: #22c55e;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -47,6 +47,18 @@
|
||||
let submitting = $state(false);
|
||||
let playerRef = $state<WaveformPlayer>();
|
||||
|
||||
// Analytics tracking
|
||||
let eventId = $state<string | null>(null);
|
||||
let listenSeconds = $state(0);
|
||||
let playStartedAt = $state<number | null>(null);
|
||||
let firstPlayFired = $state(false);
|
||||
let progressInterval = $state<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Name prompt
|
||||
let showNamePrompt = $state(false);
|
||||
let nameInput = $state('');
|
||||
let nameDismissed = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
@@ -56,11 +68,7 @@
|
||||
});
|
||||
if (res.status === 401) {
|
||||
const j = await res.json();
|
||||
if (j.passwordRequired) {
|
||||
passwordRequired = true;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
if (j.passwordRequired) { passwordRequired = true; loading = false; return; }
|
||||
}
|
||||
if (!res.ok) {
|
||||
error = (await res.json().catch(() => ({}))).error || 'Link nicht verfügbar';
|
||||
@@ -74,7 +82,93 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
async function createListenEvent() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/share/public/${token}/listen`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const j = await res.json();
|
||||
eventId = j.eventId;
|
||||
}
|
||||
} catch { /* fire and forget */ }
|
||||
}
|
||||
|
||||
async function patchEvent(patch: Record<string, unknown>) {
|
||||
if (!eventId) return;
|
||||
try {
|
||||
await fetch(`/api/v1/share/public/${token}/listen/${eventId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
} catch { /* fire and forget */ }
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
playStartedAt = Date.now();
|
||||
if (!firstPlayFired) {
|
||||
firstPlayFired = true;
|
||||
patchEvent({ firstPlay: true });
|
||||
// Show name prompt after first play, if name not yet set
|
||||
const saved = localStorage.getItem('listenName');
|
||||
if (saved) {
|
||||
guestName = saved;
|
||||
patchEvent({ listenerName: saved });
|
||||
} else if (!nameDismissed) {
|
||||
showNamePrompt = true;
|
||||
}
|
||||
}
|
||||
progressInterval = setInterval(() => {
|
||||
if (playStartedAt !== null) {
|
||||
listenSeconds += 1;
|
||||
if (listenSeconds % 30 === 0) patchEvent({ listenSeconds });
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function onPause() {
|
||||
if (progressInterval) { clearInterval(progressInterval); progressInterval = null; }
|
||||
patchEvent({ listenSeconds });
|
||||
}
|
||||
|
||||
function onFinish() {
|
||||
if (progressInterval) { clearInterval(progressInterval); progressInterval = null; }
|
||||
const duration = data?.version.duration ?? 0;
|
||||
const pct = duration > 0 ? listenSeconds / duration : 0;
|
||||
patchEvent({ listenSeconds, completed: pct >= 0.8 });
|
||||
}
|
||||
|
||||
function submitName() {
|
||||
const name = nameInput.trim();
|
||||
if (name) {
|
||||
guestName = name;
|
||||
localStorage.setItem('listenName', name);
|
||||
patchEvent({ listenerName: name });
|
||||
}
|
||||
showNamePrompt = false;
|
||||
nameDismissed = true;
|
||||
}
|
||||
|
||||
function dismissName() {
|
||||
showNamePrompt = false;
|
||||
nameDismissed = true;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load().then(() => createListenEvent());
|
||||
|
||||
const saved = localStorage.getItem('listenName');
|
||||
if (saved) guestName = saved;
|
||||
|
||||
const handleUnload = () => {
|
||||
if (!eventId) return;
|
||||
navigator.sendBeacon(
|
||||
`/api/v1/share/public/${token}/listen/${eventId}`,
|
||||
new Blob([JSON.stringify({ listenSeconds })], { type: 'application/json' }),
|
||||
);
|
||||
};
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleUnload);
|
||||
});
|
||||
|
||||
async function submitComment(e: Event) {
|
||||
e.preventDefault();
|
||||
@@ -87,16 +181,9 @@
|
||||
'Content-Type': 'application/json',
|
||||
...(password ? { 'X-Share-Password': password } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body,
|
||||
guestName,
|
||||
timestampSeconds: commentTimestamp ?? undefined,
|
||||
}),
|
||||
body: JSON.stringify({ body, guestName, timestampSeconds: commentTimestamp ?? undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
error = 'Kommentar fehlgeschlagen';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) { error = 'Kommentar fehlgeschlagen'; return; }
|
||||
body = '';
|
||||
commentTimestamp = null;
|
||||
await load();
|
||||
@@ -142,8 +229,28 @@
|
||||
userName: c.user?.name ?? c.guestName ?? 'Gast',
|
||||
}))}
|
||||
onTimeClick={(t) => (commentTimestamp = Math.round(t * 10) / 10)}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onFinish={onFinish}
|
||||
/>
|
||||
|
||||
{#if showNamePrompt}
|
||||
<div class="name-prompt">
|
||||
<p>Wie heißt du? So wissen die Künstler, wer zugehört hat.</p>
|
||||
<div class="name-prompt-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={nameInput}
|
||||
placeholder="Dein Name"
|
||||
onkeydown={(e) => e.key === 'Enter' && submitName()}
|
||||
autofocus
|
||||
/>
|
||||
<Button size="sm" onclick={submitName} disabled={!nameInput.trim()}>OK</Button>
|
||||
<button class="skip-btn" onclick={dismissName}>Überspringen</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.downloadUrl}
|
||||
<div class="actions">
|
||||
<a href={data.downloadUrl} target="_blank" rel="noopener">
|
||||
@@ -210,30 +317,49 @@
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
header { text-align: center; }
|
||||
.project { color: var(--color-text-tertiary); font-size: var(--text-sm); margin: 0; }
|
||||
h1 { margin: var(--space-1) 0; font-size: var(--text-2xl); }
|
||||
.version-label { color: var(--color-text-secondary); font-size: var(--text-sm); margin: 0; }
|
||||
.muted { color: var(--color-text-tertiary); font-size: var(--text-sm); }
|
||||
.error { color: var(--color-error, #ef4444); }
|
||||
|
||||
.name-prompt {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.project {
|
||||
.name-prompt p { margin: 0; font-size: var(--text-sm); color: var(--color-text-secondary); }
|
||||
.name-prompt-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.name-prompt-row input {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-hover);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.skip-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
margin: var(--space-1) 0;
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.version-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.error {
|
||||
color: var(--color-error, #ef4444);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.skip-btn:hover { color: var(--color-text-secondary); }
|
||||
|
||||
.password-gate {
|
||||
text-align: center;
|
||||
padding: var(--space-8);
|
||||
@@ -254,10 +380,7 @@
|
||||
font-size: var(--text-sm);
|
||||
width: 100%;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.actions { display: flex; justify-content: flex-end; }
|
||||
.comment-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -267,10 +390,7 @@
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.comment-form h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
.comment-form h2 { margin: 0 0 var(--space-2); font-size: var(--text-base); }
|
||||
.ts-badge {
|
||||
align-self: flex-start;
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
@@ -280,22 +400,9 @@
|
||||
padding: 0.15rem var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.ts-badge button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
.comments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.comments h2 {
|
||||
font-size: var(--text-base);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.ts-badge button { background: none; border: none; color: inherit; cursor: pointer; margin-left: var(--space-1); }
|
||||
.comments { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.comments h2 { font-size: var(--text-base); margin: 0 0 var(--space-2); }
|
||||
.comment {
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg-raised);
|
||||
@@ -327,10 +434,7 @@
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.comment p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.comment p { margin: 0; font-size: var(--text-sm); }
|
||||
footer {
|
||||
text-align: center;
|
||||
padding-top: var(--space-4);
|
||||
|
||||
Reference in New Issue
Block a user