Skip to content

Commit bbb60eb

Browse files
committed
feat: discover page with library-aware rails and one-click add through prefilled search
1 parent 6149a68 commit bbb60eb

6 files changed

Lines changed: 192 additions & 0 deletions

File tree

web/website/app/app.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const cmdkOpen = useState('cmdk-open', () => false);
8080
8181
const primaryNav = computed<NavigationMenuItem[]>(() => [
8282
{ label: 'Library', to: '/', icon: 'i-lucide-library', exact: true },
83+
{ label: 'Discover', to: '/discover', icon: 'i-lucide-compass' },
8384
{ label: 'Activity', to: '/queue', icon: 'i-lucide-layers' },
8485
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings' },
8586
]);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<template>
2+
<section v-if="marked.length" class="flex flex-col gap-2">
3+
<div class="flex items-center gap-2">
4+
<h2 class="font-display text-lg font-semibold text-highlighted">{{ title }}</h2>
5+
<span class="h-px w-8 bg-vermillion-500 shadow-[0_0_8px_var(--color-vermillion-500)]" />
6+
<span v-if="subtitle" class="font-mono text-[0.6rem] uppercase tracking-[0.2em] text-muted">{{ subtitle }}</span>
7+
</div>
8+
<div class="flex gap-3 overflow-x-auto pb-2">
9+
<component
10+
:is="external ? 'a' : 'button'"
11+
v-for="m in marked"
12+
:key="m.entry.url || m.entry.title"
13+
:href="external ? m.entry.url : undefined"
14+
:target="external ? '_blank' : undefined"
15+
rel="noopener"
16+
class="kenku-lift relative w-28 shrink-0 text-left cursor-pointer group"
17+
:title="m.entry.blurb ?? m.entry.title ?? undefined"
18+
@click="onClick(m)">
19+
<div
20+
class="relative h-40 w-28 rounded-lg overflow-clip ring-1"
21+
:class="m.inLibrary ? 'ring-jade-500/70' : 'ring-default'">
22+
<FallbackImage
23+
:src="m.entry.coverUrl"
24+
:alt="m.entry.title ?? ''"
25+
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
26+
<div class="absolute inset-x-0 bottom-0 pt-8 pb-1.5 px-1.5 bg-gradient-to-t from-black/90 via-black/55 to-transparent">
27+
<p class="text-[0.7rem]/tight font-medium text-white line-clamp-3 [text-shadow:0_1px_8px_rgba(0,0,0,0.6)]">
28+
{{ m.entry.title }}
29+
</p>
30+
</div>
31+
<UBadge
32+
v-if="m.inLibrary"
33+
color="success"
34+
variant="solid"
35+
size="sm"
36+
icon="i-lucide-check"
37+
class="absolute top-1 right-1">In library</UBadge>
38+
</div>
39+
</component>
40+
</div>
41+
</section>
42+
</template>
43+
44+
<script setup lang="ts">
45+
import type { components } from '#open-fetch-schemas/api';
46+
type Entry = components['schemas']['DiscoveryEntry'];
47+
type MinimalSeries = components['schemas']['MinimalSeries'];
48+
49+
const props = defineProps<{
50+
title: string;
51+
subtitle?: string;
52+
entries?: Entry[] | null;
53+
/** Tracked series to match against — matching entries are highlighted and open in place. */
54+
library?: MinimalSeries[] | null;
55+
/** Feed rails link out (reddit posts aren't series); series rails emit into the add flow. */
56+
external?: boolean;
57+
}>();
58+
const emit = defineEmits<{ (e: 'pick', entry: Entry): void; (e: 'open', seriesKey: string): void }>();
59+
60+
const norm = (s?: string | null) => (s ?? '').trim().toLowerCase();
61+
const marked = computed(() =>
62+
(props.entries ?? []).map((entry) => ({
63+
entry,
64+
inLibrary: props.external
65+
? undefined
66+
: (props.library ?? []).find((s) => s.fileLibraryId && norm(s.name) === norm(entry.title)),
67+
}))
68+
);
69+
70+
const onClick = (m: { entry: Entry; inLibrary?: MinimalSeries }) => {
71+
if (props.external) return;
72+
if (m.inLibrary) emit('open', m.inLibrary.key);
73+
else emit('pick', m.entry);
74+
};
75+
</script>

web/website/app/composables/FetchKeys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ export const FetchKeys = {
1313
JobQueue: { All: 'JobQueue' },
1414
NotificationConnectors: { All: 'All' },
1515
Version: 'Version',
16+
Discover: { Manga: 'Discover/Manga', Comics: 'Discover/Comics', Feed: 'Discover/Feed' },
1617
};

