Skip to content

Commit 2c5f1d5

Browse files
authored
Merge pull request #178 from Eroge-Abyss/feat-custom-categories
feat: add custom categories and context menu for cards
2 parents 0c92abb + 20253fb commit 2c5f1d5

14 files changed

Lines changed: 610 additions & 38 deletions

src/lib/components/Card.svelte

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { settingsStore } from '$lib/stores/settings.svelte';
66
import NsfwPlaceholder from './NsfwPlaceholder.svelte';
77
import { goto } from '$app/navigation';
8+
import { sessionStore } from '$lib/stores/session.svelte';
89
910
type Props = {
1011
id: string;
@@ -19,11 +20,20 @@
1920
const minutesPlayed = $derived(Math.floor((playtime % 3600) / 60));
2021
2122
const image_url = $derived(image ? convertFileSrc(image) : '');
23+
24+
function handleContextMenu(e: MouseEvent) {
25+
e.preventDefault();
26+
sessionStore.showContextMenu(e.clientX, e.clientY, id);
27+
}
2228
</script>
2329

2430
<!-- svelte-ignore a11y_no_static_element_interactions -->
2531
<!-- svelte-ignore a11y_click_events_have_key_events -->
26-
<section onclick={() => goto(resolve(`/novel/${id}`))} class="card">
32+
<section
33+
onclick={() => goto(resolve(`/novel/${id}`))}
34+
oncontextmenu={handleContextMenu}
35+
class="card"
36+
>
2737
<div class="card-image">
2838
{#if isNsfw && settingsStore.hideNsfwImages}
2939
<NsfwPlaceholder />
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
<script lang="ts">
2+
import { sessionStore } from '$lib/stores/session.svelte';
3+
import { gamesStore } from '$lib/stores/games.svelte';
4+
import { useGameActions } from '$lib/composables/useGameActions.svelte';
5+
import { fly } from 'svelte/transition';
6+
import StatusSelector from './StatusSelector.svelte';
7+
import ConfirmDialog from './ConfirmDialog.svelte';
8+
import { onMount } from 'svelte';
9+
import { getPreferredTitle } from '$lib/util';
10+
11+
// svelte-ignore non_reactive_update
12+
let menuRef: HTMLDivElement;
13+
let showStatusSubmenu = $state(false);
14+
let isDeleteDialogOpen = $state(false);
15+
16+
const gameId = $derived(sessionStore.contextMenu.gameId);
17+
const game = $derived(gameId ? gamesStore.getById(gameId) : undefined);
18+
19+
const actions = useGameActions(() => game);
20+
21+
function close() {
22+
sessionStore.hideContextMenu();
23+
showStatusSubmenu = false;
24+
}
25+
26+
async function toggleStatus(status: string) {
27+
if (!gameId) return;
28+
const currentStatuses = game?.categories || [];
29+
const newStatuses = currentStatuses.includes(status)
30+
? currentStatuses.filter((s) => s !== status)
31+
: [...currentStatuses, status];
32+
await gamesStore.setGameCategories(gameId, newStatuses);
33+
}
34+
35+
function handleKeydown(e: KeyboardEvent) {
36+
if (e.key === 'Escape') close();
37+
}
38+
39+
onMount(() => {
40+
const handleClickOutside = (e: MouseEvent) => {
41+
if (menuRef && !menuRef.contains(e.target as Node)) {
42+
close();
43+
}
44+
};
45+
window.addEventListener('mousedown', handleClickOutside);
46+
return () => window.removeEventListener('mousedown', handleClickOutside);
47+
});
48+
</script>
49+
50+
<svelte:window onkeydown={handleKeydown} onblur={close} />
51+
52+
<!-- svelte-ignore a11y_no_static_element_interactions -->
53+
{#if sessionStore.contextMenu.visible && game}
54+
<div
55+
bind:this={menuRef}
56+
class="context-menu"
57+
role="menu"
58+
tabindex="-1"
59+
style="top: {sessionStore.contextMenu.y}px; left: {sessionStore.contextMenu
60+
.x}px;"
61+
transition:fly={{ duration: 100, y: 5 }}
62+
oncontextmenu={(e) => e.preventDefault()}
63+
>
64+
<div class="menu-header">
65+
<span class="game-title">{getPreferredTitle(game)}</span>
66+
</div>
67+
68+
<div class="menu-divider"></div>
69+
70+
<button
71+
class="menu-item"
72+
onclick={async () => {
73+
await actions.startGame();
74+
close();
75+
}}
76+
>
77+
<i class="fa-solid fa-play"></i>
78+
Start Game
79+
</button>
80+
81+
<button
82+
class="menu-item"
83+
onclick={async () => {
84+
await actions.togglePin();
85+
close();
86+
}}
87+
>
88+
<i
89+
class={game.is_pinned
90+
? 'fa-solid fa-thumbtack-slash'
91+
: 'fa-solid fa-thumbtack'}
92+
></i>
93+
{game.is_pinned ? 'Unpin' : 'Pin'}
94+
</button>
95+
96+
<div class="menu-item-with-submenu">
97+
<button
98+
class="menu-item"
99+
onmouseenter={() => (showStatusSubmenu = true)}
100+
onclick={() => (showStatusSubmenu = !showStatusSubmenu)}
101+
>
102+
<i class="fa-solid fa-tags"></i>
103+
Status
104+
<i class="fa-solid fa-chevron-right chevron"></i>
105+
</button>
106+
107+
{#if showStatusSubmenu}
108+
<!-- svelte-ignore a11y_no_static_element_interactions -->
109+
<div
110+
class="status-submenu"
111+
role="menu"
112+
tabindex="-1"
113+
in:fly={{ x: 5, duration: 150 }}
114+
onmouseleave={() => (showStatusSubmenu = false)}
115+
>
116+
<StatusSelector
117+
categories={game.categories}
118+
{toggleStatus}
119+
clearStatuses={async () => {
120+
if (gameId) await gamesStore.setGameCategories(gameId, []);
121+
}}
122+
/>
123+
</div>
124+
{/if}
125+
</div>
126+
127+
<div class="menu-divider"></div>
128+
129+
<button
130+
class="menu-item"
131+
onclick={async () => {
132+
await actions.editExe();
133+
close();
134+
}}
135+
>
136+
<i class="fa-regular fa-pen-to-square"></i>
137+
Edit Executable
138+
</button>
139+
140+
<button
141+
class="menu-item danger"
142+
onclick={async () => {
143+
isDeleteDialogOpen = true;
144+
close(); // Close the context menu immediately
145+
}}
146+
>
147+
<i class="fa-regular fa-trash-can"></i>
148+
Delete Game
149+
</button>
150+
</div>
151+
{/if}
152+
153+
{#if game}
154+
<ConfirmDialog
155+
bind:isOpen={isDeleteDialogOpen}
156+
title="Delete Game"
157+
onConfirm={actions.deleteGame}
158+
isDanger
159+
message={`Are you sure you want to delete <i class="danger-highlight">${game.title}</i> ?`}
160+
/>
161+
{/if}
162+
163+
<style>
164+
.context-menu {
165+
position: fixed;
166+
z-index: 9999;
167+
background: var(--main-background);
168+
border: 1px solid var(--accent);
169+
border-radius: var(--small-radius);
170+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
171+
padding: 0.5rem;
172+
min-width: 200px;
173+
user-select: none;
174+
}
175+
176+
.menu-header {
177+
padding: 0.5rem 0.75rem;
178+
max-width: 250px;
179+
}
180+
181+
.game-title {
182+
font-size: 0.8rem;
183+
font-weight: 700;
184+
color: var(--secondary);
185+
white-space: nowrap;
186+
overflow: hidden;
187+
text-overflow: ellipsis;
188+
display: block;
189+
}
190+
191+
.menu-divider {
192+
height: 1px;
193+
background: var(--accent);
194+
margin: 0.4rem 0;
195+
}
196+
197+
.menu-item {
198+
width: 100%;
199+
border: 0;
200+
border-radius: var(--small-radius);
201+
color: var(--main-text);
202+
background: transparent;
203+
padding: 0.6rem 0.75rem;
204+
font-size: 13px;
205+
cursor: pointer;
206+
transition: all 0.2s ease;
207+
display: flex;
208+
align-items: center;
209+
gap: 0.75rem;
210+
text-align: left;
211+
}
212+
213+
.menu-item:hover {
214+
background: var(--accent);
215+
}
216+
217+
.menu-item i {
218+
font-size: 14px;
219+
width: 16px;
220+
text-align: center;
221+
opacity: 0.8;
222+
}
223+
224+
.menu-item.danger {
225+
color: #f7768e;
226+
}
227+
228+
.menu-item.danger:hover {
229+
background: rgba(247, 118, 142, 0.1);
230+
}
231+
232+
.menu-item-with-submenu {
233+
position: relative;
234+
}
235+
236+
.chevron {
237+
margin-left: auto;
238+
font-size: 10px !important;
239+
}
240+
241+
.status-submenu {
242+
position: absolute;
243+
left: 100%;
244+
top: -5px;
245+
background: var(--main-background);
246+
border: 1px solid var(--accent);
247+
border-radius: var(--small-radius);
248+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
249+
padding: 0.5rem;
250+
min-width: 180px;
251+
margin-left: 0.25rem;
252+
}
253+
</style>

src/lib/components/StatusSelector.svelte

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { GAME_STATUSES } from '$lib/constants';
2+
import { settingsStore } from '$lib/stores/settings.svelte';
33
import Checkbox from './Checkbox.svelte';
44
55
type Props = {
@@ -17,26 +17,28 @@
1717
}: Props = $props();
1818
</script>
1919

20-
{#each GAME_STATUSES as statusItem (statusItem)}
21-
<label class="menu-item status-checkbox-label">
22-
{statusItem}
23-
<Checkbox
24-
id={`checkbox-${statusItem}`}
25-
checked={categories ? categories.includes(statusItem) : false}
26-
onchange={() => toggleStatus(statusItem)}
27-
/>
28-
</label>
29-
{/each}
30-
{#if showUncategorized}
31-
<label class="menu-item status-checkbox-label">
32-
Uncategorized
33-
<Checkbox
34-
id="checkbox-Uncategorized"
35-
checked={categories ? categories.includes('Uncategorized') : false}
36-
onchange={() => toggleStatus('Uncategorized')}
37-
/>
38-
</label>
39-
{/if}
20+
<div class="status-list-container">
21+
{#each settingsStore.categories as statusItem (statusItem)}
22+
<label class="menu-item status-checkbox-label">
23+
{statusItem}
24+
<Checkbox
25+
id={`checkbox-${statusItem}`}
26+
checked={categories ? categories.includes(statusItem) : false}
27+
onchange={() => toggleStatus(statusItem)}
28+
/>
29+
</label>
30+
{/each}
31+
{#if showUncategorized}
32+
<label class="menu-item status-checkbox-label">
33+
Uncategorized
34+
<Checkbox
35+
id="checkbox-Uncategorized"
36+
checked={categories ? categories.includes('Uncategorized') : false}
37+
onchange={() => toggleStatus('Uncategorized')}
38+
/>
39+
</label>
40+
{/if}
41+
</div>
4042
{#if categories && categories.length > 0}
4143
<div class="menu-divider"></div>
4244
<button onclick={clearStatuses} class="menu-item danger">
@@ -46,6 +48,29 @@
4648
{/if}
4749

4850
<style>
51+
.status-list-container {
52+
max-height: 250px;
53+
overflow-y: auto;
54+
padding-right: 4px;
55+
}
56+
57+
.status-list-container::-webkit-scrollbar {
58+
width: 6px;
59+
}
60+
61+
.status-list-container::-webkit-scrollbar-track {
62+
background: transparent;
63+
}
64+
65+
.status-list-container::-webkit-scrollbar-thumb {
66+
background: var(--accent);
67+
border-radius: 4px;
68+
}
69+
70+
.status-list-container::-webkit-scrollbar-thumb:hover {
71+
background: var(--secondary);
72+
}
73+
4974
.menu-item {
5075
width: 100%;
5176
border: 0;

src/lib/components/UpdateDialog.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import Dialog from '$lib/components/Dialog.svelte';
66
import InfoNote from './InfoNote.svelte';
77
import { toast } from 'svelte-sonner';
8+
import { parseReleaseNotes } from '$lib/util';
89
910
const GITHUB_RELEASE_URL =
1011
'https://github.com/Eroge-Abyss/tadoku/releases/latest';
@@ -76,7 +77,7 @@
7677
<div class="update-notes">
7778
<h5>What's new:</h5>
7879
{#if update.body && update.body.trim().length > 0}
79-
<p>{update.body}</p>
80+
{@html parseReleaseNotes(update.body)}
8081
{:else}
8182
<p>
8283
Check <a

0 commit comments

Comments
 (0)