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 { useAuthStore } from '@/store/authStore'
|
||||||
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
|
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
|
||||||
import { useBgStore, BG_PRESETS, type BgMode } from '@/store/bgStore'
|
import { useBgStore, BG_PRESETS, type BgMode } from '@/store/bgStore'
|
||||||
|
import { getActiveAccent } from '@/store/themeStore'
|
||||||
import Header from '@/components/Header'
|
import Header from '@/components/Header'
|
||||||
|
import ColorWheel from '@/components/ColorWheel'
|
||||||
|
|
||||||
// ── Preview SVGs ─────────────────────────────────────────────────────────────
|
// ── Preview SVGs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -172,9 +174,10 @@ const BG_PREVIEWS: Record<BgMode, React.ReactNode> = {
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const { accentIdx, setAccent } = useThemeStore()
|
const { accentIdx, customHex, setAccent, setCustom } = useThemeStore()
|
||||||
const { bgMode, setBg } = useBgStore()
|
const { bgMode, setBg } = useBgStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const activeAccent = getActiveAccent(accentIdx, customHex)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) router.replace('/login')
|
if (!user) router.replace('/login')
|
||||||
@@ -196,7 +199,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* Accent color */}
|
{/* Accent color */}
|
||||||
<p className="text-[12px] text-muted mb-2.5">Акцентный цвет</p>
|
<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) => (
|
{ACCENT_PRESETS.map((preset, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
@@ -212,8 +215,32 @@ export default function SettingsPage() {
|
|||||||
{preset.name}
|
{preset.name}
|
||||||
</button>
|
</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>
|
</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 */}
|
{/* Live background */}
|
||||||
<p className="text-[12px] text-muted mb-2.5">Живой фон</p>
|
<p className="text-[12px] text-muted mb-2.5">Живой фон</p>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
<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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
|
import { useThemeStore, getActiveAccent } from '@/store/themeStore'
|
||||||
|
|
||||||
export default function ThemeApplier() {
|
export default function ThemeApplier() {
|
||||||
const { accentIdx } = useThemeStore()
|
const { accentIdx, customHex } = useThemeStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preset = ACCENT_PRESETS[accentIdx] ?? ACCENT_PRESETS[0]
|
const { accent, rgb } = getActiveAccent(accentIdx, customHex)
|
||||||
document.documentElement.style.setProperty('--accent', preset.accent)
|
document.documentElement.style.setProperty('--accent', accent)
|
||||||
document.documentElement.style.setProperty('--accent-rgb', preset.rgb)
|
document.documentElement.style.setProperty('--accent-rgb', rgb)
|
||||||
}, [accentIdx])
|
}, [accentIdx, customHex])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,22 +17,50 @@ export const ACCENT_PRESETS: AccentPreset[] = [
|
|||||||
{ name: 'Минт', accent: '#00FFB2', rgb: '0,255,178' },
|
{ 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 {
|
interface ThemeStore {
|
||||||
accentIdx: number
|
accentIdx: number // -1 = custom color
|
||||||
|
customHex: string
|
||||||
setAccent: (idx: number) => void
|
setAccent: (idx: number) => void
|
||||||
|
setCustom: (hex: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useThemeStore = create<ThemeStore>((set) => ({
|
export const useThemeStore = create<ThemeStore>((set) => ({
|
||||||
accentIdx: (() => {
|
accentIdx: (() => {
|
||||||
if (typeof window === 'undefined') return 0
|
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
|
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) => {
|
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 })
|
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