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:
@@ -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">
|
||||
|
||||
193
apps/web/src/components/ColorWheel.tsx
Normal file
193
apps/web/src/components/ColorWheel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user