web/website/app/pages/discover.vue

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<template>
2+
<KenkuPage title="Discover">
3+
<div class="reveal flex flex-col gap-8 pt-1 pb-24">
4+
<p class="text-sm text-muted -mb-4">
5+
What's moving right now — click anything new to search and add it; anything you already hoard opens in place.
6+
</p>
7+
8+
<div v-if="loading" class="flex gap-3">
9+
<USkeleton v-for="n in 8" :key="n" class="h-40 w-28 rounded-lg shrink-0" />
10+
</div>
11+
12+
<DiscoveryRail
13+
title="Trending manga"
14+
subtitle="AniList · right now"
15+
:entries="manga"
16+
:library="library"
17+
@pick="(e) => goSearch(e.title ?? '')"
18+
@open="openSeries" />
19+
<DiscoveryRail
20+
title="Fresh comics"
21+
subtitle="GetComics · latest posts"
22+
:entries="comics"
23+
:library="library"
24+
@pick="(e) => goSearch(e.title ?? '', 'GetComics')"
25+
@open="openSeries" />
26+
<DiscoveryRail title="From the feeds" subtitle="hot on reddit" :entries="feed" external />
27+
28+
<div v-if="!loading && !manga?.length && !comics?.length && !feed?.length" class="flex flex-col items-center gap-3 py-16 text-center">
29+
<KenkuMark :size="52" class="opacity-80" />
30+
<p class="font-display text-lg text-highlighted">Nothing to show right now</p>
31+
<p class="text-muted max-w-md">The discovery sources didn't answer — they're retried hourly, so check back soon.</p>
32+
</div>
33+
</div>
34+
</KenkuPage>
35+
</template>
36+
37+
<script setup lang="ts">
38+
const { data: manga, pending: mangaPending } = useApi('/v2/Discover/Manga', { key: FetchKeys.Discover.Manga, lazy: true, server: false });
39+
const { data: comics, pending: comicsPending } = useApi('/v2/Discover/Comics', { key: FetchKeys.Discover.Comics, lazy: true, server: false });
40+
const { data: feed } = useApi('/v2/Discover/Feed', { key: FetchKeys.Discover.Feed, lazy: true, server: false });
41+
const { data: library } = useApi('/v2/Series', { key: FetchKeys.Series.All, lazy: true, server: false });
42+
43+
const loading = computed(() => (mangaPending.value || comicsPending.value) && !manga.value?.length && !comics.value?.length);
44+
45+
const goSearch = (title: string, source?: string) =>
46+
navigateTo(`/search?q=${encodeURIComponent(title)}${source ? `&source=${source}` : ''}`);
47+
const openSeries = (key: string) => navigateTo(`/series/${key}`);
48+
49+
useHead({ title: 'Discover' });
50+
</script>

web/website/app/pages/search.vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,18 @@ const search = async (q: string): Promise<MinimalSeries[]> => {
152152
return data ?? [];
153153
};
154154
155+
// Deep links (e.g. Discover cards) land here with ?q= and optionally ?source=; run the search
156+
// on arrival so adding is one click away.
157+
onMounted(() => {
158+
const q = useRoute().query.q;
159+
const source = useRoute().query.source;
160+
if (typeof q !== 'string' || !q) return;
161+
query.value = q;
162+
if (typeof source === 'string')
163+
connector.value = connectors.value?.find((c) => c.name === source) ?? connector.value;
164+
performSearch();
165+
});
166+
155167
const pendingAdd = ref<MinimalSeries | null>(null);
156168
const addModalOpen = ref(false);
157169
const toast = useToast();
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { mountSuspended } from '@nuxt/test-utils/runtime';
3+
import DiscoveryRail from '~/components/DiscoveryRail.vue';
4+
5+
const entries = [
6+
{ title: 'Berserk', coverUrl: 'http://c/1.jpg', url: 'https://anilist.co/manga/1', source: 'AniList', blurb: null },
7+
{ title: 'Pick Me Up', coverUrl: 'http://c/2.jpg', url: 'https://anilist.co/manga/2', source: 'AniList', blurb: 'gacha' },
8+
];
9+
const library = [
10+
{ key: 's1', name: 'berserk', description: '', releaseStatus: 'Continuing', sourceIds: [], fileLibraryId: 'lib1', originalLanguage: 'en', coverUrl: '' },
11+
];
12+
13+
describe('DiscoveryRail', () => {
14+
it('marks entries already in the library and routes them to their series page', async () => {
15+
const wrapper = await mountSuspended(DiscoveryRail, {
16+
props: { title: 'Trending manga', entries, library },
17+
});
18+
19+
expect(wrapper.text()).toContain('Berserk');
20+
expect(wrapper.text()).toContain('In library');
21+
22+
await wrapper.findAll('button')[0]!.trigger('click');
23+
expect(wrapper.emitted('open')).toEqual([['s1']]);
24+
});
25+
26+
it('routes new entries to the add flow', async () => {
27+
const wrapper = await mountSuspended(DiscoveryRail, {
28+
props: { title: 'Trending manga', entries, library },
29+
});
30+
31+
await wrapper.findAll('button')[1]!.trigger('click');
32+
const picked = wrapper.emitted('pick')!;
33+
expect((picked[0]![0] as { title: string }).title).toBe('Pick Me Up');
34+
expect(wrapper.emitted('open')).toBeFalsy();
35+
});
36+
37+
it('external rails link out instead of emitting', async () => {
38+
const wrapper = await mountSuspended(DiscoveryRail, {
39+
props: { title: 'From the feeds', entries, external: true },
40+
});
41+
42+
const link = wrapper.find('a');
43+
expect(link.attributes('href')).toBe('https://anilist.co/manga/1');
44+
expect(link.attributes('target')).toBe('_blank');
45+
expect(wrapper.text()).not.toContain('In library');
46+
});
47+
48+
it('renders nothing while empty', async () => {
49+
const wrapper = await mountSuspended(DiscoveryRail, { props: { title: 'Trending manga', entries: [] } });
50+
51+
expect(wrapper.text()).toBe('');
52+
});
53+
});

0 commit comments

Comments
 (0)