parent
9af0ef5307
commit
b517bb975c
6 changed files with 908 additions and 23 deletions
@ -0,0 +1,222 @@ |
||||
/** |
||||
* Theme Store - Manages app theme (dark/light mode and accent colors) |
||||
* Inspired by root-v2 |
||||
*/ |
||||
import { writable, derived } from 'svelte/store'; |
||||
import { browser } from '$app/environment'; |
||||
|
||||
export type ThemeMode = 'dark' | 'light' | 'system'; |
||||
|
||||
export interface ThemeColors { |
||||
primary: string; |
||||
name: string; |
||||
} |
||||
|
||||
export const PRESET_COLORS: ThemeColors[] = [ |
||||
{ name: 'Cyan', primary: '#00A3E0' }, |
||||
{ name: 'Purple', primary: '#8B5CF6' }, |
||||
{ name: 'Pink', primary: '#EC4899' }, |
||||
{ name: 'Green', primary: '#10B981' }, |
||||
{ name: 'Orange', primary: '#F97316' }, |
||||
{ name: 'Red', primary: '#EF4444' }, |
||||
{ name: 'Blue', primary: '#3B82F6' }, |
||||
{ name: 'Indigo', primary: '#6366F1' }, |
||||
]; |
||||
|
||||
const THEME_STORAGE_KEY = 'root_theme'; |
||||
|
||||
interface ThemeState { |
||||
mode: ThemeMode; |
||||
primaryColor: string; |
||||
} |
||||
|
||||
const defaultTheme: ThemeState = { |
||||
mode: 'dark', |
||||
primaryColor: '#00A3E0', |
||||
}; |
||||
|
||||
// Convert hex to HSL
|
||||
function hexToHSL(hex: string): { h: number; s: number; l: number } { |
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); |
||||
if (!result) return { h: 0, s: 0, l: 0 }; |
||||
|
||||
const r = parseInt(result[1], 16) / 255; |
||||
const g = parseInt(result[2], 16) / 255; |
||||
const b = parseInt(result[3], 16) / 255; |
||||
|
||||
const max = Math.max(r, g, b); |
||||
const min = Math.min(r, g, b); |
||||
let h = 0; |
||||
let s = 0; |
||||
const l = (max + min) / 2; |
||||
|
||||
if (max !== min) { |
||||
const d = max - min; |
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); |
||||
switch (max) { |
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; |
||||
case g: h = ((b - r) / d + 2) / 6; break; |
||||
case b: h = ((r - g) / d + 4) / 6; break; |
||||
} |
||||
} |
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }; |
||||
} |
||||
|
||||
// Convert HSL to hex
|
||||
function hslToHex(h: number, s: number, l: number): string { |
||||
s /= 100; |
||||
l /= 100; |
||||
const a = s * Math.min(l, 1 - l); |
||||
const f = (n: number) => { |
||||
const k = (n + h / 30) % 12; |
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); |
||||
return Math.round(255 * color).toString(16).padStart(2, '0'); |
||||
}; |
||||
return `#${f(0)}${f(8)}${f(4)}`; |
||||
} |
||||
|
||||
// Generate derived colors from primary
|
||||
function generateDerivedColors(primary: string, isDark: boolean) { |
||||
const { h, s } = hexToHSL(primary); |
||||
|
||||
if (isDark) { |
||||
return { |
||||
night: hslToHex(h, Math.min(s, 40), 6), |
||||
dark: hslToHex(h, Math.min(s, 35), 10), |
||||
surface: hslToHex(h, Math.min(s, 30), 12), |
||||
background: hslToHex(h, Math.min(s, 30), 3), |
||||
light: '#e5e6f0', |
||||
text: '#ffffff', |
||||
}; |
||||
} else { |
||||
const lightSat = Math.min(s, 30); |
||||
return { |
||||
night: hslToHex(h, lightSat, 95), |
||||
dark: hslToHex(h, lightSat, 90), |
||||
surface: hslToHex(h, lightSat, 98), |
||||
background: hslToHex(h, lightSat, 100), |
||||
light: '#1a1a2e', |
||||
text: '#0a121f', |
||||
}; |
||||
} |
||||
} |
||||
|
||||
function getEffectiveMode(mode: ThemeMode): 'dark' | 'light' { |
||||
if (mode === 'system') { |
||||
if (!browser) return 'dark'; |
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; |
||||
} |
||||
return mode; |
||||
} |
||||
|
||||
function loadTheme(): ThemeState { |
||||
if (!browser) return defaultTheme; |
||||
|
||||
try { |
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY); |
||||
if (stored) { |
||||
return { ...defaultTheme, ...JSON.parse(stored) }; |
||||
} |
||||
} catch (e) { |
||||
console.warn('Failed to load theme:', e); |
||||
} |
||||
return defaultTheme; |
||||
} |
||||
|
||||
function saveTheme(theme: ThemeState): void { |
||||
if (!browser) return; |
||||
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(theme)); |
||||
} |
||||
|
||||
export function applyTheme(state: ThemeState): void { |
||||
if (!browser) return; |
||||
|
||||
const root = document.documentElement; |
||||
const effectiveMode = getEffectiveMode(state.mode); |
||||
|
||||
// Set mode class
|
||||
root.classList.remove('dark', 'light'); |
||||
root.classList.add(effectiveMode); |
||||
|
||||
// Set CSS custom properties
|
||||
root.style.setProperty('--color-primary', state.primaryColor); |
||||
|
||||
// Calculate hover variant
|
||||
const { h, s, l } = hexToHSL(state.primaryColor); |
||||
root.style.setProperty('--color-primary-hover', hslToHex(h, s, Math.min(100, l + 10))); |
||||
|
||||
// Generate and apply derived colors
|
||||
const derived = generateDerivedColors(state.primaryColor, effectiveMode === 'dark'); |
||||
root.style.setProperty('--color-night', derived.night); |
||||
root.style.setProperty('--color-dark', derived.dark); |
||||
root.style.setProperty('--color-surface', derived.surface); |
||||
root.style.setProperty('--color-background', derived.background); |
||||
root.style.setProperty('--color-light', derived.light); |
||||
root.style.setProperty('--color-text', derived.text); |
||||
} |
||||
|
||||
function createThemeStore() { |
||||
const { subscribe, set, update } = writable<ThemeState>(loadTheme()); |
||||
|
||||
return { |
||||
subscribe, |
||||
setMode: (mode: ThemeMode) => { |
||||
update(state => { |
||||
const newState = { ...state, mode }; |
||||
saveTheme(newState); |
||||
applyTheme(newState); |
||||
return newState; |
||||
}); |
||||
}, |
||||
setPrimaryColor: (color: string) => { |
||||
update(state => { |
||||
const newState = { ...state, primaryColor: color }; |
||||
saveTheme(newState); |
||||
applyTheme(newState); |
||||
return newState; |
||||
}); |
||||
}, |
||||
toggleMode: () => { |
||||
update(state => { |
||||
const modes: ThemeMode[] = ['dark', 'light', 'system']; |
||||
const currentIndex = modes.indexOf(state.mode); |
||||
const newMode = modes[(currentIndex + 1) % modes.length]; |
||||
const newState: ThemeState = { ...state, mode: newMode }; |
||||
saveTheme(newState); |
||||
applyTheme(newState); |
||||
return newState; |
||||
}); |
||||
}, |
||||
reset: () => { |
||||
set(defaultTheme); |
||||
saveTheme(defaultTheme); |
||||
applyTheme(defaultTheme); |
||||
}, |
||||
init: () => { |
||||
const state = loadTheme(); |
||||
applyTheme(state); |
||||
set(state); |
||||
} |
||||
}; |
||||
} |
||||
|
||||
export const theme = createThemeStore(); |
||||
|
||||
// Derived stores for convenience
|
||||
export const isDarkMode = derived(theme, $t => getEffectiveMode($t.mode) === 'dark'); |
||||
export const primaryColor = derived(theme, $t => $t.primaryColor); |
||||
export const themeMode = derived(theme, $t => $t.mode); |
||||
|
||||
// Initialize theme on load
|
||||
if (browser) { |
||||
applyTheme(loadTheme()); |
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { |
||||
const state = loadTheme(); |
||||
if (state.mode === 'system') { |
||||
applyTheme(state); |
||||
} |
||||
}); |
||||
} |
||||
@ -0,0 +1,53 @@ |
||||
-- Task comments for kanban cards |
||||
|
||||
CREATE TABLE IF NOT EXISTS kanban_comments ( |
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), |
||||
card_id UUID NOT NULL REFERENCES kanban_cards(id) ON DELETE CASCADE, |
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, |
||||
content TEXT NOT NULL, |
||||
created_at TIMESTAMPTZ DEFAULT now(), |
||||
updated_at TIMESTAMPTZ DEFAULT now() |
||||
); |
||||
|
||||
-- Enable RLS |
||||
ALTER TABLE kanban_comments ENABLE ROW LEVEL SECURITY; |
||||
|
||||
-- Comments inherit access from the card's board -> org |
||||
CREATE POLICY "Comments inherit card access" ON kanban_comments |
||||
FOR SELECT USING ( |
||||
EXISTS ( |
||||
SELECT 1 FROM kanban_cards c |
||||
JOIN kanban_columns col ON c.column_id = col.id |
||||
JOIN kanban_boards b ON col.board_id = b.id |
||||
JOIN org_members m ON b.org_id = m.org_id |
||||
WHERE c.id = kanban_comments.card_id |
||||
AND m.user_id = auth.uid() |
||||
) |
||||
); |
||||
|
||||
-- Users can insert their own comments |
||||
CREATE POLICY "Users can insert own comments" ON kanban_comments |
||||
FOR INSERT WITH CHECK ( |
||||
user_id = auth.uid() AND |
||||
EXISTS ( |
||||
SELECT 1 FROM kanban_cards c |
||||
JOIN kanban_columns col ON c.column_id = col.id |
||||
JOIN kanban_boards b ON col.board_id = b.id |
||||
JOIN org_members m ON b.org_id = m.org_id |
||||
WHERE c.id = kanban_comments.card_id |
||||
AND m.user_id = auth.uid() |
||||
) |
||||
); |
||||
|
||||
-- Users can update their own comments |
||||
CREATE POLICY "Users can update own comments" ON kanban_comments |
||||
FOR UPDATE USING (user_id = auth.uid()); |
||||
|
||||
-- Users can delete their own comments |
||||
CREATE POLICY "Users can delete own comments" ON kanban_comments |
||||
FOR DELETE USING (user_id = auth.uid()); |
||||
|
||||
-- Index for faster lookups |
||||
CREATE INDEX IF NOT EXISTS idx_kanban_comments_card ON kanban_comments(card_id); |
||||
CREATE INDEX IF NOT EXISTS idx_kanban_comments_user ON kanban_comments(user_id); |
||||
CREATE INDEX IF NOT EXISTS idx_kanban_comments_created ON kanban_comments(created_at DESC); |
||||
Loading…
Reference in new issue