55 changed files with 369 additions and 441 deletions
@ -1,6 +1,5 @@ |
|||||
module.exports = { |
module.exports = { |
||||
plugins: { |
plugins: { |
||||
tailwindcss: {}, |
'@tailwindcss/postcss': {}, |
||||
autoprefixer: {}, |
|
||||
}, |
}, |
||||
}; |
}; |
||||
|
|||||
@ -0,0 +1,39 @@ |
|||||
|
import { useTheme } from "@app/core/hooks/useTheme"; |
||||
|
import { Moon, Sun } from "lucide-react"; |
||||
|
import React from "react"; |
||||
|
|
||||
|
type Theme = "light" | "dark"; |
||||
|
|
||||
|
export default function ThemeSwitcher({ |
||||
|
className = "", |
||||
|
}: { className?: string }) { |
||||
|
const currentTheme = useTheme(); // Get current theme from DOM
|
||||
|
const [theme, setTheme] = React.useState<Theme>(currentTheme); |
||||
|
|
||||
|
React.useEffect(() => { |
||||
|
document.documentElement.setAttribute("data-theme", theme); |
||||
|
localStorage.setItem("theme", theme); |
||||
|
}, [theme]); |
||||
|
|
||||
|
const themeIcons = { |
||||
|
light: ( |
||||
|
<Sun className="size-5 transition-transform duration-300 scale-100" /> |
||||
|
), |
||||
|
dark: ( |
||||
|
<Moon className="size-5 transition-transform duration-300 scale-100" /> |
||||
|
), |
||||
|
}; |
||||
|
|
||||
|
const toggleTheme = () => setTheme(theme === "light" ? "dark" : "light"); |
||||
|
|
||||
|
return ( |
||||
|
<button |
||||
|
type="button" |
||||
|
className={`transition-all duration-300 hover:text-accent ${className}`} |
||||
|
onClick={toggleTheme} |
||||
|
aria-label={`Current theme: ${theme}. Click to change theme.`} |
||||
|
> |
||||
|
{themeIcons[theme]} |
||||
|
</button> |
||||
|
); |
||||
|
} |
||||
@ -1,18 +0,0 @@ |
|||||
import { useAppStore } from "@core/stores/appStore.ts"; |
|
||||
import type { ReactNode } from "react"; |
|
||||
|
|
||||
export interface ThemeControllerProps { |
|
||||
children: ReactNode; |
|
||||
} |
|
||||
|
|
||||
export const ThemeController = ({ |
|
||||
children, |
|
||||
}: ThemeControllerProps): JSX.Element => { |
|
||||
const { darkMode, accent } = useAppStore(); |
|
||||
|
|
||||
return ( |
|
||||
<div data-theme={darkMode ? "dark" : "light"} data-accent={accent}> |
|
||||
{children} |
|
||||
</div> |
|
||||
); |
|
||||
}; |
|
||||
@ -0,0 +1,67 @@ |
|||||
|
import type React from "react"; |
||||
|
import { createContext, useContext, useEffect, useState } from "react"; |
||||
|
|
||||
|
type Theme = "light" | "dark" | "system"; |
||||
|
|
||||
|
interface ThemeContextType { |
||||
|
theme: Theme; |
||||
|
setTheme: (theme: Theme) => void; |
||||
|
} |
||||
|
|
||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined); |
||||
|
|
||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) { |
||||
|
const [theme, setTheme] = useState<Theme>(() => { |
||||
|
if (typeof window !== "undefined") { |
||||
|
const savedTheme = localStorage.getItem("theme") as Theme; |
||||
|
return savedTheme || "system"; |
||||
|
} |
||||
|
return "system"; |
||||
|
}); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const root = window.document.documentElement; |
||||
|
root.classList.remove("light", "dark"); |
||||
|
|
||||
|
if (theme === "system") { |
||||
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") |
||||
|
.matches |
||||
|
? "dark" |
||||
|
: "light"; |
||||
|
root.classList.add(systemTheme); |
||||
|
} else { |
||||
|
root.classList.add(theme); |
||||
|
} |
||||
|
|
||||
|
localStorage.setItem("theme", theme); |
||||
|
}, [theme]); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); |
||||
|
|
||||
|
const handleChange = () => { |
||||
|
if (theme === "system") { |
||||
|
const root = window.document.documentElement; |
||||
|
root.classList.remove("light", "dark"); |
||||
|
root.classList.add(mediaQuery.matches ? "dark" : "light"); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
mediaQuery.addEventListener("change", handleChange); |
||||
|
return () => mediaQuery.removeEventListener("change", handleChange); |
||||
|
}, [theme]); |
||||
|
|
||||
|
return ( |
||||
|
<ThemeContext.Provider value={{ theme, setTheme }}> |
||||
|
{children} |
||||
|
</ThemeContext.Provider> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export function useTheme() { |
||||
|
const context = useContext(ThemeContext); |
||||
|
if (context === undefined) { |
||||
|
throw new Error("useTheme must be used within a ThemeProvider"); |
||||
|
} |
||||
|
return context; |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
import { useEffect, useState } from "react"; |
||||
|
|
||||
|
type Theme = "light" | "dark"; |
||||
|
|
||||
|
export function useTheme() { |
||||
|
const [theme, setTheme] = useState<Theme>(() => { |
||||
|
if (typeof window === "undefined") return "light"; |
||||
|
return ( |
||||
|
(document.documentElement.getAttribute("data-theme") as Theme) || "light" |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const observer = new MutationObserver((mutations) => { |
||||
|
for (const mutation of mutations) { |
||||
|
if ( |
||||
|
mutation.type === "attributes" && |
||||
|
mutation.attributeName === "data-theme" |
||||
|
) { |
||||
|
const newTheme = document.documentElement.getAttribute( |
||||
|
"data-theme", |
||||
|
) as Theme; |
||||
|
setTheme(newTheme); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
observer.observe(document.documentElement, { |
||||
|
attributes: true, |
||||
|
attributeFilter: ["data-theme"], |
||||
|
}); |
||||
|
|
||||
|
return () => observer.disconnect(); |
||||
|
}, []); |
||||
|
|
||||
|
return theme; |
||||
|
} |
||||
@ -1,101 +1,95 @@ |
|||||
@tailwind base; |
@import "tailwindcss"; |
||||
@tailwind components; |
@plugin "tailwindcss-animate"; |
||||
@tailwind utilities; |
|
||||
|
|
||||
:root { |
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); |
||||
|
|
||||
|
@theme { |
||||
|
--font-mono: Cascadia Code, ui-monospace, SFMono-Regular, Menlo, Monaco, |
||||
|
Consolas, "Liberation Mono", "Courier New", monospace; |
||||
|
--font-sans: Inter var, ui-sans-serif, system-ui, sans-serif, |
||||
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; |
||||
|
|
||||
|
--animate-accordion-down: accordion-down 0.2s ease-out; |
||||
|
--animate-accordion-up: accordion-up 0.2s ease-out; |
||||
|
|
||||
|
--color-background-primary: var(--backgroundPrimary); |
||||
|
--color-background-secondary: var(--backgroundSecondary); |
||||
|
--color-accent: var(--accent); |
||||
|
--color-accent-muted: var(--accentMuted); |
||||
|
--color-text-primary: var(--textPrimary); |
||||
|
--color-text-secondary: var(--textSecondary); |
||||
|
--color-link: var(--link); |
||||
|
|
||||
|
--brightness-hover: var(--brightnessHover); |
||||
|
--brightness-press: var(--brightnessPress); |
||||
|
--brightness-disabled: var(--brightnessDisabled); |
||||
|
} |
||||
|
|
||||
|
[data-theme="light"] { |
||||
--backgroundPrimary: #ffffff; |
--backgroundPrimary: #ffffff; |
||||
--backgroundSecondary: #e6e9ed; |
|
||||
--textPrimary: #111132; |
--textPrimary: #111132; |
||||
--textSecondary: #64748b; |
--textSecondary: #64748b; |
||||
--link: #0b69bf; |
--link: #0b69bf; |
||||
|
|
||||
--brighnessHover: 0.95; |
--brightnessHover: 0.95; |
||||
--brightnessPress: 1.05; |
--brightnessPress: 1.05; |
||||
--brightnessDisabled: 0.75; |
--brightnessDisabled: 0.75; |
||||
} |
} |
||||
|
|
||||
[data-theme="dark"] { |
[data-theme="dark"] { |
||||
--backgroundPrimary: #0f172a; |
--backgroundPrimary: #0f172a; |
||||
--backgroundSecondary: #363638; |
|
||||
--textPrimary: #ebebeb; |
--textPrimary: #ebebeb; |
||||
--textSecondary: #bdbdbd; |
--textSecondary: #bdbdbd; |
||||
--link: #8ec9ff; |
--link: #8ec9ff; |
||||
|
|
||||
--brighnessHover: 1.1; |
--brightnessHover: 1.1; |
||||
--brightnessPress: 0.9; |
--brightnessPress: 0.9; |
||||
--brightnessDisabled: 0.75; |
--brightnessDisabled: 0.75; |
||||
} |
} |
||||
|
|
||||
[data-accent="red"] { |
/* Accordion Animations */ |
||||
--accent: #f28585; |
@keyframes accordion-down { |
||||
--accentMuted: #f4abab; |
from { |
||||
} |
height: 0; |
||||
|
} |
||||
[data-accent="red"][data-theme="dark"] { |
|
||||
--accent: #f25555; |
|
||||
--accentMuted: #b04749; |
|
||||
} |
|
||||
|
|
||||
[data-accent="orange"] { |
to { |
||||
--accent: #edb17a; |
height: var(--radix-accordion-content-height); |
||||
--accentMuted: #efc7a4; |
} |
||||
} |
} |
||||
|
|
||||
[data-accent="orange"][data-theme="dark"] { |
@keyframes accordion-up { |
||||
--accent: #e1720b; |
from { |
||||
--accentMuted: #a55c17; |
height: var(--radix-accordion-content-height); |
||||
} |
} |
||||
|
|
||||
[data-accent="yellow"] { |
|
||||
--accent: #e0cc87; |
|
||||
--accentMuted: #e8daad; |
|
||||
} |
|
||||
|
|
||||
[data-accent="yellow"][data-theme="dark"] { |
to { |
||||
--accent: #ac8c1a; |
height: 0; |
||||
--accentMuted: #826c22; |
} |
||||
} |
} |
||||
|
|
||||
[data-accent="green"] { |
@layer base { |
||||
--accent: #8bc9c5; |
*, |
||||
--accentMuted: #afd7d5; |
::after, |
||||
|
::before, |
||||
|
::backdrop, |
||||
|
::file-selector-button { |
||||
|
border-color: var(--color-gray-200, currentColor); |
||||
|
} |
||||
} |
} |
||||
|
|
||||
[data-accent="green"][data-theme="dark"] { |
@layer components { |
||||
--accent: #27a341; |
.maplibregl-popup-close-button { |
||||
--accentMuted: #297b3b; |
padding: 4px 10px 8px 0; |
||||
} |
font-size: 1.2rem; |
||||
|
} |
||||
[data-accent="blue"] { |
|
||||
--accent: #70afea; |
|
||||
--accentMuted: #9cc7ee; |
|
||||
} |
|
||||
|
|
||||
[data-accent="blue"][data-theme="dark"] { |
|
||||
--accent: #2093fe; |
|
||||
--accentMuted: #2471ba; |
|
||||
} |
|
||||
|
|
||||
[data-accent="purple"] { |
|
||||
--accent: #a09eef; |
|
||||
--accentMuted: #bcbcf1; |
|
||||
} |
|
||||
|
|
||||
[data-accent="purple"][data-theme="dark"] { |
|
||||
--accent: #926bff; |
|
||||
--accentMuted: #7057bb; |
|
||||
} |
|
||||
|
|
||||
[data-accent="pink"] { |
|
||||
--accent: #dba0c7; |
|
||||
--accentMuted: #e3bcd7; |
|
||||
} |
|
||||
|
|
||||
[data-accent="pink"][data-theme="dark"] { |
.maplibregl-popup-close-button:hover { |
||||
--accent: #e454c4; |
background-color: white !important; |
||||
--accentMuted: #a84892; |
} |
||||
} |
} |
||||
|
|
||||
|
/* Prevent image dragging */ |
||||
img { |
img { |
||||
-webkit-user-drag: none; |
-webkit-user-drag: none; |
||||
} |
} |
||||
|
|||||
@ -1,44 +0,0 @@ |
|||||
const { fontFamily } = require("tailwindcss/defaultTheme"); |
|
||||
|
|
||||
/** @type {import('tailwindcss').Config} */ |
|
||||
module.exports = { |
|
||||
darkMode: ["class", '[data-theme="dark"]'], |
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"], |
|
||||
theme: { |
|
||||
extend: { |
|
||||
fontFamily: { |
|
||||
mono: ["Cascadia Code", ...fontFamily.mono], |
|
||||
sans: ["Inter var", ...fontFamily.sans], |
|
||||
}, |
|
||||
keyframes: { |
|
||||
"accordion-down": { |
|
||||
from: { height: 0 }, |
|
||||
to: { height: "var(--radix-accordion-content-height)" }, |
|
||||
}, |
|
||||
"accordion-up": { |
|
||||
from: { height: "var(--radix-accordion-content-height)" }, |
|
||||
to: { height: 0 }, |
|
||||
}, |
|
||||
}, |
|
||||
animation: { |
|
||||
"accordion-down": "accordion-down 0.2s ease-out", |
|
||||
"accordion-up": "accordion-up 0.2s ease-out", |
|
||||
}, |
|
||||
colors: { |
|
||||
backgroundPrimary: "var(--backgroundPrimary)", |
|
||||
backgroundSecondary: "var(--backgroundSecondary)", |
|
||||
accent: "var(--accent)", |
|
||||
accentMuted: "var(--accentMuted)", |
|
||||
textPrimary: "var(--textPrimary)", |
|
||||
textSecondary: "var(--textSecondary)", |
|
||||
link: "var(--link)", |
|
||||
}, |
|
||||
brightness: { |
|
||||
hover: "var(--brighnessHover)", |
|
||||
press: "var(--brightnessPress)", |
|
||||
disabled: "var(--brightnessDisabled)", |
|
||||
}, |
|
||||
}, |
|
||||
}, |
|
||||
plugins: [require("tailwindcss-animate")], |
|
||||
}; |
|
||||
Loading…
Reference in new issue