diff --git a/.env.example b/.env.example index d45a041..eae4d3b 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ PROJECT_NAME=jama # Image version to run (set by build.sh, or use 'latest') -JAMA_VERSION=0.9.78 +JAMA_VERSION=0.9.79 # App port — the host port Docker maps to the container PORT=3000 diff --git a/backend/package.json b/backend/package.json index aa49ec6..08aec10 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.9.78", + "version": "0.9.79", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index 0fa4b24..b1211a8 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.9.78}" +VERSION="${1:-0.9.79}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 87a532a..054cad7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.9.78", + "version": "0.9.79", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/ColourPickerSheet.jsx b/frontend/src/components/ColourPickerSheet.jsx new file mode 100644 index 0000000..93f2d77 --- /dev/null +++ b/frontend/src/components/ColourPickerSheet.jsx @@ -0,0 +1,163 @@ +// Shared mobile-friendly colour picker — used by EventTypesPanel and MobileEventForm +// Renders inline (no sheet wrapper) so callers can embed it wherever they like. +import { useState, useEffect, useRef } from 'react'; + +const COLOUR_SUGGESTIONS = [ + '#1a73e8','#a142f4','#e53935','#fa7b17','#34a853','#00bcd4', + '#ff5722','#795548','#607d8b','#e91e63','#9c27b0','#3f51b5', +]; + +function hexToHsv(hex) { + const r=parseInt(hex.slice(1,3),16)/255, g=parseInt(hex.slice(3,5),16)/255, 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+(gMath.round(x*255).toString(16).padStart(2,'0')).join(''); +} +function isValidHex(h){return/^#[0-9a-fA-F]{6}$/.test(h);} + +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'),W=canvas.width,H=canvas.height; + 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); + 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( +
+ {dragging.current=true;handle(e);}} onMouseMove={e=>{if(dragging.current)handle(e);}} + onMouseUp={()=>{dragging.current=false;}} onMouseLeave={()=>{dragging.current=false;}} + onTouchStart={handle} onTouchMove={handle}/> +
+
+ ); +} + +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( +
+
{dragging.current=true;handle(e);}} onMouseMove={e=>{if(dragging.current)handle(e);}} + onMouseUp={()=>{dragging.current=false;}} onMouseLeave={()=>{dragging.current=false;}} + onTouchStart={handle} onTouchMove={handle}/> +
+
+ ); +} + +// Full inline picker — no sheet wrapper, callers handle the container +export function ColourPicker({ value, onChange }) { + const {h:ih,s:is,v:iv}=hexToHsv(value||'#6366f1'); + const [mode,setMode]=useState('suggestions'); // 'suggestions' | 'custom' + const [hue,setHue]=useState(ih); + const [sat,setSat]=useState(is); + const [val,setVal]=useState(iv); + const [hexInput,setHexInput]=useState(value||'#6366f1'); + const [hexError,setHexError]=useState(false); + const current=hsvToHex(hue,sat,val); + + // Sync from value prop when it changes externally + useEffect(()=>{ + if(value&&isValidHex(value)){ + const{h,s,v}=hexToHsv(value); + setHue(h);setSat(s);setVal(v);setHexInput(value); + } + },[value]); + + 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); + }; + + if(mode==='suggestions') return( +
+ {/* Current preview */} +
+
+ {value} +
+ {/* Swatches */} +
+ {COLOUR_SUGGESTIONS.map(hex=>( +
+ +
+ ); + + return( +
+ {setSat(s);setVal(v);}}/> + +
+
+ +
+
+ + +
+
+ ); +} + +// Bottom-sheet wrapper for mobile — position:fixed, slides up from bottom +export default function ColourPickerSheet({ value, onChange, onClose, title='Pick a colour' }) { + return ( +
e.target===e.currentTarget&&onClose()}> +
+
+ {title} + +
+ {onChange(v);}}/> + +
+
+ ); +} diff --git a/frontend/src/components/MobileEventForm.jsx b/frontend/src/components/MobileEventForm.jsx index 726d2dd..f37a771 100644 --- a/frontend/src/components/MobileEventForm.jsx +++ b/frontend/src/components/MobileEventForm.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { api } from '../utils/api.js'; +import ColourPickerSheet from './ColourPickerSheet.jsx'; import { useToast } from '../contexts/ToastContext.jsx'; // ── Utilities ───────────────────────────────────────────────────────────────── @@ -193,6 +194,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte const [showAddType, setShowAddType] = useState(false); const [newTypeName, setNewTypeName] = useState(''); const [newTypeColour, setNewTypeColour] = useState('#6366f1'); + const [showTypeColourPicker, setShowTypeColourPicker] = useState(false); const [savingType, setSavingType] = useState(false); const [sd, setSd] = useState(event ? toDateIn(event.start_at) : def); const [st, setSt] = useState(event ? toTimeIn(event.start_at) : '09:00'); @@ -377,6 +379,9 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte {showStartDate && {setSd(v);setShowStartDate(false);}} onClose={()=>setShowStartDate(false)}/>} {showEndDate && {setEd(v);setShowEndDate(false);}} onClose={()=>setShowEndDate(false)}/>} {showRecurrence && {setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>} + {showTypeColourPicker && ( + setShowTypeColourPicker(false)} title="Event Type Colour"/> + )} {showAddType && (
e.target===e.currentTarget&&setShowAddType(false)}>
@@ -394,7 +399,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte />
- setNewTypeColour(e.target.value)} style={{ flex:1,height:40,border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:3,cursor:'pointer' }}/> +
)} + {showColourPicker && ( + setShowColourPicker(false)} title="Event Type Colour"/> + )}
); }