diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index 5f73034..56c0ba9 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -5,7 +5,9 @@ import { useEffect } from 'react' import { useAuthStore } from '@/store/authStore' import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore' import { useBgStore, BG_PRESETS, type BgMode } from '@/store/bgStore' +import { getActiveAccent } from '@/store/themeStore' import Header from '@/components/Header' +import ColorWheel from '@/components/ColorWheel' // ── Preview SVGs ───────────────────────────────────────────────────────────── @@ -172,9 +174,10 @@ const BG_PREVIEWS: Record = { export default function SettingsPage() { const { user } = useAuthStore() - const { accentIdx, setAccent } = useThemeStore() + const { accentIdx, customHex, setAccent, setCustom } = useThemeStore() const { bgMode, setBg } = useBgStore() const router = useRouter() + const activeAccent = getActiveAccent(accentIdx, customHex) useEffect(() => { if (!user) router.replace('/login') @@ -196,7 +199,7 @@ export default function SettingsPage() { {/* Accent color */}

Акцентный цвет

-
+
{ACCENT_PRESETS.map((preset, i) => (
+ {/* Color wheel — shown when custom is selected */} + {accentIdx === -1 && ( +
+ +
+ )} + {accentIdx !== -1 &&
} + {/* Live background */}

Живой фон

diff --git a/apps/web/src/components/ColorWheel.tsx b/apps/web/src/components/ColorWheel.tsx new file mode 100644 index 0000000..7e1b4ff --- /dev/null +++ b/apps/web/src/components/ColorWheel.tsx @@ -0,0 +1,193 @@ +'use client' + +import { useRef, useEffect, useCallback, useState } from 'react' + +interface Props { + value: string + onChange: (hex: string) => void +} + +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 c = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) + return Math.round(255 * c).toString(16).padStart(2, '0') + } + return `#${f(0)}${f(8)}${f(4)}` +} + +function hexToHsl(hex: string): [number, number, number] { + const h = hex.replace('#', '') + const r = parseInt(h.slice(0, 2), 16) / 255 + const g = parseInt(h.slice(2, 4), 16) / 255 + const b = parseInt(h.slice(4, 6), 16) / 255 + const max = Math.max(r, g, b), min = Math.min(r, g, b) + let hue = 0, sat = 0 + const l = (max + min) / 2 + if (max !== min) { + const d = max - min + sat = l > 0.5 ? d / (2 - max - min) : d / (max + min) + switch (max) { + case r: hue = ((g - b) / d + (g < b ? 6 : 0)) / 6; break + case g: hue = ((b - r) / d + 2) / 6; break + case b: hue = ((r - g) / d + 4) / 6; break + } + } + return [hue * 360, sat * 100, l * 100] +} + +export default function ColorWheel({ value, onChange }: Props) { + const wheelRef = useRef(null) + const stripRef = useRef(null) + const [hex, setHex] = useState(value) + const [hsl, setHsl] = useState<[number, number, number]>(() => hexToHsl(value)) + const draggingWheel = useRef(false) + const draggingStrip = useRef(false) + + // Sync when parent value changes + useEffect(() => { + setHex(value) + setHsl(hexToHsl(value)) + }, [value]) + + // Draw color wheel + useEffect(() => { + const canvas = wheelRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + const SIZE = canvas.width + const cx = SIZE / 2, cy = SIZE / 2, r = SIZE / 2 - 2 + + ctx.clearRect(0, 0, SIZE, SIZE) + + // Hue + saturation wheel + for (let angle = 0; angle < 360; angle += 1) { + const startAngle = ((angle - 1) * Math.PI) / 180 + const endAngle = ((angle + 1) * Math.PI) / 180 + const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r) + gradient.addColorStop(0, `hsl(${angle},0%,${hsl[2]}%)`) + gradient.addColorStop(1, `hsl(${angle},100%,${hsl[2]}%)`) + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.arc(cx, cy, r, startAngle, endAngle) + ctx.closePath() + ctx.fillStyle = gradient + ctx.fill() + } + + // Cursor + const hRad = (hsl[0] * Math.PI) / 180 + const dist = (hsl[1] / 100) * r + const px = cx + Math.cos(hRad) * dist + const py = cy + Math.sin(hRad) * dist + ctx.beginPath(); ctx.arc(px, py, 7, 0, Math.PI * 2) + ctx.strokeStyle = '#fff'; ctx.lineWidth = 2.5; ctx.stroke() + ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2) + ctx.fillStyle = hslToHex(hsl[0], hsl[1], hsl[2]); ctx.fill() + }, [hsl]) + + // Draw lightness strip + useEffect(() => { + const canvas = stripRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + const W = canvas.width, H = canvas.height + const grad = ctx.createLinearGradient(0, 0, 0, H) + grad.addColorStop(0, `hsl(${hsl[0]},${hsl[1]}%,100%)`) + grad.addColorStop(0.5, `hsl(${hsl[0]},${hsl[1]}%,50%)`) + grad.addColorStop(1, `hsl(${hsl[0]},${hsl[1]}%,0%)`) + ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H) + + // Cursor + const y = ((1 - hsl[2] / 100) * H) + ctx.beginPath(); ctx.rect(0, y - 3, W, 6) + ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke() + ctx.fillStyle = `hsl(${hsl[0]},${hsl[1]}%,${hsl[2]}%)`; ctx.fillRect(1, y - 2, W - 2, 4) + }, [hsl]) + + const pickWheel = useCallback((e: React.MouseEvent | React.TouchEvent) => { + const canvas = wheelRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2 + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY + const dx = clientX - cx, dy = clientY - cy + const r = rect.width / 2 - 2 + const dist = Math.min(Math.sqrt(dx * dx + dy * dy), r) + const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360 + const newHsl: [number, number, number] = [angle, (dist / r) * 100, hsl[2]] + const newHex = hslToHex(...newHsl) + setHsl(newHsl); setHex(newHex); onChange(newHex) + }, [hsl, onChange]) + + const pickStrip = useCallback((e: React.MouseEvent | React.TouchEvent) => { + const canvas = stripRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY + const t = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)) + const l = (1 - t) * 100 + const newHsl: [number, number, number] = [hsl[0], hsl[1], l] + const newHex = hslToHex(...newHsl) + setHsl(newHsl); setHex(newHex); onChange(newHex) + }, [hsl, onChange]) + + const onHexInput = (v: string) => { + setHex(v) + if (/^#[0-9a-fA-F]{6}$/.test(v)) { + const h = hexToHsl(v) + setHsl(h); onChange(v) + } + } + + return ( +
+
+ {/* Wheel */} + { draggingWheel.current = true; pickWheel(e) }} + onMouseMove={(e) => { if (draggingWheel.current) pickWheel(e) }} + onMouseUp={() => { draggingWheel.current = false }} + onMouseLeave={() => { draggingWheel.current = false }} + onTouchStart={(e) => { draggingWheel.current = true; pickWheel(e) }} + onTouchMove={(e) => { if (draggingWheel.current) pickWheel(e) }} + onTouchEnd={() => { draggingWheel.current = false }} + /> + {/* Lightness strip */} + { draggingStrip.current = true; pickStrip(e) }} + onMouseMove={(e) => { if (draggingStrip.current) pickStrip(e) }} + onMouseUp={() => { draggingStrip.current = false }} + onMouseLeave={() => { draggingStrip.current = false }} + onTouchStart={(e) => { draggingStrip.current = true; pickStrip(e) }} + onTouchMove={(e) => { if (draggingStrip.current) pickStrip(e) }} + onTouchEnd={() => { draggingStrip.current = false }} + /> + {/* Preview + HEX input */} +
+
+ onHexInput(e.target.value)} + maxLength={7} + spellCheck={false} + className="w-full font-mono text-[13px] bg-surface2 border border-white/[0.07] rounded-[8px] px-3 py-2 text-app-text outline-none focus:border-white/20 uppercase tracking-widest" + placeholder="#000000" + /> +
+
+
+ ) +} diff --git a/apps/web/src/components/ThemeApplier.tsx b/apps/web/src/components/ThemeApplier.tsx index be58d1d..e47445b 100644 --- a/apps/web/src/components/ThemeApplier.tsx +++ b/apps/web/src/components/ThemeApplier.tsx @@ -1,16 +1,16 @@ 'use client' import { useEffect } from 'react' -import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore' +import { useThemeStore, getActiveAccent } from '@/store/themeStore' export default function ThemeApplier() { - const { accentIdx } = useThemeStore() + const { accentIdx, customHex } = useThemeStore() useEffect(() => { - const preset = ACCENT_PRESETS[accentIdx] ?? ACCENT_PRESETS[0] - document.documentElement.style.setProperty('--accent', preset.accent) - document.documentElement.style.setProperty('--accent-rgb', preset.rgb) - }, [accentIdx]) + const { accent, rgb } = getActiveAccent(accentIdx, customHex) + document.documentElement.style.setProperty('--accent', accent) + document.documentElement.style.setProperty('--accent-rgb', rgb) + }, [accentIdx, customHex]) return null } diff --git a/apps/web/src/store/themeStore.ts b/apps/web/src/store/themeStore.ts index 82bd776..fb6dfc0 100644 --- a/apps/web/src/store/themeStore.ts +++ b/apps/web/src/store/themeStore.ts @@ -17,22 +17,50 @@ export const ACCENT_PRESETS: AccentPreset[] = [ { name: 'Минт', accent: '#00FFB2', rgb: '0,255,178' }, ] -const STORAGE_KEY = 'pm_accent' +function hexToRgbStr(hex: string): string { + const h = hex.replace('#', '') + const r = parseInt(h.substring(0, 2), 16) + const g = parseInt(h.substring(2, 4), 16) + const b = parseInt(h.substring(4, 6), 16) + return `${r},${g},${b}` +} + +const KEY_IDX = 'pm_accent' +const KEY_CUSTOM = 'pm_accent_custom' interface ThemeStore { - accentIdx: number + accentIdx: number // -1 = custom color + customHex: string setAccent: (idx: number) => void + setCustom: (hex: string) => void } export const useThemeStore = create((set) => ({ accentIdx: (() => { if (typeof window === 'undefined') return 0 - const saved = localStorage.getItem(STORAGE_KEY) + const saved = localStorage.getItem(KEY_IDX) const idx = saved !== null ? parseInt(saved, 10) : 0 - return idx >= 0 && idx < ACCENT_PRESETS.length ? idx : 0 + return idx >= -1 && idx < ACCENT_PRESETS.length ? idx : 0 + })(), + customHex: (() => { + if (typeof window === 'undefined') return '#ff00ff' + return localStorage.getItem(KEY_CUSTOM) || '#ff00ff' })(), setAccent: (idx) => { - if (typeof window !== 'undefined') localStorage.setItem(STORAGE_KEY, String(idx)) + if (typeof window !== 'undefined') localStorage.setItem(KEY_IDX, String(idx)) set({ accentIdx: idx }) }, + setCustom: (hex) => { + if (typeof window !== 'undefined') { + localStorage.setItem(KEY_CUSTOM, hex) + localStorage.setItem(KEY_IDX, '-1') + } + set({ accentIdx: -1, customHex: hex }) + }, })) + +export function getActiveAccent(accentIdx: number, customHex: string): { accent: string; rgb: string } { + if (accentIdx === -1) return { accent: customHex, rgb: hexToRgbStr(customHex) } + const preset = ACCENT_PRESETS[accentIdx] ?? ACCENT_PRESETS[0] + return { accent: preset.accent, rgb: preset.rgb } +}