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