v0.9.20 created new colour picker on mobile

This commit is contained in:
2026-03-14 17:24:05 -04:00
parent 5086d86340
commit 878299d661
5 changed files with 219 additions and 44 deletions

View File

@@ -10,7 +10,7 @@
PROJECT_NAME=jama
# Image version to run (set by build.sh, or use 'latest')
JAMA_VERSION=0.9.18
JAMA_VERSION=0.9.20
# App port — the host port Docker maps to the container
PORT=3000

View File

@@ -1,6 +1,6 @@
{
"name": "jama-backend",
"version": "0.9.18",
"version": "0.9.20",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.9.18}"
VERSION="${1:-0.9.20}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"

View File

@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
"version": "0.9.18",
"version": "0.9.20",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
@@ -10,18 +10,216 @@ const COLOUR_SUGGESTIONS = [
'#1a73e8', '#a142f4', '#e53935', '#fa7b17', '#fdd835', '#34a853',
];
// A single colour picker card: shows suggestions by default, Custom button opens native picker
// ── Colour math helpers ──────────────────────────────────────────────────────
function hexToHsv(hex) {
const r = parseInt(hex.slice(1,3),16)/255;
const g = parseInt(hex.slice(3,5),16)/255;
const b = parseInt(hex.slice(5,7),16)/255;
const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max - min;
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
}
return { h: h * 360, s: max === 0 ? 0 : d / max, v: max };
}
function hsvToHex(h, s, v) {
h = h / 360;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
let r, g, b;
switch (i % 6) {
case 0: r=v; g=t; b=p; break; case 1: r=q; g=v; b=p; break;
case 2: r=p; g=v; b=t; break; case 3: r=p; g=q; b=v; break;
case 4: r=t; g=p; b=v; break; default: r=v; g=p; b=q;
}
return '#' + [r,g,b].map(x => Math.round(x*255).toString(16).padStart(2,'0')).join('');
}
function isValidHex(h) { return /^#[0-9a-fA-F]{6}$/.test(h); }
// ── SV (saturation/value) square ─────────────────────────────────────────────
function SvSquare({ hue, s, v, onChange }) {
const canvasRef = useRef(null);
const dragging = useRef(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// White → hue gradient (left→right)
const hGrad = ctx.createLinearGradient(0, 0, W, 0);
hGrad.addColorStop(0, '#fff');
hGrad.addColorStop(1, `hsl(${hue},100%,50%)`);
ctx.fillStyle = hGrad; ctx.fillRect(0, 0, W, H);
// Transparent → black gradient (top→bottom)
const vGrad = ctx.createLinearGradient(0, 0, 0, H);
vGrad.addColorStop(0, 'transparent');
vGrad.addColorStop(1, '#000');
ctx.fillStyle = vGrad; ctx.fillRect(0, 0, W, H);
}, [hue]);
const getPos = (e, canvas) => {
const r = canvas.getBoundingClientRect();
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top;
return {
s: Math.max(0, Math.min(1, cx / r.width)),
v: Math.max(0, Math.min(1, 1 - cy / r.height)),
};
};
const handle = (e) => {
e.preventDefault();
const p = getPos(e, canvasRef.current);
onChange(p.s, p.v);
};
return (
<div style={{ position: 'relative', userSelect: 'none', touchAction: 'none' }}>
<canvas
ref={canvasRef} width={260} height={160}
style={{ display: 'block', width: '100%', height: 160, borderRadius: 8, cursor: 'crosshair', border: '1px solid var(--border)' }}
onMouseDown={e => { dragging.current = true; handle(e); }}
onMouseMove={e => { if (dragging.current) handle(e); }}
onMouseUp={() => { dragging.current = false; }}
onMouseLeave={() => { dragging.current = false; }}
onTouchStart={handle} onTouchMove={handle}
/>
{/* Cursor circle */}
<div style={{
position: 'absolute',
left: `calc(${s * 100}% - 7px)`,
top: `calc(${(1 - v) * 100}% - 7px)`,
width: 14, height: 14, borderRadius: '50%',
border: '2px solid white',
boxShadow: '0 0 0 1.5px rgba(0,0,0,0.4)',
pointerEvents: 'none',
background: 'transparent',
}} />
</div>
);
}
// ── Hue bar ───────────────────────────────────────────────────────────────────
function HueBar({ hue, onChange }) {
const barRef = useRef(null);
const dragging = useRef(false);
const handle = (e) => {
e.preventDefault();
const r = barRef.current.getBoundingClientRect();
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
onChange(Math.max(0, Math.min(360, (cx / r.width) * 360)));
};
return (
<div style={{ position: 'relative', userSelect: 'none', touchAction: 'none', marginTop: 10 }}>
<div
ref={barRef}
style={{
height: 20, borderRadius: 10,
background: 'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)',
border: '1px solid var(--border)', cursor: 'pointer',
}}
onMouseDown={e => { dragging.current = true; handle(e); }}
onMouseMove={e => { if (dragging.current) handle(e); }}
onMouseUp={() => { dragging.current = false; }}
onMouseLeave={() => { dragging.current = false; }}
onTouchStart={handle} onTouchMove={handle}
/>
<div style={{
position: 'absolute',
left: `calc(${(hue / 360) * 100}% - 9px)`,
top: -2, width: 18, height: 24, borderRadius: 4,
background: `hsl(${hue},100%,50%)`,
border: '2px solid white',
boxShadow: '0 0 0 1.5px rgba(0,0,0,0.3)',
pointerEvents: 'none',
}} />
</div>
);
}
// ── Custom HSV picker ─────────────────────────────────────────────────────────
function CustomPicker({ initial, onSet, onBack }) {
const { h: ih, s: is, v: iv } = hexToHsv(initial);
const [hue, setHue] = useState(ih);
const [sat, setSat] = useState(is);
const [val, setVal] = useState(iv);
const [hexInput, setHexInput] = useState(initial);
const [hexError, setHexError] = useState(false);
const current = hsvToHex(hue, sat, val);
// Sync hex input when sliders change
useEffect(() => { setHexInput(current); setHexError(false); }, [current]);
const handleHexInput = (e) => {
const v = e.target.value;
setHexInput(v);
if (isValidHex(v)) {
const { h, s, v: bv } = hexToHsv(v);
setHue(h); setSat(s); setVal(bv);
setHexError(false);
} else {
setHexError(true);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<SvSquare hue={hue} s={sat} v={val} onChange={(s, v) => { setSat(s); setVal(v); }} />
<HueBar hue={hue} onChange={setHue} />
{/* Preview + hex input */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
<div style={{
width: 40, height: 40, borderRadius: 8, background: current,
border: '2px solid var(--border)', flexShrink: 0,
boxShadow: '0 1px 4px rgba(0,0,0,0.15)',
}} />
<input
value={hexInput}
onChange={handleHexInput}
maxLength={7}
style={{
fontFamily: 'monospace', fontSize: 14,
padding: '6px 10px', borderRadius: 8,
border: `1px solid ${hexError ? '#e53935' : 'var(--border)'}`,
width: 110, background: 'var(--surface)',
color: 'var(--text-primary)',
}}
placeholder="#000000"
/>
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Chosen colour</span>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, marginTop: 2 }}>
<button className="btn btn-primary btn-sm" onClick={() => onSet(current)} disabled={hexError}>
Set
</button>
<button className="btn btn-secondary btn-sm" onClick={onBack}>
Back
</button>
</div>
</div>
);
}
// ── ColourPicker card ─────────────────────────────────────────────────────────
function ColourPicker({ label, value, onChange, preview }) {
const [mode, setMode] = useState('suggestions'); // 'suggestions' | 'custom'
const [draft, setDraft] = useState(value);
// Keep draft in sync if parent value changes (e.g. reset)
useEffect(() => { setDraft(value); }, [value]);
const handleSet = () => {
onChange(draft);
setMode('suggestions');
};
return (
<div>
@@ -54,41 +252,18 @@ function ColourPicker({ label, value, onChange, preview }) {
/>
))}
</div>
<button className="btn btn-secondary btn-sm" onClick={() => { setDraft(value); setMode('custom'); }}>
<button className="btn btn-secondary btn-sm" onClick={() => setMode('custom')}>
Custom
</button>
</>
)}
{mode === 'custom' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Large label-wrapped swatch triggers the colour input — reliable on mobile and desktop */}
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
<span style={{
display: 'inline-block', width: 56, height: 44,
borderRadius: 8, background: draft,
border: '2px solid var(--border)',
boxShadow: '0 1px 4px rgba(0,0,0,0.15)',
flexShrink: 0,
}} />
<input
type="color"
value={draft}
onInput={e => setDraft(e.target.value)}
onChange={e => setDraft(e.target.value)}
style={{ position: 'absolute', opacity: 0, width: 0, height: 0, pointerEvents: 'none' }}
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{draft}</span>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={handleSet}>
Set
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setMode('suggestions')}>
Back
</button>
</div>
</div>
<CustomPicker
initial={value}
onSet={(hex) => { onChange(hex); setMode('suggestions'); }}
onBack={() => setMode('suggestions')}
/>
)}
</div>
);