diff --git a/deno.lock b/deno.lock index 68b2c1a9d..4e463d490 100644 --- a/deno.lock +++ b/deno.lock @@ -40,6 +40,7 @@ "npm:hono@^4.6.16": "4.6.18", "npm:idb-keyval@^6.2.1": "6.2.1", "npm:jsdom@26": "26.0.0", + "npm:marked@^15.0.6": "15.0.6", "npm:msw@^2.7.0": "2.7.0_typescript@5.7.3", "npm:playwright@*": "1.50.1", "npm:playwright@^1.50.1": "1.50.1", @@ -4802,6 +4803,9 @@ "semver@7.6.3" ] }, + "marked@15.0.6": { + "integrity": "sha512-Y07CUOE+HQXbVDCGl3LXggqJDbXDP2pArc2C1N1RRMN0ONiShoSsIInMd5Gsxupe7fKLpgimTV+HOJ9r7bA+pg==" + }, "math-intrinsics@1.1.0": { "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" }, @@ -6849,6 +6853,7 @@ "npm:globals@^15.14.0", "npm:idb-keyval@^6.2.1", "npm:jsdom@26", + "npm:marked@^15.0.6", "npm:msw@^2.7.0", "npm:playwright@^1.50.1", "npm:prettier-plugin-svelte@^3.3.3", diff --git a/projects/api/src/contracts/_internal/request/commentsSortParamsSchema.ts b/projects/api/src/contracts/_internal/request/commentsSortParamsSchema.ts new file mode 100644 index 000000000..399895a31 --- /dev/null +++ b/projects/api/src/contracts/_internal/request/commentsSortParamsSchema.ts @@ -0,0 +1,13 @@ +import { z } from '../z.ts'; + +export const commentsSortParamsSchema = z.object({ + sort: z.enum([ + 'newest', + 'oldest', + 'likes', + 'replies', + 'highest', + 'lowest', + 'plays', + ]), +}); diff --git a/projects/api/src/contracts/_internal/response/commentResponseSchema.ts b/projects/api/src/contracts/_internal/response/commentResponseSchema.ts new file mode 100644 index 000000000..b46a5648d --- /dev/null +++ b/projects/api/src/contracts/_internal/response/commentResponseSchema.ts @@ -0,0 +1,21 @@ +import { z } from '../z.ts'; +import { profileResponseSchema } from './userProfileResponseSchema.ts'; + +export const commentReponseSchema = z.object({ + id: z.number(), + parent_id: z.number(), + created_at: z.string(), + updated_at: z.string(), + comment: z.string(), + spoiler: z.boolean(), + review: z.boolean(), + replies: z.number(), + likes: z.number(), + user_rating: z.number().nullable(), + user_stats: z.object({ + rating: z.number().nullable(), + play_count: z.number(), + completed_count: z.number(), + }), + user: profileResponseSchema, +}); diff --git a/projects/api/src/contracts/movies/index.ts b/projects/api/src/contracts/movies/index.ts index aec0cd4a9..9eb038d2f 100644 --- a/projects/api/src/contracts/movies/index.ts +++ b/projects/api/src/contracts/movies/index.ts @@ -1,9 +1,11 @@ import { builder } from '../_internal/builder.ts'; +import { commentsSortParamsSchema } from '../_internal/request/commentsSortParamsSchema.ts'; import { extendedQuerySchemaFactory } from '../_internal/request/extendedQuerySchemaFactory.ts'; import { idParamsSchema } from '../_internal/request/idParamsSchema.ts'; import { languageParamsSchema } from '../_internal/request/languageParamsSchema.ts'; import { pageQuerySchema } from '../_internal/request/pageQuerySchema.ts'; import { watchNowParamsSchema } from '../_internal/request/watchNowParamsSchema.ts'; +import { commentReponseSchema } from '../_internal/response/commentResponseSchema.ts'; import type { genreResponseSchema } from '../_internal/response/genreResponseSchema.ts'; import type { jobResponseSchema } from '../_internal/response/jobResponseSchema.ts'; import { listResponseSchema } from '../_internal/response/listResponseSchema.ts'; @@ -119,6 +121,15 @@ const ENTITY_LEVEL = builder.router({ 200: listResponseSchema.array(), }, }, + comments: { + path: '/comments/:sort', + method: 'GET', + query: extendedQuerySchemaFactory<['full', 'images']>(), + pathParams: idParamsSchema.merge(commentsSortParamsSchema), + responses: { + 200: commentReponseSchema.array(), + }, + }, }, { pathPrefix: '/:id', }); @@ -176,6 +187,7 @@ export type PeopleResponse = z.infer; export type CrewResponse = z.infer; export type CastResponse = z.infer; export type ListResponse = z.infer; +export type CommentResponse = z.infer; export type MovieTranslationResponse = z.infer< typeof translationResponseSchema diff --git a/projects/api/src/contracts/shows/index.ts b/projects/api/src/contracts/shows/index.ts index 879b3980b..c64fe5c9b 100644 --- a/projects/api/src/contracts/shows/index.ts +++ b/projects/api/src/contracts/shows/index.ts @@ -1,10 +1,12 @@ import { builder } from '../_internal/builder.ts'; +import { commentsSortParamsSchema } from '../_internal/request/commentsSortParamsSchema.ts'; import { extendedQuerySchemaFactory } from '../_internal/request/extendedQuerySchemaFactory.ts'; import { idParamsSchema } from '../_internal/request/idParamsSchema.ts'; import { languageParamsSchema } from '../_internal/request/languageParamsSchema.ts'; import { pageQuerySchema } from '../_internal/request/pageQuerySchema.ts'; import { statsQuerySchema } from '../_internal/request/statsQuerySchema.ts'; import { watchNowParamsSchema } from '../_internal/request/watchNowParamsSchema.ts'; +import { commentReponseSchema } from '../_internal/response/commentResponseSchema.ts'; import { episodeResponseSchema } from '../_internal/response/episodeResponseSchema.ts'; import { episodeStatsResponseSchema } from '../_internal/response/episodeStatsResponseSchema.ts'; import { episodeTranslationResponseSchema } from '../_internal/response/episodeTranslationResponseSchema.ts'; @@ -205,6 +207,15 @@ const ENTITY_LEVEL = builder.router({ 200: listResponseSchema.array(), }, }, + comments: { + path: '/comments/:sort', + method: 'GET', + pathParams: idParamsSchema.merge(commentsSortParamsSchema), + query: extendedQuerySchemaFactory<['full', 'images']>(), + responses: { + 200: commentReponseSchema.array(), + }, + }, }, { pathPrefix: '/:id', }); diff --git a/projects/client/i18n/messages/de-de.json b/projects/client/i18n/messages/de-de.json index 5adbd88b7..a7d1d0c24 100644 --- a/projects/client/i18n/messages/de-de.json +++ b/projects/client/i18n/messages/de-de.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} taucht in keinen beliebten Listen auf.", "by": "von", "users_avatar": "{userName}'s Avatar", - "media_poster": "{title} Poster" + "media_poster": "{title} Poster", + "popular_comments": "Beliebte Kommentare", + "no_comments": "Noch keine Kommentare. Sei der/die Erste!", + "review_by": "Review von", + "shout_by": "Shout von" } diff --git a/projects/client/i18n/messages/en.json b/projects/client/i18n/messages/en.json index b66c14181..7344f4ca7 100644 --- a/projects/client/i18n/messages/en.json +++ b/projects/client/i18n/messages/en.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} does not appear on any popular lists.", "by": "by", "users_avatar": "{userName}'s avatar", - "media_poster": "{title} poster" + "media_poster": "{title} poster", + "popular_comments": "Popular comments", + "no_comments": "No comments yet.", + "review_by": "Review by", + "shout_by": "Shout by" } diff --git a/projects/client/i18n/messages/es-es.json b/projects/client/i18n/messages/es-es.json index ff4deab73..c9cfbac49 100644 --- a/projects/client/i18n/messages/es-es.json +++ b/projects/client/i18n/messages/es-es.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} no aparece en ninguna lista popular.", "by": "por", "users_avatar": "Avatar de {userName}", - "media_poster": "Póster de {title}" + "media_poster": "Póster de {title}", + "popular_comments": "Comentarios populares", + "no_comments": "Aún no hay comentarios.", + "review_by": "Reseña de", + "shout_by": "Grito de" } diff --git a/projects/client/i18n/messages/es-mx.json b/projects/client/i18n/messages/es-mx.json index 8f20c3d3a..a83522bbf 100644 --- a/projects/client/i18n/messages/es-mx.json +++ b/projects/client/i18n/messages/es-mx.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} no aparece en ninguna lista popular.", "by": "por", "users_avatar": "Avatar de {userName}", - "media_poster": "Póster de {title}" + "media_poster": "Póster de {title}", + "popular_comments": "Comentarios populares", + "no_comments": "Aún no hay comentarios.", + "review_by": "Reseña de", + "shout_by": "Grito de" } diff --git a/projects/client/i18n/messages/fr-ca.json b/projects/client/i18n/messages/fr-ca.json index 663b0d23f..d242b1443 100644 --- a/projects/client/i18n/messages/fr-ca.json +++ b/projects/client/i18n/messages/fr-ca.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} ne figure dans aucune liste populaire.", "by": "par", "users_avatar": "Avatar de {userName}", - "media_poster": "Affiche de {title}" + "media_poster": "Affiche de {title}", + "popular_comments": "Commentaires populaires", + "no_comments": "Aucun commentaire pour l'instant.", + "review_by": "Critique par", + "shout_by": "Crié par" } diff --git a/projects/client/i18n/messages/fr-fr.json b/projects/client/i18n/messages/fr-fr.json index b89f9807a..51b9e29cf 100644 --- a/projects/client/i18n/messages/fr-fr.json +++ b/projects/client/i18n/messages/fr-fr.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} n'apparaît dans aucune liste populaire.", "by": "par", "users_avatar": "Avatar de {userName}", - "media_poster": "Affiche de {title}" + "media_poster": "Affiche de {title}", + "popular_comments": "Commentaires populaires", + "no_comments": "Pas encore de commentaires.", + "review_by": "Critique par", + "shout_by": "Crié par" } diff --git a/projects/client/i18n/messages/it-it.json b/projects/client/i18n/messages/it-it.json index 99b6e4d50..1ecc4863f 100644 --- a/projects/client/i18n/messages/it-it.json +++ b/projects/client/i18n/messages/it-it.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} non compare in nessuna lista popolare.", "by": "di", "users_avatar": "Avatar di {userName}", - "media_poster": "Locandina di {title}" + "media_poster": "Locandina di {title}", + "popular_comments": "Commenti popolari", + "no_comments": "Nessun commento per ora.", + "review_by": "Recensione di", + "shout_by": "Grido di" } diff --git a/projects/client/i18n/messages/ja-jp.json b/projects/client/i18n/messages/ja-jp.json index 5fe82114c..c184a8f76 100644 --- a/projects/client/i18n/messages/ja-jp.json +++ b/projects/client/i18n/messages/ja-jp.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title}は人気のリストには見当たりません。", "by": "作成者", "users_avatar": "{userName}のアバター", - "media_poster": "{title}のポスター" + "media_poster": "{title}のポスター", + "popular_comments": "人気のコメント", + "no_comments": "まだコメントはありません。", + "review_by": "レビュー:", + "shout_by": "シャウト:" } diff --git a/projects/client/i18n/messages/nl-nl.json b/projects/client/i18n/messages/nl-nl.json index 8f2223e20..7351bf796 100644 --- a/projects/client/i18n/messages/nl-nl.json +++ b/projects/client/i18n/messages/nl-nl.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} staat niet op populaire lijsten.", "by": "door", "users_avatar": "Avatar van {userName}", - "media_poster": "{title} poster" + "media_poster": "{title} poster", + "popular_comments": "Populaire reacties", + "no_comments": "Nog geen reacties.", + "review_by": "Review door", + "shout_by": "Geschreeuwd door" } diff --git a/projects/client/i18n/messages/pl-pl.json b/projects/client/i18n/messages/pl-pl.json index 381bde4e9..05feb31e9 100644 --- a/projects/client/i18n/messages/pl-pl.json +++ b/projects/client/i18n/messages/pl-pl.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} nie pojawia się na żadnych popularnych listach. Wygląda na to, że nikt nie wpadł na to, żeby go umieścić... jeszcze!", "by": "przez", "users_avatar": "Awatar użytkownika {userName}", - "media_poster": "Plakat {title}" + "media_poster": "Plakat {title}", + "popular_comments": "Popularne komentarze", + "no_comments": "Jeszcze brak komentarzy.", + "review_by": "Recenzja od", + "shout_by": "Krzyknął" } diff --git a/projects/client/i18n/messages/pt-br.json b/projects/client/i18n/messages/pt-br.json index 8fec69d74..5977699ac 100644 --- a/projects/client/i18n/messages/pt-br.json +++ b/projects/client/i18n/messages/pt-br.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} não aparece em nenhuma lista popular.", "by": "de", "users_avatar": "Avatar de {userName}", - "media_poster": "Pôster de {title}" + "media_poster": "Pôster de {title}", + "popular_comments": "Comentários populares", + "no_comments": "Sem comentários por enquanto. Seja o primeiro!", + "review_by": "Crítica por", + "shout_by": "Grito por" } diff --git a/projects/client/i18n/messages/ro-ro.json b/projects/client/i18n/messages/ro-ro.json index ef004ed04..efd477e80 100644 --- a/projects/client/i18n/messages/ro-ro.json +++ b/projects/client/i18n/messages/ro-ro.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} nu apare în nicio listă populară.", "by": "de", "users_avatar": "Avatarul lui {userName}", - "media_poster": "Posterul {title}" + "media_poster": "Posterul {title}", + "popular_comments": "Comentarii populare", + "no_comments": "Încă nu sunt comentarii.", + "review_by": "Recenzie de", + "shout_by": "Strigăt de" } diff --git a/projects/client/i18n/messages/uk-ua.json b/projects/client/i18n/messages/uk-ua.json index 721c8a479..43c1fcd61 100644 --- a/projects/client/i18n/messages/uk-ua.json +++ b/projects/client/i18n/messages/uk-ua.json @@ -278,5 +278,9 @@ "no_popular_lists": "{title} не з'являється в жодному популярному списку.", "by": "від", "users_avatar": "Аватар {userName}", - "media_poster": "Постер {title}" + "media_poster": "Постер {title}", + "popular_comments": "Популярні коментарі", + "no_comments": "Ще немає коментарів.", + "review_by": "Огляд від", + "shout_by": "Вигук від" } diff --git a/projects/client/package.json b/projects/client/package.json index a5be89be9..13467ebbd 100644 --- a/projects/client/package.json +++ b/projects/client/package.json @@ -41,6 +41,7 @@ "eslint-plugin-svelte": "^2.46.1", "globals": "^15.14.0", "jsdom": "^26.0.0", + "marked": "^15.0.6", "msw": "^2.7.0", "playwright": "^1.50.1", "prettier": "^3.4.2", diff --git a/projects/client/src/lib/components/link/Link.svelte b/projects/client/src/lib/components/link/Link.svelte index a406e3878..a949b1267 100644 --- a/projects/client/src/lib/components/link/Link.svelte +++ b/projects/client/src/lib/components/link/Link.svelte @@ -83,19 +83,7 @@ } &[data-color="default"] { - text-decoration-color: var(--color-link-active); - - &, - &:visited { - color: var(--color-foreground); - } - - @include for-mouse { - &:hover, - &:focus-visible { - color: var(--color-link-active); - } - } + @include default-link-style; &.trakt-link-active { &, diff --git a/projects/client/src/lib/features/spoilers/_internal/useSpoilerAction.spec.ts b/projects/client/src/lib/features/spoilers/_internal/useSpoilerAction.spec.ts index 2b4d96810..f79ef8f10 100644 --- a/projects/client/src/lib/features/spoilers/_internal/useSpoilerAction.spec.ts +++ b/projects/client/src/lib/features/spoilers/_internal/useSpoilerAction.spec.ts @@ -1,3 +1,4 @@ +import { SPOILER_CLASS_NAME } from '$lib/features/spoilers/constants.ts'; import { clone } from '$lib/utils/object/clone.ts'; import { deepAssign } from '$lib/utils/object/deepAssign.ts'; import { ExtendedUsersResponseMock } from '$mocks/data/users/response/ExtendedUserSettingsResponseMock.ts'; @@ -46,7 +47,7 @@ describe('action: useSpoilerAction', () => { spoiler(node); await waitFor(() => - expect(node.classList.contains('trakt-spoiler')).toBe(true) + expect(node.classList.contains(SPOILER_CLASS_NAME)).toBe(true) ); }); @@ -61,10 +62,10 @@ describe('action: useSpoilerAction', () => { }) ); - node.classList.add('trakt-spoiler'); + node.classList.add(SPOILER_CLASS_NAME); spoiler(node); - expect(node.classList.contains('trakt-spoiler')).toBe(false); + expect(node.classList.contains(SPOILER_CLASS_NAME)).toBe(false); }); it('should NOT remove the spoiler class when a show is unwatched', async () => { @@ -104,11 +105,11 @@ describe('action: useSpoilerAction', () => { spoiler(node); // Remove the class to the node - node.classList.remove('trakt-spoiler'); + node.classList.remove(SPOILER_CLASS_NAME); // Then verify it is removed await waitFor( - () => expect(node.classList.contains('trakt-spoiler')).toBe(true), + () => expect(node.classList.contains(SPOILER_CLASS_NAME)).toBe(true), ); }); @@ -149,11 +150,11 @@ describe('action: useSpoilerAction', () => { spoiler(node); // Add the class to the node - node.classList.add('trakt-spoiler'); + node.classList.add(SPOILER_CLASS_NAME); // Then verify it is removed await waitFor( - () => expect(node.classList.contains('trakt-spoiler')).toBe(false), + () => expect(node.classList.contains(SPOILER_CLASS_NAME)).toBe(false), ); }); }); diff --git a/projects/client/src/lib/features/spoilers/_internal/useSpoilerAction.ts b/projects/client/src/lib/features/spoilers/_internal/useSpoilerAction.ts index d860693fc..a66980037 100644 --- a/projects/client/src/lib/features/spoilers/_internal/useSpoilerAction.ts +++ b/projects/client/src/lib/features/spoilers/_internal/useSpoilerAction.ts @@ -1,3 +1,4 @@ +import { SPOILER_CLASS_NAME } from '$lib/features/spoilers/constants.ts'; import type { MediaStoreProps } from '$lib/models/MediaStoreProps.ts'; import { useMediaSpoiler } from '../useMediaSpoiler.ts'; @@ -8,11 +9,11 @@ export function useSpoilerAction(rest: SpoilerActionProps) { function spoiler(node: HTMLElement) { const add = () => { - node.classList.add('trakt-spoiler'); + node.classList.add(SPOILER_CLASS_NAME); }; const remove = () => { - node.classList.remove('trakt-spoiler'); + node.classList.remove(SPOILER_CLASS_NAME); }; function applySpoilerStyle(isHidden: boolean) { @@ -29,7 +30,7 @@ export function useSpoilerAction(rest: SpoilerActionProps) { return { destroy() { unsubscribe(); - node.classList.remove('trakt-spoiler'); + node.classList.remove(SPOILER_CLASS_NAME); }, }; } diff --git a/projects/client/src/lib/features/spoilers/constants.ts b/projects/client/src/lib/features/spoilers/constants.ts new file mode 100644 index 000000000..8e6262ae8 --- /dev/null +++ b/projects/client/src/lib/features/spoilers/constants.ts @@ -0,0 +1 @@ +export const SPOILER_CLASS_NAME = 'trakt-spoiler'; diff --git a/projects/client/src/lib/features/spoilers/useMediaSpoiler.ts b/projects/client/src/lib/features/spoilers/useMediaSpoiler.ts index d1ace64fe..6d72cebc8 100644 --- a/projects/client/src/lib/features/spoilers/useMediaSpoiler.ts +++ b/projects/client/src/lib/features/spoilers/useMediaSpoiler.ts @@ -1,7 +1,7 @@ import type { MediaStoreProps } from '$lib/models/MediaStoreProps.ts'; import { useIsWatched, -} from '$lib/sections/media-actions/mark-as-watched/useIsWatched'; +} from '$lib/sections/media-actions/mark-as-watched/useIsWatched.ts'; import { derived } from 'svelte/store'; import { useSpoiler } from './_internal/useSpoiler.ts'; diff --git a/projects/client/src/lib/requests/_internal/mapCommentResponseToMediaComment.ts b/projects/client/src/lib/requests/_internal/mapCommentResponseToMediaComment.ts new file mode 100644 index 000000000..32a4df4aa --- /dev/null +++ b/projects/client/src/lib/requests/_internal/mapCommentResponseToMediaComment.ts @@ -0,0 +1,32 @@ +import { DEFAULT_AVATAR } from '$lib/utils/constants'; +import type { CommentResponse } from '@trakt/api'; +import type { MediaComment } from '../models/MediaComment'; + +export function mapCommentResponseToMediaComment( + commentResponse: CommentResponse, +): MediaComment { + return { + id: commentResponse.id, + parentId: commentResponse.parent_id, + createdAt: new Date(commentResponse.created_at), + updatedAt: new Date(commentResponse.updated_at), + comment: commentResponse.comment, + isSpoiler: commentResponse.spoiler, + isReview: commentResponse.review, + replyCount: commentResponse.replies, + likeCount: commentResponse.likes, + user: { + userName: commentResponse.user.username, + isVip: commentResponse.user.vip || commentResponse.user.vip_ep, + slug: commentResponse.user.ids.slug, + avatar: { + url: commentResponse.user.images?.avatar.full ?? DEFAULT_AVATAR, + }, + stats: { + rating: commentResponse.user_stats.rating, + playCount: commentResponse.user_stats.play_count, + completedCount: commentResponse.user_stats.completed_count, + }, + }, + }; +} diff --git a/projects/client/src/lib/requests/models/MediaComment.ts b/projects/client/src/lib/requests/models/MediaComment.ts new file mode 100644 index 000000000..d29d0c255 --- /dev/null +++ b/projects/client/src/lib/requests/models/MediaComment.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const MediaCommentSchema = z.object({ + id: z.number(), + parentId: z.number(), + createdAt: z.date(), + updatedAt: z.date(), + comment: z.string(), + isSpoiler: z.boolean(), + isReview: z.boolean(), + replyCount: z.number(), + likeCount: z.number(), + user: z.object({ + userName: z.string(), + isVip: z.boolean(), + slug: z.string(), + avatar: z.object({ + url: z.string(), + }), + stats: z.object({ + rating: z.number().nullable(), + playCount: z.number(), + completedCount: z.number(), + }), + }), +}); + +export type MediaComment = z.infer; diff --git a/projects/client/src/lib/requests/queries/movies/movieCommentsQuery.spec.ts b/projects/client/src/lib/requests/queries/movies/movieCommentsQuery.spec.ts new file mode 100644 index 000000000..1acdbaf89 --- /dev/null +++ b/projects/client/src/lib/requests/queries/movies/movieCommentsQuery.spec.ts @@ -0,0 +1,18 @@ +import { MovieHereticCommentsMappedMock } from '$mocks/data/summary/movies/heretic/mapped/MovieHereticCommentsMappedMock.ts'; +import { MovieHereticMappedMock } from '$mocks/data/summary/movies/heretic/mapped/MovieHereticMappedMock.ts'; +import { runQuery } from '$test/beds/query/runQuery.ts'; +import { createQuery } from '@tanstack/svelte-query'; +import { describe, expect, it } from 'vitest'; +import { movieCommentsQuery } from './movieCommentsQuery.ts'; + +describe('movieCommentsQuery', () => { + it('should query for comments on a movie', async () => { + const result = await runQuery({ + factory: () => + createQuery(movieCommentsQuery({ slug: MovieHereticMappedMock.slug })), + mapper: (response) => response?.data, + }); + + expect(result).to.deep.equal(MovieHereticCommentsMappedMock); + }); +}); diff --git a/projects/client/src/lib/requests/queries/movies/movieCommentsQuery.ts b/projects/client/src/lib/requests/queries/movies/movieCommentsQuery.ts new file mode 100644 index 000000000..b760c79b7 --- /dev/null +++ b/projects/client/src/lib/requests/queries/movies/movieCommentsQuery.ts @@ -0,0 +1,41 @@ +import { defineQuery } from '$lib/features/query/defineQuery.ts'; +import { mapCommentResponseToMediaComment } from '$lib/requests/_internal/mapCommentResponseToMediaComment.ts'; +import { api, type ApiParams } from '$lib/requests/api.ts'; +import { MediaCommentSchema } from '$lib/requests/models/MediaComment.ts'; +import { time } from '$lib/utils/timing/time.ts'; + +const DEFAULT_COMMENT_SORT = 'likes' as const; + +type MovieCommentsParams = { slug: string } & ApiParams; + +const movieCommentsRequest = ( + { fetch, slug }: MovieCommentsParams, +) => + api({ fetch }) + .movies + .comments({ + params: { + id: slug, + sort: DEFAULT_COMMENT_SORT, + }, + query: { + extended: 'images', + }, + }) + .then((response) => { + if (response.status !== 200) { + throw new Error('Failed to fetch movie comments'); + } + + return response.body; + }); + +export const movieCommentsQuery = defineQuery({ + key: 'movieComments', + invalidations: [], + dependencies: (params) => [params.slug], + request: movieCommentsRequest, + mapper: (data) => data.map(mapCommentResponseToMediaComment), + schema: MediaCommentSchema.array(), + ttl: time.minutes(30), +}); diff --git a/projects/client/src/lib/requests/queries/shows/showCommentsQuery.spec.ts b/projects/client/src/lib/requests/queries/shows/showCommentsQuery.spec.ts new file mode 100644 index 000000000..f28137079 --- /dev/null +++ b/projects/client/src/lib/requests/queries/shows/showCommentsQuery.spec.ts @@ -0,0 +1,18 @@ +import { ShowSiloCommentsMappedMock } from '$mocks/data/summary/shows/silo/mapped/ShowSiloCommentsMappedMock.ts'; +import { ShowSiloMappedMock } from '$mocks/data/summary/shows/silo/mapped/ShowSiloMappedMock.ts'; +import { runQuery } from '$test/beds/query/runQuery.ts'; +import { createQuery } from '@tanstack/svelte-query'; +import { describe, expect, it } from 'vitest'; +import { showCommentsQuery } from './showCommentsQuery.ts'; + +describe('showCommentsQuery', () => { + it('should query for comments on a show', async () => { + const result = await runQuery({ + factory: () => + createQuery(showCommentsQuery({ slug: ShowSiloMappedMock.slug })), + mapper: (response) => response?.data, + }); + + expect(result).to.deep.equal(ShowSiloCommentsMappedMock); + }); +}); diff --git a/projects/client/src/lib/requests/queries/shows/showCommentsQuery.ts b/projects/client/src/lib/requests/queries/shows/showCommentsQuery.ts new file mode 100644 index 000000000..b4f25d330 --- /dev/null +++ b/projects/client/src/lib/requests/queries/shows/showCommentsQuery.ts @@ -0,0 +1,41 @@ +import { defineQuery } from '$lib/features/query/defineQuery.ts'; +import { mapCommentResponseToMediaComment } from '$lib/requests/_internal/mapCommentResponseToMediaComment.ts'; +import { api, type ApiParams } from '$lib/requests/api.ts'; +import { MediaCommentSchema } from '$lib/requests/models/MediaComment.ts'; +import { time } from '$lib/utils/timing/time.ts'; + +const DEFAULT_COMMENT_SORT = 'likes' as const; + +type ShowCommentsParams = { slug: string } & ApiParams; + +const showCommentsRequest = ( + { fetch, slug }: ShowCommentsParams, +) => + api({ fetch }) + .shows + .comments({ + params: { + id: slug, + sort: DEFAULT_COMMENT_SORT, + }, + query: { + extended: 'images', + }, + }) + .then((response) => { + if (response.status !== 200) { + throw new Error('Failed to fetch show comments'); + } + + return response.body; + }); + +export const showCommentsQuery = defineQuery({ + key: 'showComments', + invalidations: [], + dependencies: (params) => [params.slug], + request: showCommentsRequest, + mapper: (data) => data.map(mapCommentResponseToMediaComment), + schema: MediaCommentSchema.array(), + ttl: time.minutes(30), +}); diff --git a/projects/client/src/lib/sections/summary/MovieSummary.svelte b/projects/client/src/lib/sections/summary/MovieSummary.svelte index 637809ed7..be315d747 100644 --- a/projects/client/src/lib/sections/summary/MovieSummary.svelte +++ b/projects/client/src/lib/sections/summary/MovieSummary.svelte @@ -7,6 +7,7 @@ import type { MovieEntry } from "$lib/requests/models/MovieEntry"; import CastList from "../lists/CastList.svelte"; import RelatedList from "../lists/RelatedList.svelte"; + import Comments from "./components/comments/Comments.svelte"; import Lists from "./components/lists/Lists.svelte"; import MediaSummary from "./components/media/MediaSummary.svelte"; import type { MediaSummaryProps } from "./components/media/MediaSummaryProps"; @@ -39,6 +40,8 @@ + + diff --git a/projects/client/src/lib/sections/summary/ShowSummary.svelte b/projects/client/src/lib/sections/summary/ShowSummary.svelte index 4898423a0..67c768cb9 100644 --- a/projects/client/src/lib/sections/summary/ShowSummary.svelte +++ b/projects/client/src/lib/sections/summary/ShowSummary.svelte @@ -12,6 +12,7 @@ import CastList from "../lists/CastList.svelte"; import RelatedList from "../lists/RelatedList.svelte"; import SeasonList from "../lists/SeasonList.svelte"; + import Comments from "./components/comments/Comments.svelte"; import Lists from "./components/lists/Lists.svelte"; import MediaSummary from "./components/media/MediaSummary.svelte"; import type { MediaSummaryProps } from "./components/media/MediaSummaryProps"; @@ -62,6 +63,8 @@ + + diff --git a/projects/client/src/lib/sections/summary/components/_internal/UserAvatar.svelte b/projects/client/src/lib/sections/summary/components/_internal/UserAvatar.svelte new file mode 100644 index 000000000..cf7caa293 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/_internal/UserAvatar.svelte @@ -0,0 +1,45 @@ + + +
+ + + {#if icon} + {@render icon()} + {/if} +
+ + diff --git a/projects/client/src/lib/sections/summary/components/_internal/UserProfileLink.svelte b/projects/client/src/lib/sections/summary/components/_internal/UserProfileLink.svelte new file mode 100644 index 000000000..e0982c8ec --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/_internal/UserProfileLink.svelte @@ -0,0 +1,17 @@ + + + +

+ {name} +

+ diff --git a/projects/client/src/lib/sections/summary/components/comments/Comments.svelte b/projects/client/src/lib/sections/summary/components/comments/Comments.svelte new file mode 100644 index 000000000..4c3087c6c --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/Comments.svelte @@ -0,0 +1,37 @@ + + + + {#snippet item(comment)} + + {/snippet} + + {#snippet empty()} + {#if !$isLoading} +

{m.no_comments()}

+ {/if} + {/snippet} +
diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/CommentCard.svelte b/projects/client/src/lib/sections/summary/components/comments/_internal/CommentCard.svelte new file mode 100644 index 000000000..6b83d61a9 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/CommentCard.svelte @@ -0,0 +1,83 @@ + + + +
+ + + +
+ + {@html marked.parse(comment.comment, { gfm: true, breaks: true })} +
+
+
+
+
+ + diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/CommentHeader.svelte b/projects/client/src/lib/sections/summary/components/comments/_internal/CommentHeader.svelte new file mode 100644 index 000000000..7afef9c97 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/CommentHeader.svelte @@ -0,0 +1,94 @@ + + +{#snippet icon()} + {#if commenterRating} +
+ +
+ {/if} +{/snippet} + +
+ + {#snippet icon()} + {#if commenterRating} + + {/if} + {/snippet} + + +
+
+

+ {comment.isReview ? m.review_by() : m.shout_by()} +

+ + {#if comment.user.isVip} + + {/if} +
+

+ {toHumanDate(new Date(), comment.createdAt, getLocale())} +

+
+
+ + diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/ShadowScroller.svelte b/projects/client/src/lib/sections/summary/components/comments/_internal/ShadowScroller.svelte new file mode 100644 index 000000000..27dbf5ba6 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/ShadowScroller.svelte @@ -0,0 +1,60 @@ + + +
+
+ {@render children()} +
+
+ + diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/UserRatingIcon.svelte b/projects/client/src/lib/sections/summary/components/comments/_internal/UserRatingIcon.svelte new file mode 100644 index 000000000..dd5048948 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/UserRatingIcon.svelte @@ -0,0 +1,30 @@ + + +
+ +
+ + diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/setScrollInfo.spec.ts b/projects/client/src/lib/sections/summary/components/comments/_internal/setScrollInfo.spec.ts new file mode 100644 index 000000000..c90c11674 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/setScrollInfo.spec.ts @@ -0,0 +1,76 @@ +import { setScrollInfo } from '$lib/sections/summary/components/comments/_internal/setScrollInfo.ts'; +import { renderStore } from '$test/beds/store/renderStore.ts'; +import { describe, expect, it, vi } from 'vitest'; + +describe('action: setScrollInfo', () => { + function scrollTo( + node: HTMLDivElement, + location: 'top' | 'middle' | 'bottom', + ) { + vi.spyOn(node, 'scrollHeight', 'get') + .mockReturnValueOnce(100); + + vi.spyOn(node, 'clientHeight', 'get') + .mockReturnValueOnce(50); + + switch (location) { + case 'top': + node.scrollTop = 0; + break; + case 'middle': + node.scrollTop = 25; + break; + case 'bottom': + node.scrollTop = 50; + break; + } + + node.dispatchEvent(new Event('scroll')); + } + + it('should not add scroll info if the node has no overflow', async () => { + const node = document.createElement('div'); + const component = await renderStore(() => setScrollInfo(node)); + + expect(node.classList).not.toContain('scrolled-down'); + expect(node.classList).not.toContain('scrolled-up'); + + component.destroy(); + }); + + it('should indicate it scrolled down to the bottom', async () => { + const node = document.createElement('div'); + const component = await renderStore(() => setScrollInfo(node)); + + scrollTo(node, 'bottom'); + + expect(node.classList).toContain('scrolled-down'); + expect(node.classList).not.toContain('scrolled-up'); + + component.destroy(); + }); + + it('should indicate it scrolled up to the top', async () => { + const node = document.createElement('div'); + const component = await renderStore(() => setScrollInfo(node)); + + scrollTo(node, 'top'); + + expect(node.classList).not.toContain('scrolled-down'); + expect(node.classList).toContain('scrolled-up'); + + component.destroy(); + }); + + it('should indicate it scrolled a little', async () => { + const node = document.createElement('div'); + const component = await renderStore(() => setScrollInfo(node)); + + scrollTo(node, 'middle'); + + expect(node.classList).toContain('scrolled-down'); + expect(node.classList).toContain('scrolled-up'); + + component.destroy(); + }); +}); diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/setScrollInfo.ts b/projects/client/src/lib/sections/summary/components/comments/_internal/setScrollInfo.ts new file mode 100644 index 000000000..337bd6479 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/setScrollInfo.ts @@ -0,0 +1,33 @@ +import { GlobalEventBus } from '$lib/utils/events/GlobalEventBus.ts'; +import { onMount } from 'svelte'; + +export function setScrollInfo(node: HTMLElement) { + const scrollHandler = () => { + const { scrollTop, scrollHeight, clientHeight } = node; + const hasOverflow = scrollHeight > clientHeight; + if (!hasOverflow) { + return; + } + + const isAtTop = scrollTop === 0; + const isAtBottom = scrollHeight - scrollTop === clientHeight; + + node.classList.toggle('scrolled-down', !isAtTop); + node.classList.toggle('scrolled-up', !isAtBottom); + }; + + node.addEventListener('scroll', scrollHandler); + const unregisterResize = GlobalEventBus.getInstance().register( + 'resize', + () => requestAnimationFrame(scrollHandler), + ); + + onMount(scrollHandler); + + return { + destroy() { + unregisterResize(); + node.removeEventListener('scroll', scrollHandler); + }, + }; +} diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/spoilMeAnyway.spec.ts b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilMeAnyway.spec.ts new file mode 100644 index 000000000..7573ea441 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilMeAnyway.spec.ts @@ -0,0 +1,32 @@ +import { SPOILER_CLASS_NAME } from '$lib/features/spoilers/constants.ts'; +import { spoilMeAnyway } from '$lib/sections/summary/components/comments/_internal/spoilMeAnyway.ts'; +import { renderStore } from '$test/beds/store/renderStore.ts'; +import { describe, expect, it } from 'vitest'; + +describe('action: spoilMeAnyway', () => { + it('should remove spoilers on the entire comment', async () => { + const commentNode = document.createElement('div'); + commentNode.classList.add(SPOILER_CLASS_NAME); + + const component = await renderStore(() => spoilMeAnyway(commentNode)); + commentNode.dispatchEvent(new Event('click')); + + expect(commentNode.classList).not.toContain(SPOILER_CLASS_NAME); + + component.destroy(); + }); + + it('should remove spoilers in a comment', async () => { + const commentNode = document.createElement('div'); + const spoilerNode = document.createElement('div'); + commentNode.appendChild(spoilerNode); + spoilerNode.classList.add(SPOILER_CLASS_NAME); + + const component = await renderStore(() => spoilMeAnyway(commentNode)); + spoilerNode.dispatchEvent(new Event('click', { bubbles: true })); + + expect(spoilerNode.classList).not.toContain(SPOILER_CLASS_NAME); + + component.destroy(); + }); +}); diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/spoilMeAnyway.ts b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilMeAnyway.ts new file mode 100644 index 000000000..c47bfe1a2 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilMeAnyway.ts @@ -0,0 +1,20 @@ +import { SPOILER_CLASS_NAME } from '$lib/features/spoilers/constants.ts'; + +export function spoilMeAnyway(node: HTMLElement) { + function handleClick(e: MouseEvent) { + if (!(e.target instanceof HTMLElement)) { + return; + } + + node.classList.remove(SPOILER_CLASS_NAME); + e.target.classList.remove(SPOILER_CLASS_NAME); + } + + node.addEventListener('click', handleClick); + + return { + destroy() { + node.removeEventListener('click', handleClick); + }, + }; +} diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.spec.ts b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.spec.ts new file mode 100644 index 000000000..19aa200de --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { + matchSpoilerTag, + matchSpoilerTagStart, + spoilerRenderer, +} from './spoilerExtension.ts'; + +describe('spoilerExtension', () => { + it('should match the start of a spoiler tag', () => { + const index = matchSpoilerTagStart('[spoiler]'); + expect(index).to.equal(0); + }); + + it('should match the a spoiler tag', () => { + const match = matchSpoilerTag('[spoiler]test[/spoiler]'); + expect(match).to.deep.equal(['[spoiler]test[/spoiler]', 'test']); + }); + + it('should not match the another tag', () => { + const match = matchSpoilerTag('[bold]test[/bold]'); + expect(match).to.deep.equal(null); + }); + + it('should render a spoiler tag', () => { + const renderedResult = spoilerRenderer('test', false); + + expect(renderedResult).to.equal("

test

"); + }); + + it('should not render a spoiler tag if the entire comment is a spoiler', () => { + const renderedResult = spoilerRenderer('test', true); + + expect(renderedResult).to.equal('

test

'); + }); +}); diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.ts b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.ts new file mode 100644 index 000000000..da9e9cb6f --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.ts @@ -0,0 +1,53 @@ +import type { TokenizerAndRendererExtension } from 'marked'; + +export function matchSpoilerTagStart(src: string) { + return src.match(/\[spoiler\]/)?.index; +} + +export function matchSpoilerTag(src: string) { + const rule = /^\[spoiler\](.*?)\[\/spoiler\]/; + return rule.exec(src); +} + +export function spoilerRenderer( + text: string, + isCommentSpoiler: boolean, +) { + if (isCommentSpoiler) { + // If the comment itself is already marked as a spoiler, + // then parse the individual spoilers as a normal paragraph + return `

${text}

`; + } + + return `

${text}

`; +} + +export function spoilerExtension( + isCommentSpoiler: boolean, +): TokenizerAndRendererExtension { + return { + name: 'spoiler', + level: 'inline', + start(src: string) { + return matchSpoilerTagStart(src); + }, + tokenizer(src) { + const match = matchSpoilerTag(src); + if (match) { + const token = { + type: 'spoiler', + raw: match[0], + text: match[1]?.trim() ?? '', + tokens: [], + }; + this.lexer.inline(token.text, token.tokens); + return token; + } + + return undefined; + }, + renderer(token) { + return spoilerRenderer(token.text, isCommentSpoiler); + }, + }; +} diff --git a/projects/client/src/lib/sections/summary/components/comments/_internal/useComments.ts b/projects/client/src/lib/sections/summary/components/comments/_internal/useComments.ts new file mode 100644 index 000000000..56af5bdb1 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/useComments.ts @@ -0,0 +1,43 @@ +import { useQuery } from '$lib/features/query/useQuery.ts'; +import type { MediaComment } from '$lib/requests/models/MediaComment.ts'; +import type { MediaType } from '$lib/requests/models/MediaType.ts'; +import { movieCommentsQuery } from '$lib/requests/queries/movies/movieCommentsQuery.ts'; +import { showCommentsQuery } from '$lib/requests/queries/shows/showCommentsQuery.ts'; +import { toLoadingState } from '$lib/utils/requests/toLoadingState.ts'; +import { type CreateQueryOptions } from '@tanstack/svelte-query'; +import { derived } from 'svelte/store'; + +type UseCommentsProps = { + slug: string; + type: MediaType; +}; + +function typeToQuery({ + slug, + type, +}: UseCommentsProps) { + switch (type) { + case 'movie': + return movieCommentsQuery({ slug }) as CreateQueryOptions< + MediaComment[] + >; + case 'show': + return showCommentsQuery({ slug }) as CreateQueryOptions< + MediaComment[] + >; + } +} + +export function useComments(props: UseCommentsProps) { + const query = useQuery(typeToQuery(props)); + + const isLoading = derived( + query, + toLoadingState, + ); + + return { + isLoading, + comments: derived(query, ($query) => $query.data ?? []), + }; +} diff --git a/projects/client/src/lib/sections/summary/components/lists/_internal/ListHeader.svelte b/projects/client/src/lib/sections/summary/components/lists/_internal/ListHeader.svelte index 8a59c7ee6..a52dd4ad9 100644 --- a/projects/client/src/lib/sections/summary/components/lists/_internal/ListHeader.svelte +++ b/projects/client/src/lib/sections/summary/components/lists/_internal/ListHeader.svelte @@ -1,19 +1,13 @@
-
- -
+

@@ -21,11 +15,7 @@

{m.by()}

- -

- {list.user.userName} -

- +
@@ -38,20 +28,6 @@ gap: var(--gap-xs); } - .list-creator-avatar { - width: var(--ni-36); - height: var(--ni-36); - flex-shrink: 0; - - :global(img) { - border-radius: 50%; - box-sizing: border-box; - - width: 100%; - height: 100%; - } - } - .list-name-and-creator { display: grid; } diff --git a/projects/client/src/mocks/data/summary/movies/heretic/mapped/MovieHereticCommentsMappedMock.ts b/projects/client/src/mocks/data/summary/movies/heretic/mapped/MovieHereticCommentsMappedMock.ts new file mode 100644 index 000000000..97ceca414 --- /dev/null +++ b/projects/client/src/mocks/data/summary/movies/heretic/mapped/MovieHereticCommentsMappedMock.ts @@ -0,0 +1,30 @@ +import type { MediaComment } from '$lib/requests/models/MediaComment.ts'; +import { DEFAULT_AVATAR } from '$lib/utils/constants.ts'; + +export const MovieHereticCommentsMappedMock: MediaComment[] = [ + { + 'comment': + 'This all could have been avoided if he just started a podcast like a normal dude', + 'createdAt': new Date('2024-11-08T06:21:26.000Z'), + 'id': 1337, + 'isReview': false, + 'isSpoiler': false, + 'likeCount': 102, + 'parentId': 0, + 'replyCount': 1, + 'updatedAt': new Date('2024-11-08T06:21:26.000Z'), + 'user': { + 'avatar': { + 'url': DEFAULT_AVATAR, + }, + 'isVip': true, + 'slug': 'heretic', + 'stats': { + 'completedCount': 1, + 'playCount': 1, + 'rating': 8, + }, + 'userName': 'Heretic', + }, + }, +]; diff --git a/projects/client/src/mocks/data/summary/movies/heretic/response/MovieHereticCommentsResponseMock.ts b/projects/client/src/mocks/data/summary/movies/heretic/response/MovieHereticCommentsResponseMock.ts new file mode 100644 index 000000000..e4180017d --- /dev/null +++ b/projects/client/src/mocks/data/summary/movies/heretic/response/MovieHereticCommentsResponseMock.ts @@ -0,0 +1,33 @@ +import type { CommentResponse } from '$lib/api.ts'; + +export const MovieHereticCommentsResponseMock: CommentResponse[] = [ + { + 'id': 1337, + 'comment': + 'This all could have been avoided if he just started a podcast like a normal dude', + 'spoiler': false, + 'review': false, + 'parent_id': 0, + 'created_at': '2024-11-08T06:21:26.000Z', + 'updated_at': '2024-11-08T06:21:26.000Z', + 'replies': 1, + 'likes': 102, + 'user_rating': 8, + 'user_stats': { + 'rating': 8, + 'play_count': 1, + 'completed_count': 1, + }, + 'user': { + 'username': 'Heretic', + 'private': false, + 'name': 'Heretic', + 'vip': true, + 'vip_ep': false, + 'ids': { + 'slug': 'heretic', + 'trakt': 8008135, + }, + }, + }, +]; diff --git a/projects/client/src/mocks/data/summary/shows/silo/mapped/ShowSiloCommentsMappedMock.ts b/projects/client/src/mocks/data/summary/shows/silo/mapped/ShowSiloCommentsMappedMock.ts new file mode 100644 index 000000000..72c20ac08 --- /dev/null +++ b/projects/client/src/mocks/data/summary/shows/silo/mapped/ShowSiloCommentsMappedMock.ts @@ -0,0 +1,30 @@ +import type { MediaComment } from '$lib/requests/models/MediaComment.ts'; +import { DEFAULT_AVATAR } from '$lib/utils/constants.ts'; + +export const ShowSiloCommentsMappedMock: MediaComment[] = [ + { + 'comment': + "this looks really good, can't wait. The fact that its on AppleTV and not Netflix series gives a big hope", + 'createdAt': new Date('2023-03-09T06:25:15.000Z'), + 'id': 420, + 'isReview': false, + 'isSpoiler': false, + 'likeCount': 39, + 'parentId': 0, + 'replyCount': 1, + 'updatedAt': new Date('2023-03-09T06:25:15.000Z'), + 'user': { + 'avatar': { + 'url': DEFAULT_AVATAR, + }, + 'isVip': true, + 'slug': 'silo_enjoyer', + 'stats': { + 'completedCount': 11, + 'playCount': 11, + 'rating': null, + }, + 'userName': 'silo_enjoyer', + }, + }, +]; diff --git a/projects/client/src/mocks/data/summary/shows/silo/response/ShowSiloCommentsResponseMock.ts b/projects/client/src/mocks/data/summary/shows/silo/response/ShowSiloCommentsResponseMock.ts new file mode 100644 index 000000000..3b24ae52e --- /dev/null +++ b/projects/client/src/mocks/data/summary/shows/silo/response/ShowSiloCommentsResponseMock.ts @@ -0,0 +1,33 @@ +import type { CommentResponse } from '$lib/api.ts'; + +export const ShowSiloCommentsResponseMock: CommentResponse[] = [ + { + 'id': 420, + 'comment': + "this looks really good, can't wait. The fact that its on AppleTV and not Netflix series gives a big hope", + 'spoiler': false, + 'review': false, + 'parent_id': 0, + 'created_at': '2023-03-09T06:25:15.000Z', + 'updated_at': '2023-03-09T06:25:15.000Z', + 'replies': 1, + 'likes': 39, + 'user_rating': null, + 'user_stats': { + 'rating': null, + 'play_count': 11, + 'completed_count': 11, + }, + 'user': { + 'username': 'silo_enjoyer', + 'private': false, + 'name': 'SiloEnjoyer', + 'vip': true, + 'vip_ep': false, + 'ids': { + 'slug': 'silo_enjoyer', + 'trakt': 1337, + }, + }, + }, +]; diff --git a/projects/client/src/mocks/handlers/movies.ts b/projects/client/src/mocks/handlers/movies.ts index 1221631c4..f79721676 100644 --- a/projects/client/src/mocks/handlers/movies.ts +++ b/projects/client/src/mocks/handlers/movies.ts @@ -1,5 +1,6 @@ import { http, HttpResponse } from 'msw'; +import { MovieHereticCommentsResponseMock } from '$mocks/data/summary/movies/heretic/response/MovieHereticCommentsResponseMock.ts'; import { MoviesAnticipatedResponseMock } from '../data/movies/response/MoviesAnticipatedResponseMock.ts'; import { MoviesPopularResponseMock } from '../data/movies/response/MoviesPopularResponseMock.ts'; import { MoviesTrendingResponseMock } from '../data/movies/response/MoviesTrendingResponseMock.ts'; @@ -93,4 +94,10 @@ export const movies = [ return HttpResponse.json(HereticListsResponseMock); }, ), + http.get( + `http://localhost/movies/${MovieHereticResponseMock.ids.slug}/comments/likes*`, + () => { + return HttpResponse.json(MovieHereticCommentsResponseMock); + }, + ), ]; diff --git a/projects/client/src/mocks/handlers/shows.ts b/projects/client/src/mocks/handlers/shows.ts index 869c01526..4df14d71b 100644 --- a/projects/client/src/mocks/handlers/shows.ts +++ b/projects/client/src/mocks/handlers/shows.ts @@ -1,5 +1,6 @@ import { http, HttpResponse } from 'msw'; +import { ShowSiloCommentsResponseMock } from '$mocks/data/summary/shows/silo/response/ShowSiloCommentsResponseMock.ts'; import { ShowsAnticipatedResponseMock } from '../data/shows/response/ShowsAnticipatedResponseMock.ts'; import { ShowsPopularResponseMock } from '../data/shows/response/ShowsPopularResponseMock.ts'; import { ShowsTrendingResponseMock } from '../data/shows/response/ShowsTrendingResponseMock.ts'; @@ -152,4 +153,10 @@ export const shows = [ return HttpResponse.json(SiloListsResponseMock); }, ), + http.get( + `http://localhost/shows/${ShowSiloResponseMock.ids.slug}/comments/likes*`, + () => { + return HttpResponse.json(ShowSiloCommentsResponseMock); + }, + ), ]; diff --git a/projects/client/src/style/layout/index.css b/projects/client/src/style/layout/index.css index fab34205b..b0e32d155 100644 --- a/projects/client/src/style/layout/index.css +++ b/projects/client/src/style/layout/index.css @@ -24,7 +24,10 @@ --height-episode-summary-card: var(--ni-112); --width-list-card: var(--ni-480); - --height-list-card: var(--ni-252); + --height-list-card: var(--ni-256); + + --width-comment-card: var(--ni-480); + --height-comment-card: var(--ni-228); /** * List dimensions @@ -50,6 +53,10 @@ var(--height-list-card) + var(--layout-distance-scroll-card) + var(--layout-scrollbar-width) ); + --height-comments-list: calc( + var(--height-comment-card) + var(--layout-distance-scroll-card) + + var(--layout-scrollbar-width) + ); /** * Structural variables diff --git a/projects/client/src/style/scss/mixins/index.scss b/projects/client/src/style/scss/mixins/index.scss index 534f5c32f..2c34063cb 100644 --- a/projects/client/src/style/scss/mixins/index.scss +++ b/projects/client/src/style/scss/mixins/index.scss @@ -19,8 +19,7 @@ } @mixin for-tablet-sm { - @media (min-width: $breakpoint-tablet-sm-min) - and (max-width: $breakpoint-tablet-sm-max) { + @media (min-width: $breakpoint-tablet-sm-min) and (max-width: $breakpoint-tablet-sm-max) { @content; } } @@ -32,8 +31,7 @@ } @mixin for-tablet-lg { - @media (min-width: $breakpoint-tablet-lg-min) - and (max-width: $breakpoint-tablet-lg-max) { + @media (min-width: $breakpoint-tablet-lg-min) and (max-width: $breakpoint-tablet-lg-max) { @content; } } @@ -63,3 +61,20 @@ @content; } } + +@mixin default-link-style { + text-decoration-color: var(--color-link-active); + + &, + &:visited { + color: var(--color-foreground); + } + + @include for-mouse { + + &:hover, + &:focus-visible { + color: var(--color-link-active); + } + } +}