ui update and some backend functionality adding in accordance with research on adhd and ux design
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
export default function PushNotificationToggle() {
|
||||
const [supported, setSupported] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setSupported(true);
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
setEnabled(!!sub);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
check();
|
||||
}, []);
|
||||
|
||||
const toggle = async () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
|
||||
if (enabled) {
|
||||
// Unsubscribe
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await api.notifications.unsubscribe(sub.endpoint);
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
setEnabled(false);
|
||||
} else {
|
||||
// Subscribe
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { public_key } = await api.notifications.getVapidPublicKey();
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key).buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
const subJson = sub.toJSON();
|
||||
await api.notifications.subscribe(subJson);
|
||||
setEnabled(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Push notification toggle failed:', err);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (!supported) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between bg-white rounded-xl p-4 shadow-sm">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm">Push Notifications</h3>
|
||||
<p className="text-xs text-gray-500">Get reminders on this device</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? 'bg-indigo-600' : 'bg-gray-300'
|
||||
} ${loading ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
synculous-client/src/components/session/VisualTimeline.tsx
Normal file
94
synculous-client/src/components/session/VisualTimeline.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CheckIcon } from '@/components/ui/Icons';
|
||||
|
||||
interface Step {
|
||||
id: string;
|
||||
name: string;
|
||||
duration_minutes?: number;
|
||||
}
|
||||
|
||||
interface VisualTimelineProps {
|
||||
steps: Step[];
|
||||
currentStepIndex: number;
|
||||
completedSteps: Set<number>;
|
||||
}
|
||||
|
||||
export default function VisualTimeline({ steps, currentStepIndex, completedSteps }: VisualTimelineProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-4 py-2 cursor-pointer"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
// Expanded: show step names
|
||||
<div className="flex flex-col gap-1">
|
||||
{steps.map((step, i) => {
|
||||
const isCompleted = completedSteps.has(i);
|
||||
const isCurrent = i === currentStepIndex;
|
||||
const isUpcoming = !isCompleted && !isCurrent;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 transition-all ${
|
||||
isCompleted
|
||||
? 'bg-indigo-500'
|
||||
: isCurrent
|
||||
? 'bg-indigo-400 ring-2 ring-indigo-400/50 ring-offset-2 ring-offset-gray-950'
|
||||
: 'bg-gray-700'
|
||||
}`}>
|
||||
{isCompleted ? (
|
||||
<CheckIcon size={14} className="text-white" />
|
||||
) : (
|
||||
<span className={`text-xs font-medium ${isCurrent ? 'text-white' : 'text-gray-400'}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-sm truncate ${
|
||||
isCompleted ? 'text-white/40 line-through' :
|
||||
isCurrent ? 'text-white font-medium' :
|
||||
'text-white/50'
|
||||
}`}>
|
||||
{step.name}
|
||||
</span>
|
||||
{step.duration_minutes && (
|
||||
<span className="text-xs text-white/30 ml-auto shrink-0">
|
||||
{step.duration_minutes}m
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-white/30 text-xs text-center mt-1">Tap to collapse</p>
|
||||
</div>
|
||||
) : (
|
||||
// Collapsed: just circles
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{steps.map((step, i) => {
|
||||
const isCompleted = completedSteps.has(i);
|
||||
const isCurrent = i === currentStepIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`rounded-full transition-all ${
|
||||
isCompleted
|
||||
? 'w-3 h-3 bg-indigo-500'
|
||||
: isCurrent
|
||||
? 'w-4 h-4 bg-indigo-400 animate-gentle-pulse'
|
||||
: 'w-3 h-3 bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
synculous-client/src/components/ui/AnimatedCheckmark.tsx
Normal file
45
synculous-client/src/components/ui/AnimatedCheckmark.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
interface AnimatedCheckmarkProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
strokeWidth?: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export default function AnimatedCheckmark({
|
||||
size = 48,
|
||||
color = '#22c55e',
|
||||
strokeWidth = 3,
|
||||
delay = 0,
|
||||
}: AnimatedCheckmarkProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
opacity={0.2}
|
||||
/>
|
||||
<path
|
||||
d="M7 12.5l3.5 3.5 6.5-7"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="24"
|
||||
strokeDashoffset="24"
|
||||
className="animate-checkmark-draw"
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -708,6 +708,135 @@ export function TimerIcon({ className = '', size = 24 }: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LockIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SparklesIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
<path d="M5 3v4" />
|
||||
<path d="M19 17v4" />
|
||||
<path d="M3 5h4" />
|
||||
<path d="M17 19h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrophyIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
|
||||
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
|
||||
<path d="M4 22h16" />
|
||||
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
|
||||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
|
||||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapPinIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function VolumeIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function VolumeOffIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<line x1="22" y1="9" x2="16" y2="15" />
|
||||
<line x1="16" y1="9" x2="22" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkipForwardIcon({ className = '', size = 24 }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
Reference in New Issue
Block a user