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} +
+
+ +
+ + + + {#if error} +

{error}

+ {/if} +
+{: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 @@ + + +
  • +
    + {$t(`threads.kind_${thread.kind}`)} + {formatDate(thread.created_at)} +
    + + {#if rootComment} +

    {rootComment.body}

    + {/if} + +
    + {#if replyCount > 0} + + {:else} + {$t('threads.replies_count_zero')} + {/if} +
    + + {#if showReplies && replies.length > 0} + + {/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} + + + + {#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}