diff --git a/src/lib/components/ThreadForm.svelte b/src/lib/components/ThreadForm.svelte
new file mode 100644
index 0000000..efefaa9
--- /dev/null
+++ b/src/lib/components/ThreadForm.svelte
@@ -0,0 +1,136 @@
+
+
+{#if user}
+
+{:else}
+ {$t('threads.sign_in_to_comment')}
+{/if}
+
+
diff --git a/src/lib/components/ThreadItem.svelte b/src/lib/components/ThreadItem.svelte
new file mode 100644
index 0000000..a21f842
--- /dev/null
+++ b/src/lib/components/ThreadItem.svelte
@@ -0,0 +1,181 @@
+
+
+
+
+
+ {#if rootComment}
+ {rootComment.body}
+ {/if}
+
+
+ {#if replyCount > 0}
+
+ {:else}
+ {$t('threads.replies_count_zero')}
+ {/if}
+
+
+ {#if showReplies && replies.length > 0}
+
+ {#each replies as reply (reply.id)}
+ -
+
{reply.body}
+ {formatDate(reply.created_at)}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
diff --git a/src/lib/components/ThreadList.svelte b/src/lib/components/ThreadList.svelte
new file mode 100644
index 0000000..7df0b77
--- /dev/null
+++ b/src/lib/components/ThreadList.svelte
@@ -0,0 +1,84 @@
+
+
+
+ {#if $threadsError}
+
{$t('threads.error_load')}
+ {/if}
+
+ {#if $threads.length === 0 && !$threadsLoading}
+
{$t('threads.no_threads')}
+ {/if}
+
+
+ {#each $threads as thread (thread.id)}
+
+ {/each}
+
+
+ {#if $threadsCursor}
+
+ {/if}
+
+
+
diff --git a/src/lib/i18n/locales/en.yml b/src/lib/i18n/locales/en.yml
index 38923ff..6c38d12 100644
--- a/src/lib/i18n/locales/en.yml
+++ b/src/lib/i18n/locales/en.yml
@@ -47,6 +47,24 @@ albums:
audio:
no_audio: No audio for this version.
player: Player
+threads:
+ title: Comments
+ no_threads: No comments yet.
+ new_thread: New comment
+ kind_comment: Comment
+ kind_question: Question
+ reply: Reply
+ replies_count: '{count} replies'
+ replies_count_one: '1 reply'
+ replies_count_zero: No replies
+ body_placeholder: Write your comment…
+ submit: Post
+ reply_placeholder: Write a reply…
+ submit_reply: Reply
+ sign_in_to_comment: Sign in to comment
+ error_create: Could not create comment.
+ error_reply: Could not post reply.
+ error_load: Could not load comments.
auth:
sign_in: Sign in with Google
sign_out: Sign out
diff --git a/src/lib/i18n/locales/fr.yml b/src/lib/i18n/locales/fr.yml
index effc967..a523a05 100644
--- a/src/lib/i18n/locales/fr.yml
+++ b/src/lib/i18n/locales/fr.yml
@@ -47,6 +47,24 @@ albums:
audio:
no_audio: Pas d’audio pour cette version.
player: Lecteur
+threads:
+ title: Commentaires
+ no_threads: Aucun commentaire pour le moment.
+ new_thread: Nouveau commentaire
+ kind_comment: Commentaire
+ kind_question: Question
+ reply: Répondre
+ replies_count: '{count} réponses'
+ replies_count_one: '1 réponse'
+ replies_count_zero: Aucune réponse
+ body_placeholder: Écrivez votre commentaire…
+ submit: Publier
+ reply_placeholder: Écrivez une réponse…
+ submit_reply: Répondre
+ sign_in_to_comment: Connectez-vous pour commenter
+ error_create: Impossible de créer le commentaire.
+ error_reply: Impossible de publier la réponse.
+ error_load: Impossible de charger les commentaires.
auth:
sign_in: Se connecter avec Google
sign_out: Se déconnecter
diff --git a/src/lib/stores/threads.ts b/src/lib/stores/threads.ts
new file mode 100644
index 0000000..2199c04
--- /dev/null
+++ b/src/lib/stores/threads.ts
@@ -0,0 +1,103 @@
+import { writable } from 'svelte/store';
+import type {
+ ThreadWithComments,
+ ThreadsResponse,
+ CreateThreadResponse,
+ ThreadKind,
+ Comment
+} from '$lib/types/threads';
+
+export const threads = writable([]);
+export const threadsLoading = writable(false);
+export const threadsError = writable(null);
+export const threadsCursor = writable(null);
+
+export async function fetchThreads(
+ entityType: string,
+ entityId: string,
+ reset = false
+): Promise {
+ threadsLoading.set(true);
+ threadsError.set(null);
+
+ let cursor: string | null = null;
+ if (!reset) {
+ threadsCursor.subscribe((v) => (cursor = v))();
+ } else {
+ threadsCursor.set(null);
+ }
+
+ try {
+ const params = new URLSearchParams({
+ entityType,
+ entityId,
+ limit: '10'
+ });
+ if (cursor) params.set('cursor', cursor);
+
+ const res = await fetch(`/api/threads?${params}`);
+ if (!res.ok) throw new Error('Failed to load threads');
+
+ const data: ThreadsResponse = await res.json();
+
+ if (reset) {
+ threads.set(data.threads);
+ } else {
+ threads.update((current) => [...current, ...data.threads]);
+ }
+ threadsCursor.set(data.nextCursor);
+ } catch (err: any) {
+ threadsError.set(err?.message ?? 'Failed to load threads');
+ } finally {
+ threadsLoading.set(false);
+ }
+}
+
+export async function createThread(
+ entityType: string,
+ entityId: string,
+ kind: ThreadKind,
+ body: string
+): Promise {
+ try {
+ const res = await fetch('/api/threads', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ entityType, entityId, kind, body })
+ });
+
+ if (!res.ok) throw new Error('Failed to create thread');
+
+ const data: CreateThreadResponse = await res.json();
+ // Refresh list after creation
+ await fetchThreads(entityType, entityId, true);
+ return data;
+ } catch {
+ return null;
+ }
+}
+
+export async function postReply(
+ threadId: string,
+ body: string,
+ entityType: string,
+ entityId: string,
+ parentCommentId?: string | null
+): Promise {
+ try {
+ const res = await fetch(`/api/threads/${threadId}/comments`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ body, parentCommentId: parentCommentId || null })
+ });
+
+ if (!res.ok) throw new Error('Failed to post reply');
+
+ const data: Comment = await res.json();
+ // Refresh list after reply
+ await fetchThreads(entityType, entityId, true);
+ return data;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/types/threads.ts b/src/lib/types/threads.ts
new file mode 100644
index 0000000..09ccfcc
--- /dev/null
+++ b/src/lib/types/threads.ts
@@ -0,0 +1,49 @@
+export type ThreadKind = 'comment' | 'question';
+
+export type Thread = {
+ id: string;
+ entity_type: string;
+ entity_id: string;
+ kind: ThreadKind;
+ created_by: string;
+ created_at: string;
+ comment_count: number;
+ last_activity_at: string;
+};
+
+export type Comment = {
+ id: string;
+ thread_id: string;
+ user_id: string;
+ body: string;
+ parent_comment_id: string | null;
+ is_published: boolean;
+ created_at: string;
+ updated_at: string;
+};
+
+export type ThreadWithComments = Thread & {
+ comments: Comment[];
+};
+
+export type ThreadsResponse = {
+ threads: ThreadWithComments[];
+ nextCursor: string | null;
+};
+
+export type CreateThreadRequest = {
+ entityType: string;
+ entityId: string;
+ kind: ThreadKind;
+ body: string;
+};
+
+export type CreateThreadResponse = {
+ thread_id: string;
+ comment_id: string;
+};
+
+export type CreateCommentRequest = {
+ body: string;
+ parentCommentId?: string | null;
+};
diff --git a/src/routes/api/threads/+server.ts b/src/routes/api/threads/+server.ts
new file mode 100644
index 0000000..b2f4b13
--- /dev/null
+++ b/src/routes/api/threads/+server.ts
@@ -0,0 +1,184 @@
+import type { RequestHandler } from '@sveltejs/kit';
+import type { ThreadWithComments, ThreadsResponse, Comment } from '$lib/types/threads';
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export const GET: RequestHandler = async ({ locals, url }) => {
+ const entityType = url.searchParams.get('entityType');
+ const entityId = url.searchParams.get('entityId');
+ const limit = Math.min(
+ Math.max(parseInt(url.searchParams.get('limit') ?? '10', 10) || 10, 1),
+ 50
+ );
+ const cursor = url.searchParams.get('cursor');
+
+ if (entityType !== 'track') {
+ return new Response(JSON.stringify({ error: 'entityType must be "track"' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ if (!entityId || !UUID_RE.test(entityId)) {
+ return new Response(JSON.stringify({ error: 'entityId must be a valid UUID' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ try {
+ let query = locals.supabase
+ .from('threads')
+ .select('*')
+ .eq('entity_type', entityType)
+ .eq('entity_id', entityId)
+ .order('last_activity_at', { ascending: false })
+ .order('id', { ascending: false })
+ .limit(limit + 1);
+
+ if (cursor) {
+ // cursor format: "last_activity_at|id"
+ const [cursorDate, cursorId] = cursor.split('|');
+ if (cursorDate && cursorId) {
+ query = query.or(
+ `last_activity_at.lt.${cursorDate},and(last_activity_at.eq.${cursorDate},id.lt.${cursorId})`
+ );
+ }
+ }
+
+ const { data: threads, error: threadsErr } = await query;
+
+ if (threadsErr) {
+ return new Response(JSON.stringify({ error: 'Failed to load threads' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ const hasMore = (threads?.length ?? 0) > limit;
+ const sliced = (threads ?? []).slice(0, limit);
+
+ let nextCursor: string | null = null;
+ if (hasMore && sliced.length > 0) {
+ const last = sliced[sliced.length - 1];
+ nextCursor = `${last.last_activity_at}|${last.id}`;
+ }
+
+ const threadIds = sliced.map((t) => t.id);
+
+ let comments: Comment[] = [];
+ if (threadIds.length > 0) {
+ const { data: commentsData, error: commentsErr } = await locals.supabase
+ .from('comments')
+ .select('*')
+ .in('thread_id', threadIds)
+ .order('created_at', { ascending: true });
+
+ if (!commentsErr && commentsData) {
+ comments = commentsData;
+ }
+ }
+
+ const commentsByThread = new Map();
+ for (const c of comments) {
+ const list = commentsByThread.get(c.thread_id) ?? [];
+ list.push(c);
+ commentsByThread.set(c.thread_id, list);
+ }
+
+ const result: ThreadsResponse = {
+ threads: sliced.map((thread) => ({
+ ...thread,
+ comments: commentsByThread.get(thread.id) ?? []
+ })) as ThreadWithComments[],
+ nextCursor
+ };
+
+ return new Response(JSON.stringify(result), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ } catch {
+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+};
+
+export const POST: RequestHandler = async ({ locals, request }) => {
+ const user = locals.user;
+ if (!user) {
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ let payload: { entityType?: string; entityId?: string; kind?: string; body?: string };
+ try {
+ payload = await request.json();
+ } catch {
+ return new Response(JSON.stringify({ error: 'Invalid JSON body' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ const { entityType, entityId, kind, body: commentBody } = payload;
+
+ if (entityType !== 'track') {
+ return new Response(JSON.stringify({ error: 'entityType must be "track"' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ if (!entityId || !UUID_RE.test(entityId)) {
+ return new Response(JSON.stringify({ error: 'entityId must be a valid UUID' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ if (kind !== 'comment' && kind !== 'question') {
+ return new Response(JSON.stringify({ error: 'kind must be "comment" or "question"' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ if (!commentBody || typeof commentBody !== 'string' || commentBody.trim().length === 0) {
+ return new Response(JSON.stringify({ error: 'body must be a non-empty string' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ try {
+ const { data, error } = await locals.supabase.rpc('create_thread_with_comment', {
+ p_entity_type: entityType,
+ p_entity_id: entityId,
+ p_kind: kind,
+ p_body: commentBody.trim(),
+ p_is_published: true
+ });
+
+ if (error) {
+ return new Response(JSON.stringify({ error: 'Failed to create thread' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ return new Response(JSON.stringify(data), {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ } catch {
+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+};
diff --git a/src/routes/api/threads/[threadId]/comments/+server.ts b/src/routes/api/threads/[threadId]/comments/+server.ts
new file mode 100644
index 0000000..21197a7
--- /dev/null
+++ b/src/routes/api/threads/[threadId]/comments/+server.ts
@@ -0,0 +1,78 @@
+import type { RequestHandler } from '@sveltejs/kit';
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export const POST: RequestHandler = async ({ locals, params, request }) => {
+ const user = locals.user;
+ if (!user) {
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ const threadId = params.threadId;
+ if (!threadId || !UUID_RE.test(threadId)) {
+ return new Response(JSON.stringify({ error: 'Invalid thread ID' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ let payload: { body?: string; parentCommentId?: string };
+ try {
+ payload = await request.json();
+ } catch {
+ return new Response(JSON.stringify({ error: 'Invalid JSON body' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ const { body: commentBody, parentCommentId } = payload;
+
+ if (!commentBody || typeof commentBody !== 'string' || commentBody.trim().length === 0) {
+ return new Response(JSON.stringify({ error: 'body must be a non-empty string' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ if (parentCommentId && !UUID_RE.test(parentCommentId)) {
+ return new Response(JSON.stringify({ error: 'parentCommentId must be a valid UUID' }), {
+ status: 400,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ try {
+ const { data, error } = await locals.supabase
+ .from('comments')
+ .insert({
+ thread_id: threadId,
+ user_id: user.id,
+ body: commentBody.trim(),
+ parent_comment_id: parentCommentId || null,
+ is_published: true
+ })
+ .select()
+ .single();
+
+ if (error) {
+ return new Response(JSON.stringify({ error: 'Failed to post reply' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ return new Response(JSON.stringify(data), {
+ status: 201,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ } catch {
+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+};
diff --git a/src/routes/api/threads/recent/+server.ts b/src/routes/api/threads/recent/+server.ts
new file mode 100644
index 0000000..f350df9
--- /dev/null
+++ b/src/routes/api/threads/recent/+server.ts
@@ -0,0 +1,86 @@
+import type { RequestHandler } from '@sveltejs/kit';
+import type { ThreadWithComments, ThreadsResponse, Comment } from '$lib/types/threads';
+
+export const GET: RequestHandler = async ({ locals, url }) => {
+ const limit = Math.min(
+ Math.max(parseInt(url.searchParams.get('limit') ?? '10', 10) || 10, 1),
+ 50
+ );
+ const cursor = url.searchParams.get('cursor');
+
+ try {
+ let query = locals.supabase
+ .from('threads')
+ .select('*')
+ .order('last_activity_at', { ascending: false })
+ .order('id', { ascending: false })
+ .limit(limit + 1);
+
+ if (cursor) {
+ const [cursorDate, cursorId] = cursor.split('|');
+ if (cursorDate && cursorId) {
+ query = query.or(
+ `last_activity_at.lt.${cursorDate},and(last_activity_at.eq.${cursorDate},id.lt.${cursorId})`
+ );
+ }
+ }
+
+ const { data: threads, error: threadsErr } = await query;
+
+ if (threadsErr) {
+ return new Response(JSON.stringify({ error: 'Failed to load threads' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ const hasMore = (threads?.length ?? 0) > limit;
+ const sliced = (threads ?? []).slice(0, limit);
+
+ let nextCursor: string | null = null;
+ if (hasMore && sliced.length > 0) {
+ const last = sliced[sliced.length - 1];
+ nextCursor = `${last.last_activity_at}|${last.id}`;
+ }
+
+ const threadIds = sliced.map((t) => t.id);
+
+ let comments: Comment[] = [];
+ if (threadIds.length > 0) {
+ const { data: commentsData, error: commentsErr } = await locals.supabase
+ .from('comments')
+ .select('*')
+ .in('thread_id', threadIds)
+ .order('created_at', { ascending: true });
+
+ if (!commentsErr && commentsData) {
+ comments = commentsData;
+ }
+ }
+
+ const commentsByThread = new Map();
+ for (const c of comments) {
+ const list = commentsByThread.get(c.thread_id) ?? [];
+ list.push(c);
+ commentsByThread.set(c.thread_id, list);
+ }
+
+ const result: ThreadsResponse = {
+ threads: sliced.map((thread) => ({
+ ...thread,
+ comments: commentsByThread.get(thread.id) ?? []
+ })) as ThreadWithComments[],
+ nextCursor
+ };
+
+ return new Response(JSON.stringify(result), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ } catch {
+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+};
diff --git a/src/routes/tracks/[track_id]/+page.svelte b/src/routes/tracks/[track_id]/+page.svelte
index 142db62..01327af 100644
--- a/src/routes/tracks/[track_id]/+page.svelte
+++ b/src/routes/tracks/[track_id]/+page.svelte
@@ -18,13 +18,17 @@
lyrics?: string | null;
};
+ import type { User } from '@supabase/supabase-js';
+
export let data: {
track: Track | null;
versions: Version[];
error: string | null;
+ user?: User | null;
};
const { track, versions, error } = data;
+ $: user = data.user ?? null;
$: sorted = (versions ?? [])
.slice()
@@ -36,7 +40,9 @@
import TrackTimeline from '$lib/components/TrackTimeline.svelte';
import TrackVersions from '$lib/components/TrackVersions.svelte';
import TrackLyrics from '$lib/components/TrackLyrics.svelte';
- let tab: 'timeline' | 'versions' | 'lyrics' = 'timeline';
+ import ThreadList from '$lib/components/ThreadList.svelte';
+ import ThreadForm from '$lib/components/ThreadForm.svelte';
+ let tab: 'timeline' | 'versions' | 'lyrics' | 'comments' = 'timeline';
{#if error}
@@ -51,7 +57,8 @@
items={[
{ id: 'timeline', label: $t('common.timeline') },
{ id: 'versions', label: $t('common.versions') },
- { id: 'lyrics', label: $t('common.lyrics') }
+ { id: 'lyrics', label: $t('common.lyrics') },
+ { id: 'comments', label: $t('common.comments') }
]}
bind:value={tab}
ariaLabel={$t('common.versions')}
@@ -61,6 +68,9 @@
{:else if tab === 'versions'}
+ {:else if tab === 'comments'}
+
+
{:else}
{/if}