Skip to content

Commit 8a2feef

Browse files
authored
Merge pull request #204 from jaykerkar0405/feature/124-motivational-quotes
Feature/124 motivational quotes
2 parents de2a705 + ff8879d commit 8a2feef

18 files changed

Lines changed: 1641 additions & 93 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- CreateEnum
2+
CREATE TYPE "QuotesDisplayMode" AS ENUM ('PRE_WORKOUT', 'POST_WORKOUT', 'BETWEEN_SETS');
3+
4+
-- CreateTable
5+
CREATE TABLE "UserSettings" (
6+
"id" STRING NOT NULL,
7+
"userId" STRING NOT NULL,
8+
"motivationalQuotesEnabled" BOOL NOT NULL DEFAULT false,
9+
"quotesDisplayModes" "QuotesDisplayMode"[] DEFAULT ARRAY['PRE_WORKOUT']::"QuotesDisplayMode"[],
10+
11+
CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
-- CreateIndex
15+
CREATE UNIQUE INDEX "UserSettings_userId_key" ON "UserSettings"("userId");
16+
17+
-- AddForeignKey
18+
ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Please do not edit this file manually
2-
# It should be added in your version-control system (e.g., Git)
2+
# It should be added in your version-control system (i.e. Git)
33
provider = "cockroachdb"

prisma/schema/enums.prisma

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,9 @@ enum WorkoutStatus {
3838
Skipped
3939
RestDay
4040
}
41+
42+
enum QuotesDisplayMode {
43+
PRE_WORKOUT
44+
POST_WORKOUT
45+
BETWEEN_SETS
46+
}

prisma/schema/schema.prisma

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ model User {
2727
mesocycles Mesocycle[]
2828
workouts Workout[]
2929
migratedFromV2 Boolean?
30+
settings UserSettings?
3031
}
3132

3233
model Account {
@@ -67,3 +68,13 @@ model VerificationToken {
6768
6869
@@id([identifier, token])
6970
}
71+
72+
model UserSettings {
73+
id String @id @default(cuid())
74+
userId String @unique
75+
76+
motivationalQuotesEnabled Boolean @default(false)
77+
quotesDisplayModes QuotesDisplayMode[] @default([PRE_WORKOUT])
78+
79+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
80+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
<script lang="ts">
2+
import { toast } from 'svelte-sonner';
3+
import Quote from 'virtual:icons/lucide/quote';
4+
import * as Card from '$lib/components/ui/card';
5+
import Loader from 'virtual:icons/lucide/loader';
6+
import { Switch } from '$lib/components/ui/switch';
7+
import { Button } from '$lib/components/ui/button';
8+
import { QuotesDisplayMode } from '@prisma/client';
9+
import { Checkbox } from '$lib/components/ui/checkbox';
10+
11+
interface Props {
12+
quotesEnabled: boolean;
13+
quotesDisplayModes: QuotesDisplayMode[];
14+
onUpdateSettings: (enabled: boolean, modes?: QuotesDisplayMode[]) => Promise<void>;
15+
}
16+
17+
let { quotesEnabled, quotesDisplayModes, onUpdateSettings }: Props = $props();
18+
19+
let isUpdating = $state(false);
20+
let localEnabled = $state(quotesEnabled);
21+
let localDisplayModes = $state([...quotesDisplayModes]);
22+
23+
$effect(() => {
24+
localEnabled = quotesEnabled;
25+
localDisplayModes = [...quotesDisplayModes];
26+
});
27+
28+
const displayModeOptions: { label: string; value: QuotesDisplayMode; description: string }[] = [
29+
{
30+
label: 'Pre-Workout',
31+
value: 'PRE_WORKOUT',
32+
description: 'Show quotes before starting your workout'
33+
},
34+
{
35+
label: 'Post-Workout',
36+
value: 'POST_WORKOUT',
37+
description: 'Show quotes after completing your workout'
38+
},
39+
{
40+
label: 'Between Sets',
41+
value: 'BETWEEN_SETS',
42+
description: 'Show quotes during rest periods between sets'
43+
}
44+
];
45+
46+
const toggleQuotes = async (checked: boolean) => {
47+
if (isUpdating) return;
48+
49+
isUpdating = true;
50+
const previousState = localEnabled;
51+
localEnabled = checked;
52+
53+
try {
54+
await onUpdateSettings(checked);
55+
toast.success(checked ? 'Motivational quotes enabled' : 'Motivational quotes disabled');
56+
} catch (error) {
57+
localEnabled = previousState;
58+
toast.error('Failed to toggle quotes');
59+
console.error('Failed to toggle quotes:', error);
60+
} finally {
61+
isUpdating = false;
62+
}
63+
};
64+
65+
const toggleDisplayMode = async (mode: QuotesDisplayMode, checked: boolean) => {
66+
if (isUpdating || !localEnabled) return;
67+
68+
const newModes = checked ? [...localDisplayModes, mode] : localDisplayModes.filter((m) => m !== mode);
69+
70+
if (newModes.length === 0) {
71+
toast.error('At least one display mode must be selected');
72+
return;
73+
}
74+
75+
isUpdating = true;
76+
const previousModes = [...localDisplayModes];
77+
localDisplayModes = newModes;
78+
79+
try {
80+
await onUpdateSettings(localEnabled, newModes);
81+
82+
const option = displayModeOptions.find((opt) => opt.value === mode);
83+
const action = checked ? 'enabled' : 'disabled';
84+
toast.success(`${option?.label} ${action}`);
85+
} catch (error) {
86+
localDisplayModes = previousModes;
87+
console.error('Failed to update display modes:', error);
88+
89+
if (error instanceof Error && error.message.includes('validation')) {
90+
toast.error('Invalid display mode selection');
91+
} else {
92+
toast.error('Failed to update display modes');
93+
}
94+
} finally {
95+
isUpdating = false;
96+
}
97+
};
98+
99+
const selectAllModes = async () => {
100+
if (isUpdating || !localEnabled) return;
101+
102+
const allModes = displayModeOptions.map((opt) => opt.value);
103+
if (localDisplayModes.length === allModes.length) return;
104+
105+
isUpdating = true;
106+
const previousModes = [...localDisplayModes];
107+
localDisplayModes = allModes;
108+
109+
try {
110+
await onUpdateSettings(localEnabled, allModes);
111+
toast.success('All display modes enabled');
112+
} catch (error) {
113+
localDisplayModes = previousModes;
114+
console.error('Failed to select all modes:', error);
115+
toast.error('Failed to enable all modes');
116+
} finally {
117+
isUpdating = false;
118+
}
119+
};
120+
121+
const clearAllModes = async () => {
122+
if (isUpdating || !localEnabled || localDisplayModes.length <= 1) return;
123+
124+
isUpdating = true;
125+
const previousModes = [...localDisplayModes];
126+
localDisplayModes = [QuotesDisplayMode.PRE_WORKOUT];
127+
128+
try {
129+
await onUpdateSettings(localEnabled, [QuotesDisplayMode.PRE_WORKOUT]);
130+
toast.success('Reset to Pre-Workout only');
131+
} catch (error) {
132+
localDisplayModes = previousModes;
133+
console.error('Failed to clear modes:', error);
134+
toast.error('Failed to reset modes');
135+
} finally {
136+
isUpdating = false;
137+
}
138+
};
139+
140+
const isAllSelected = $derived(localDisplayModes.length === displayModeOptions.length);
141+
const selectedCount = $derived(localDisplayModes.length);
142+
</script>
143+
144+
<Card.Root class="w-full">
145+
<Card.Header>
146+
<Card.Title class="flex items-center gap-2">
147+
<Quote class="h-5 w-5" />
148+
Motivational Quotes
149+
</Card.Title>
150+
<Card.Description>Get inspired with motivational quotes during your workouts</Card.Description>
151+
</Card.Header>
152+
153+
<Card.Content class="space-y-6">
154+
<div class="flex items-center space-x-4 rounded-md border p-4">
155+
<div class="flex-1 space-y-1">
156+
<p class="text-sm font-medium leading-none">Enable Motivational Quotes</p>
157+
<p class="text-xs text-muted-foreground">Display inspirational quotes to keep you motivated</p>
158+
</div>
159+
160+
<div class="flex items-center gap-2">
161+
{#if isUpdating}
162+
<Loader class="h-4 w-4 animate-spin text-muted-foreground" />
163+
{/if}
164+
<Switch name="enable-quotes" checked={localEnabled} onCheckedChange={toggleQuotes} disabled={isUpdating} />
165+
</div>
166+
</div>
167+
168+
{#if localEnabled}
169+
<div class="animate-in slide-in-from-top-2 space-y-4 duration-300">
170+
<div class="flex items-center justify-between">
171+
<div class="space-y-1">
172+
<h4 class="text-sm font-medium">Display Modes</h4>
173+
<p class="text-xs text-muted-foreground">
174+
Choose when to show quotes ({selectedCount} selected)
175+
</p>
176+
</div>
177+
178+
<div class="flex gap-2">
179+
<Button
180+
size="sm"
181+
class="text-xs"
182+
variant="outline"
183+
onclick={selectAllModes}
184+
disabled={isUpdating || isAllSelected}
185+
>
186+
Select All
187+
</Button>
188+
<Button
189+
variant="destructive"
190+
size="sm"
191+
onclick={clearAllModes}
192+
disabled={isUpdating || selectedCount <= 1}
193+
class="text-xs"
194+
>
195+
Reset
196+
</Button>
197+
</div>
198+
</div>
199+
200+
<div class="space-y-3">
201+
{#each displayModeOptions as option}
202+
{@const isSelected = localDisplayModes.includes(option.value)}
203+
<div class="flex items-start space-x-3 rounded-lg border p-3 transition-colors hover:bg-muted/30">
204+
<Checkbox
205+
checked={isSelected}
206+
id={`mode-${option.value}`}
207+
onCheckedChange={(checked) => {
208+
if (typeof checked === 'boolean') {
209+
toggleDisplayMode(option.value, checked);
210+
}
211+
}}
212+
disabled={isUpdating}
213+
class="mt-1"
214+
/>
215+
216+
<div class="flex-1 space-y-1">
217+
<label for={`mode-${option.value}`} class="cursor-pointer text-sm font-medium leading-none">
218+
{option.label}
219+
</label>
220+
<p class="text-xs text-muted-foreground">
221+
{option.description}
222+
</p>
223+
</div>
224+
</div>
225+
{/each}
226+
</div>
227+
</div>
228+
{/if}
229+
</Card.Content>
230+
</Card.Root>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte';
3+
import Quote from 'virtual:icons/lucide/quote';
4+
import type { QuotesDisplayMode } from '@prisma/client';
5+
import { getRandomQuote, type WorkoutQuote } from '$lib/data/workoutQuotes';
6+
7+
interface Props {
8+
class?: string;
9+
mode: QuotesDisplayMode;
10+
}
11+
12+
let currentQuote: WorkoutQuote | null = $state(null);
13+
let { mode, class: className = '' }: Props = $props();
14+
15+
function loadRandomQuote() {
16+
currentQuote = getRandomQuote(mode);
17+
}
18+
19+
onMount(() => {
20+
loadRandomQuote();
21+
});
22+
</script>
23+
24+
{#if currentQuote}
25+
<div
26+
role="region"
27+
aria-label="Motivational quote"
28+
class="rounded-lg border border-border bg-secondary/50 p-4 {className}"
29+
>
30+
<div class="flex items-start gap-3">
31+
<div class="mt-1 flex-shrink-0">
32+
<Quote class="h-5 w-5 text-primary" />
33+
</div>
34+
35+
<div class="min-w-0 flex-1">
36+
<blockquote class="text-base font-medium leading-relaxed text-foreground">
37+
"{currentQuote.quote}"
38+
</blockquote>
39+
40+
<cite class="mt-2 block justify-self-end text-sm font-normal text-muted-foreground">
41+
— {currentQuote.author}
42+
</cite>
43+
</div>
44+
</div>
45+
</div>
46+
{/if}

0 commit comments

Comments
 (0)