feat: custom accent color via color wheel in settings

Add ColorWheel component (hue/saturation wheel + lightness strip + hex input).
themeStore gains accentIdx=-1 for custom mode and setCustom action.
ThemeApplier uses getActiveAccent to support both presets and custom hex.
Settings page shows "Свой" button; clicking reveals the color wheel inline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 20:49:29 +03:00
parent 292117cf56
commit 7f2f6f7e44
4 changed files with 261 additions and 13 deletions

View File

@@ -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<BgMode, React.ReactNode> = {
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 */}
<p className="text-[12px] text-muted mb-2.5">Акцентный цвет</p>
<div className="flex flex-wrap gap-2 mb-6">
<div className="flex flex-wrap gap-2 mb-4">
{ACCENT_PRESETS.map((preset, i) => (
<button
key={i}
@@ -212,8 +215,32 @@ export default function SettingsPage() {
{preset.name}
</button>
))}
{/* Custom color button */}
<button
onClick={() => setCustom(customHex)}
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
style={accentIdx === -1
? { background: `rgba(${activeAccent.rgb},0.12)`, color: activeAccent.accent, borderColor: `${activeAccent.accent}55` }
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span className="w-3 h-3 rounded-full shrink-0 border border-white/20"
style={{
background: `conic-gradient(red,yellow,lime,cyan,blue,magenta,red)`,
boxShadow: accentIdx === -1 ? `0 0 7px ${activeAccent.accent}90` : 'none',
}}/>
Свой
</button>
</div>
{/* Color wheel — shown when custom is selected */}
{accentIdx === -1 && (
<div className="mb-6 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<ColorWheel value={customHex} onChange={setCustom} />
</div>
)}
{accentIdx !== -1 && <div className="mb-2" />}
{/* Live background */}
<p className="text-[12px] text-muted mb-2.5">Живой фон</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">

View File

@@ -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<HTMLCanvasElement>(null)
const stripRef = useRef<HTMLCanvasElement>(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<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
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<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
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 (
<div className="flex flex-col gap-3">
<div className="flex gap-3 items-center">
{/* Wheel */}
<canvas
ref={wheelRef} width={160} height={160}
className="rounded-full cursor-crosshair shrink-0"
style={{ width: 160, height: 160 }}
onMouseDown={(e) => { 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 */}
<canvas
ref={stripRef} width={18} height={160}
className="rounded-lg cursor-ns-resize shrink-0"
style={{ width: 18, height: 160 }}
onMouseDown={(e) => { 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 */}
<div className="flex flex-col gap-2 flex-1 min-w-0">
<div className="w-full h-14 rounded-[10px] border border-white/[0.1]" style={{ background: hex }}/>
<input
type="text"
value={hex}
onChange={(e) => 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"
/>
</div>
</div>
</div>
)
}

View File

@@ -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
}

View File

@@ -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<ThemeStore>((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 }
}