From c37961cb1914ba1e57d0321a9cad89a9afd012e5 Mon Sep 17 00:00:00 2001 From: seferturan Date: Mon, 10 Feb 2025 20:42:06 +0100 Subject: [PATCH 01/10] refactor: extract common user components --- .../components/_internal/UserAvatar.svelte | 31 ++++++++++++++++++ .../_internal/UserProfileLink.svelte | 17 ++++++++++ .../lists/_internal/ListHeader.svelte | 32 +++---------------- 3 files changed, 52 insertions(+), 28 deletions(-) create mode 100644 projects/client/src/lib/sections/summary/components/_internal/UserAvatar.svelte create mode 100644 projects/client/src/lib/sections/summary/components/_internal/UserProfileLink.svelte 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..87e7d5993 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/_internal/UserAvatar.svelte @@ -0,0 +1,31 @@ + + +
+ +
+ + 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/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; } From 48503684c887d4d830e0e5f1ce3761b5839cf482 Mon Sep 17 00:00:00 2001 From: seferturan Date: Mon, 10 Feb 2025 22:52:06 +0100 Subject: [PATCH 02/10] refactor: extract spoiler class name --- .../spoilers/_internal/useSpoilerAction.spec.ts | 15 ++++++++------- .../spoilers/_internal/useSpoilerAction.ts | 7 ++++--- .../client/src/lib/features/spoilers/constants.ts | 1 + 3 files changed, 13 insertions(+), 10 deletions(-) create mode 100644 projects/client/src/lib/features/spoilers/constants.ts 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'; From 10a71cb15ecec05ba65cfdb540f03f86aa7955fc Mon Sep 17 00:00:00 2001 From: seferturan Date: Wed, 12 Feb 2025 11:19:49 +0100 Subject: [PATCH 03/10] refactor: extract default link style --- .../src/lib/components/link/Link.svelte | 14 +---------- .../client/src/style/scss/mixins/index.scss | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 17 deletions(-) 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/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); + } + } +} From f864c0e081b4e33aeab334cfe5180a35518a6a8e Mon Sep 17 00:00:00 2001 From: seferturan Date: Mon, 10 Feb 2025 20:42:30 +0100 Subject: [PATCH 04/10] fix: use correct avatar size --- .../sections/summary/components/_internal/UserAvatar.svelte | 4 ++-- projects/client/src/style/layout/index.css | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/client/src/lib/sections/summary/components/_internal/UserAvatar.svelte b/projects/client/src/lib/sections/summary/components/_internal/UserAvatar.svelte index 87e7d5993..c8fabce3b 100644 --- a/projects/client/src/lib/sections/summary/components/_internal/UserAvatar.svelte +++ b/projects/client/src/lib/sections/summary/components/_internal/UserAvatar.svelte @@ -16,8 +16,8 @@ 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.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.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.ts b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.ts new file mode 100644 index 000000000..f3e3cb8a9 --- /dev/null +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.ts @@ -0,0 +1,38 @@ +import type { TokenizerAndRendererExtension } from 'marked'; + +export function spoilerExtension( + isCommentSpoiler: boolean, +): TokenizerAndRendererExtension { + return { + name: 'spoiler', + level: 'inline', + start(src: string) { + return src.match(/\[spoiler\]/)?.index; + }, + tokenizer(src) { + const rule = /^\[spoiler\](.*?)\[\/spoiler\]/; + const match = rule.exec(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) { + if (isCommentSpoiler) { + // If the comment itself is already marked as a spoiler, + // then parse the individual spoilers as a normal paragraph + return `${token.text}

`; + } + + return `

${token.text}

`; + }, + }; +} 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/style/layout/index.css b/projects/client/src/style/layout/index.css index 7dba719b8..b0e32d155 100644 --- a/projects/client/src/style/layout/index.css +++ b/projects/client/src/style/layout/index.css @@ -26,6 +26,9 @@ --width-list-card: var(--ni-480); --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 From 3afd7c44f7b8dd12a1750e3310ff46600b4f429f Mon Sep 17 00:00:00 2001 From: seferturan Date: Wed, 12 Feb 2025 12:52:16 +0100 Subject: [PATCH 09/10] test(requests): movie and show comments --- .../queries/movies/movieCommentsQuery.spec.ts | 18 ++++++++++ .../queries/shows/showCommentsQuery.spec.ts | 18 ++++++++++ .../mapped/MovieHereticCommentsMappedMock.ts | 30 +++++++++++++++++ .../MovieHereticCommentsResponseMock.ts | 33 +++++++++++++++++++ .../silo/mapped/ShowSiloCommentsMappedMock.ts | 30 +++++++++++++++++ .../response/ShowSiloCommentsResponseMock.ts | 33 +++++++++++++++++++ projects/client/src/mocks/handlers/movies.ts | 7 ++++ projects/client/src/mocks/handlers/shows.ts | 7 ++++ 8 files changed, 176 insertions(+) create mode 100644 projects/client/src/lib/requests/queries/movies/movieCommentsQuery.spec.ts create mode 100644 projects/client/src/lib/requests/queries/shows/showCommentsQuery.spec.ts create mode 100644 projects/client/src/mocks/data/summary/movies/heretic/mapped/MovieHereticCommentsMappedMock.ts create mode 100644 projects/client/src/mocks/data/summary/movies/heretic/response/MovieHereticCommentsResponseMock.ts create mode 100644 projects/client/src/mocks/data/summary/shows/silo/mapped/ShowSiloCommentsMappedMock.ts create mode 100644 projects/client/src/mocks/data/summary/shows/silo/response/ShowSiloCommentsResponseMock.ts 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/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/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); + }, + ), ]; From f0d3ef5489ef86c48bdd2f2d67d61e1b8bed11ae Mon Sep 17 00:00:00 2001 From: seferturan Date: Wed, 12 Feb 2025 14:02:06 +0100 Subject: [PATCH 10/10] test(summary): comment utils --- .../comments/_internal/setScrollInfo.spec.ts | 76 +++++++++++++++++++ .../comments/_internal/spoilMeAnyway.spec.ts | 32 ++++++++ .../_internal/spoilerExtension.spec.ts | 35 +++++++++ .../comments/_internal/spoilerExtension.ts | 35 ++++++--- 4 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 projects/client/src/lib/sections/summary/components/comments/_internal/setScrollInfo.spec.ts create mode 100644 projects/client/src/lib/sections/summary/components/comments/_internal/spoilMeAnyway.spec.ts create mode 100644 projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.spec.ts 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/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/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 index f3e3cb8a9..da9e9cb6f 100644 --- a/projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.ts +++ b/projects/client/src/lib/sections/summary/components/comments/_internal/spoilerExtension.ts @@ -1,5 +1,27 @@ 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 { @@ -7,11 +29,10 @@ export function spoilerExtension( name: 'spoiler', level: 'inline', start(src: string) { - return src.match(/\[spoiler\]/)?.index; + return matchSpoilerTagStart(src); }, tokenizer(src) { - const rule = /^\[spoiler\](.*?)\[\/spoiler\]/; - const match = rule.exec(src); + const match = matchSpoilerTag(src); if (match) { const token = { type: 'spoiler', @@ -26,13 +47,7 @@ export function spoilerExtension( return undefined; }, renderer(token) { - if (isCommentSpoiler) { - // If the comment itself is already marked as a spoiler, - // then parse the individual spoilers as a normal paragraph - return `${token.text}

`; - } - - return `

${token.text}

`; + return spoilerRenderer(token.text, isCommentSpoiler); }, }; }