Merge pull request #80 from jcreek/48-add-dark-mode-toggle

48 add dark mode toggle
This commit is contained in:
Josh Creek
2026-01-10 21:53:08 +00:00
committed by GitHub
7 changed files with 247 additions and 59 deletions
+14 -1
View File
@@ -1,9 +1,22 @@
<!doctype html> <!doctype html>
<html lang="en" data-theme="pokeball"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function () {
try {
const storedTheme = localStorage.getItem('theme');
const allowed = ['pokeball', 'dark'];
const theme = storedTheme && allowed.includes(storedTheme) ? storedTheme : 'pokeball';
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
// Fallback to default theme if localStorage is not accessible
document.documentElement.setAttribute('data-theme', 'pokeball');
}
})();
</script>
%sveltekit.head% %sveltekit.head%
<link rel="stylesheet" href="%sveltekit.assets%/output.css" /> <link rel="stylesheet" href="%sveltekit.assets%/output.css" />
</head> </head>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import { theme, applyTheme } from '$lib/stores/theme.js';
import { onMount } from 'svelte';
let currentTheme: 'pokeball' | 'dark' = 'pokeball';
// Subscribe to theme changes
$: (currentTheme = $theme), applyTheme($theme);
onMount(() => {
// Initialize theme from localStorage
theme.init();
});
function toggleTheme() {
theme.toggle();
}
</script>
<button
class="btn btn-ghost btn-circle"
on:click={toggleTheme}
aria-label="Toggle dark mode"
title="Toggle dark mode"
>
{#if currentTheme === 'pokeball'}
<!-- Sun icon for light mode (clicking will switch to dark) -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{:else}
<!-- Moon icon for dark mode (clicking will switch to light) -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
{/if}
</button>
@@ -59,7 +59,7 @@
> >
<div class="dex-column pokedex-entry-container"> <div class="dex-column pokedex-entry-container">
<div class="flex mb-2"> <div class="flex mb-2">
<div class="sprite-container flex justify-center items-center bg-white rounded-lg p-2"> <div class="sprite-container flex justify-center items-center bg-base-100 rounded-lg p-2">
<PokemonSprite <PokemonSprite
pokemonName={pokedexEntry.pokemon} pokemonName={pokedexEntry.pokemon}
pokedexNumber={pokedexEntry.pokedexNumber} pokedexNumber={pokedexEntry.pokedexNumber}
@@ -68,18 +68,20 @@
/> />
</div> </div>
<div class="pl-2"> <div class="pl-2">
<h3 class="text-xl font-bold pt-1 text-secondary">{pokedexEntry.pokemon}</h3> <h3 class="text-xl font-bold pt-1">{pokedexEntry.pokemon}</h3>
<sub class="text-gray-200">#{pokedexEntry.pokedexNumber.toString().padStart(3, '0')}</sub> <sub class="text-primary-content/80"
>#{pokedexEntry.pokedexNumber.toString().padStart(3, '0')}</sub
>
</div> </div>
</div> </div>
{#if showForms} {#if showForms}
<div class="bg-white text-black rounded-lg p-4 mb-2"> <div class="bg-base-100 text-base-content rounded-lg p-4 mb-2">
<p><strong>Form:</strong> {pokedexEntry.form ? pokedexEntry.form : '-'}</p> <p><strong>Form:</strong> {pokedexEntry.form ? pokedexEntry.form : '-'}</p>
</div> </div>
{/if} {/if}
<div class="bg-white text-black rounded-lg p-4"> <div class="bg-base-100 text-base-content rounded-lg p-4">
{#if pokedexEntry.evolutionInformation} {#if pokedexEntry.evolutionInformation}
<p><strong>How to evolve: </strong>{pokedexEntry.evolutionInformation}</p> <p><strong>How to evolve: </strong>{pokedexEntry.evolutionInformation}</p>
{:else} {:else}
@@ -95,7 +97,9 @@
</div> </div>
{#if catchRecord} {#if catchRecord}
<div class="dex-column catch-record-container bg-white text-black rounded-lg p-4 mb-4 md:mb-0"> <div
class="dex-column catch-record-container bg-base-100 text-base-content rounded-lg p-4 mb-4 md:mb-0"
>
<div class="flex items-center"> <div class="flex items-center">
<div class="form-control"> <div class="form-control">
<label class="cursor-pointer label"> <label class="cursor-pointer label">
@@ -103,7 +107,7 @@
<input <input
type="checkbox" type="checkbox"
bind:checked={catchRecord.caught} bind:checked={catchRecord.caught}
class="checkbox checkbox-primary border-black" class="checkbox checkbox-primary"
on:change={onCaughtChange} on:change={onCaughtChange}
/> />
</label> </label>
@@ -116,7 +120,7 @@
<input <input
type="checkbox" type="checkbox"
bind:checked={catchRecord.haveToEvolve} bind:checked={catchRecord.haveToEvolve}
class="checkbox checkbox-primary border-black" class="checkbox checkbox-primary"
on:change={onNeedsToEvolveChange} on:change={onNeedsToEvolveChange}
/> />
</label> </label>
@@ -129,7 +133,7 @@
<input <input
type="checkbox" type="checkbox"
bind:checked={catchRecord.inHome} bind:checked={catchRecord.inHome}
class="checkbox checkbox-primary border-black" class="checkbox checkbox-primary"
on:change={() => updateCatchRecord('toggle')} on:change={() => updateCatchRecord('toggle')}
/> />
</label> </label>
@@ -143,7 +147,7 @@
<input <input
type="checkbox" type="checkbox"
bind:checked={catchRecord.hasGigantamaxed} bind:checked={catchRecord.hasGigantamaxed}
class="checkbox checkbox-primary border-black" class="checkbox checkbox-primary"
on:change={() => updateCatchRecord('toggle')} on:change={() => updateCatchRecord('toggle')}
/> />
</label> </label>
@@ -158,7 +162,7 @@
<textarea <textarea
bind:value={catchRecord.personalNotes} bind:value={catchRecord.personalNotes}
id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`} id={`personalNotesInput-${catchRecord._id || pokedexEntry._id}`}
class="form-textarea w-full p-2 border rounded" class="textarea textarea-bordered w-full"
style="min-height: 120px;" style="min-height: 120px;"
on:input={() => updateCatchRecord('notes')} on:input={() => updateCatchRecord('notes')}
on:change={() => updateCatchRecord('notes-blur')} on:change={() => updateCatchRecord('notes-blur')}
@@ -169,7 +173,7 @@
<div class="dex-column additional-details-container"> <div class="dex-column additional-details-container">
{#if showOrigins} {#if showOrigins}
<div class="bg-white text-black rounded-lg p-4 mb-2"> <div class="bg-base-100 text-base-content rounded-lg p-4 mb-2">
<h3 class="text-xl font-semibold mb-4">Origin Dex Requirements</h3> <h3 class="text-xl font-semibold mb-4">Origin Dex Requirements</h3>
<p><strong>Region to Catch In:</strong> {pokedexEntry.regionToCatchIn}</p> <p><strong>Region to Catch In:</strong> {pokedexEntry.regionToCatchIn}</p>
<p><strong>Games to catch in:</strong></p> <p><strong>Games to catch in:</strong></p>
@@ -184,7 +188,7 @@
</div> </div>
{/if} {/if}
{#if pokedexEntry.catchInformation.length > 0} {#if pokedexEntry.catchInformation.length > 0}
<div class="bg-white text-black rounded-lg p-4 mb-2"> <div class="bg-base-100 text-base-content rounded-lg p-4 mb-2">
<p><strong>Where to catch: </strong></p> <p><strong>Where to catch: </strong></p>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
{#each pokedexEntry.catchInformation as info} {#each pokedexEntry.catchInformation as info}
@@ -206,7 +210,7 @@
</p> </p>
</div> </div>
{:else} {:else}
<div class="bg-white text-black rounded-lg p-4 mb-2"> <div class="bg-base-100 text-base-content rounded-lg p-4 mb-2">
<p> <p>
<strong>Where to catch: </strong>Currently missing - can you <strong>Where to catch: </strong>Currently missing - can you
<a <a
@@ -229,27 +233,4 @@
width: 68px; width: 68px;
height: 68px; height: 68px;
} }
.form-checkbox {
appearance: none;
background-color: #fff;
border: 2px solid #000;
border-radius: 0.25rem;
width: 1.5rem;
height: 1.5rem;
display: inline-block;
position: relative;
}
.form-checkbox:checked {
background-color: #00bfff;
background-size: 100% 100%;
background-position: center center;
background-repeat: no-repeat;
}
.form-textarea {
min-height: 3rem;
background-color: #fff;
}
</style> </style>
@@ -25,7 +25,7 @@
{#if isOpen} {#if isOpen}
<div class="modal modal-open" role="dialog" aria-modal="true"> <div class="modal modal-open" role="dialog" aria-modal="true">
<div class="modal-box-custom"> <div class="modal-box-custom bg-primary text-primary-content">
<button class="close-button" on:click={onClose} aria-label="Close"></button> <button class="close-button" on:click={onClose} aria-label="Close"></button>
<div class="modal-content"> <div class="modal-content">
<slot /> <slot />
@@ -52,7 +52,6 @@
max-width: 72rem; max-width: 72rem;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
background-color: rgba(220, 38, 38, 0.95);
border: none; border: none;
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5); box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5);
outline: none; outline: none;
@@ -175,9 +175,9 @@
} else { } else {
const placement = calculateBoxPlacement(index); const placement = calculateBoxPlacement(index);
if (placement.column % 2 === 0) { if (placement.column % 2 === 0) {
return 'background-color: #ffffff'; return 'background-color: var(--ld-box-bg-even);';
} else { } else {
return 'background-color: #f9f9f9;'; return 'background-color: var(--ld-box-bg-odd);';
} }
} }
} }
@@ -533,7 +533,6 @@
form={pokedexEntry.form} form={pokedexEntry.form}
shiny={showShiny} shiny={showShiny}
/> />
<span class="md:hidden">&#9432;</span>
</div> </div>
</div> </div>
<div slot="tooltip"> <div slot="tooltip">
@@ -596,6 +595,21 @@
</main> </main>
<style> <style>
/*
Theme-aware backgrounds for non-caught box slots.
- Light mode (`pokeball`) keeps the original exact colors.
- Dark mode maps to DaisyUI theme base tokens so it stays consistent with the active theme.
*/
:global(:root) {
--ld-box-bg-even: var(--fallback-b1, oklch(var(--b1) / 1));
--ld-box-bg-odd: var(--fallback-b3, oklch(var(--b3) / 1));
}
:global([data-theme='pokeball']) {
--ld-box-bg-even: #ffffff;
--ld-box-bg-odd: #f9f9f9;
}
.boxes-grid { .boxes-grid {
display: grid; display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
@@ -612,6 +626,13 @@
border: 1px solid #ddd; border: 1px solid #ddd;
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
/*
On small screens the grid columns get narrow; without an explicit ratio,
the button height becomes content-driven (padding + sprite) and you end up
with rectangular cells. Force each slot to be square.
*/
aspect-ratio: 1 / 1;
width: 100%;
} }
.pokemon-box--filtered-out { .pokemon-box--filtered-out {
@@ -638,15 +659,53 @@
.pokemon-box :global(img) { .pokemon-box :global(img) {
width: var(--sprite-size, 64px); width: var(--sprite-size, 64px);
height: var(--sprite-size, 64px); height: var(--sprite-size, 64px);
max-width: 100%;
max-height: 100%;
display: block;
object-fit: contain; object-fit: contain;
} }
.sprite-placeholder { .sprite-placeholder {
width: var(--sprite-size, 64px); width: var(--sprite-size, 64px);
height: var(--sprite-size, 64px); height: var(--sprite-size, 64px);
max-width: 100%;
max-height: 100%;
display: block; display: block;
} }
/* Mobile: keep 6 columns but prevent sprite/padding from forcing tall cells */
@media (max-width: 767px) {
.pokemon-box-inner {
/* cap padding so the sprite can fit inside small squares */
padding: min(var(--cell-padding, 1rem), 0.35rem);
}
.pokemon-box :global(img) {
/* allow the sprite to shrink with the square cell */
width: min(var(--sprite-size, 64px), 100%);
height: min(var(--sprite-size, 64px), 100%);
}
.sprite-placeholder {
width: min(var(--sprite-size, 64px), 100%);
height: min(var(--sprite-size, 64px), 100%);
}
.status-badge {
width: 0.95rem;
height: 0.95rem;
}
.status-badge-top {
top: 0.35rem;
}
.status-icon {
width: 0.85rem;
height: 0.85rem;
}
}
.status-badge { .status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+76
View File
@@ -0,0 +1,76 @@
import { writable } from 'svelte/store';
type Theme = 'pokeball' | 'dark';
const STORAGE_KEY = 'theme';
function isTheme(type: string): type is Theme {
return type === 'pokeball' || type === 'dark';
}
function createThemeStore() {
// Get stored theme from localStorage or default to 'pokeball'
let initialTheme: Theme = 'pokeball';
if (typeof window !== 'undefined') {
try {
const storedTheme = localStorage.getItem(STORAGE_KEY);
if (storedTheme && isTheme(storedTheme)) {
initialTheme = storedTheme;
}
} catch {
// ignore (privacy mode / disabled storage)
}
}
const { subscribe, set: writableSet, update } = writable<Theme>(initialTheme);
return {
subscribe,
set: (value: Theme) => {
if (isTheme(value)) {
writableSet(value);
if (typeof window !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEY, value);
} catch {
// ignore (privacy mode / disabled storage)
}
}
}
},
toggle: () => {
update((theme) => {
const newTheme = theme === 'pokeball' ? 'dark' : 'pokeball';
if (typeof window !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEY, newTheme);
} catch {
// ignore (privacy mode / disabled storage)
}
}
return newTheme;
});
},
init: () => {
if (typeof window !== 'undefined') {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && isTheme(stored)) {
writableSet(stored);
}
} catch {
// ignore (privacy mode / disabled storage)
}
}
}
};
}
export const theme = createThemeStore();
// Helper function to apply theme to HTML element
export function applyTheme(themeValue: Theme) {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', themeValue);
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
import { type User } from '@supabase/auth-js'; import { type User } from '@supabase/auth-js';
import SignIn from '$lib/components/SignIn.svelte'; import SignIn from '$lib/components/SignIn.svelte';
import SignOut from '$lib/components/SignOut.svelte'; import SignOut from '$lib/components/SignOut.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import { pwaInfo } from 'virtual:pwa-info'; import { pwaInfo } from 'virtual:pwa-info';
import { pwaAssetsHead } from 'virtual:pwa-assets/head'; import { pwaAssetsHead } from 'virtual:pwa-assets/head';
@@ -28,7 +29,6 @@
const { const {
data: { subscription } data: { subscription }
} = supabase.auth.onAuthStateChange((event, session) => { } = supabase.auth.onAuthStateChange((event, session) => {
console.log('[Layout] Auth state changed:', event, session ? 'Session found' : 'No session');
if (session) { if (session) {
localUser = session.user; localUser = session.user;
} else { } else {
@@ -137,6 +137,7 @@
<a class="btn btn-ghost text-xl" href="/">Living Dex Tracker</a> <a class="btn btn-ghost text-xl" href="/">Living Dex Tracker</a>
</div> </div>
<div class="navbar-end"> <div class="navbar-end">
<ThemeToggle />
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
{#if localUser} {#if localUser}
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar"> <div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">