Persist sessions view mode in localStorage

This commit is contained in:
2026-03-04 17:04:03 +01:00
parent dcc769a930
commit 367eea6ee8

View File

@@ -1,16 +1,24 @@
'use client';
import { useState, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { CollaboratorDisplay } from '@/components/ui';
import { type WorkshopTabType, WORKSHOPS, VALID_TAB_PARAMS } from '@/lib/workshops';
import { type WorkshopTabType, VALID_TAB_PARAMS } from '@/lib/workshops';
import { useClickOutside } from '@/hooks/useClickOutside';
import {
type CardView, type SortCol, type WorkshopTabsProps, type AnySession,
TABLE_COLS, SORT_COLUMNS, TYPE_TABS,
type CardView,
type SortCol,
type WorkshopTabsProps,
type AnySession,
TABLE_COLS,
SORT_COLUMNS,
TYPE_TABS,
} from './workshop-session-types';
import {
getResolvedCollaborator, groupByPerson, getMonthGroup, sortSessions,
getResolvedCollaborator,
groupByPerson,
getMonthGroup,
sortSessions,
} from './workshop-session-helpers';
import { SessionCard } from './SessionCard';
@@ -33,7 +41,13 @@ function SectionHeader({ label, count }: { label: string; count: number }) {
function SortIcon({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) {
if (!active) {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" className="opacity-30 flex-shrink-0">
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="opacity-30 flex-shrink-0"
>
<path d="M5 1.5L8 5H2L5 1.5Z" />
<path d="M5 8.5L2 5H8L5 8.5Z" />
</svg>
@@ -72,27 +86,61 @@ function ViewToggle({ view, setView }: { view: CardView; setView: (v: CardView)
return (
<div className="flex items-center gap-0.5 p-0.5 bg-card border border-border rounded-lg ml-auto flex-shrink-0 shadow-sm">
{btn('grid', 'Grille',
{btn(
'grid',
'Grille',
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="0" y="0" width="4" height="4" rx="0.5" /><rect x="5" y="0" width="4" height="4" rx="0.5" /><rect x="10" y="0" width="4" height="4" rx="0.5" />
<rect x="0" y="5" width="4" height="4" rx="0.5" /><rect x="5" y="5" width="4" height="4" rx="0.5" /><rect x="10" y="5" width="4" height="4" rx="0.5" />
<rect x="0" y="10" width="4" height="4" rx="0.5" /><rect x="5" y="10" width="4" height="4" rx="0.5" /><rect x="10" y="10" width="4" height="4" rx="0.5" />
<rect x="0" y="0" width="4" height="4" rx="0.5" />
<rect x="5" y="0" width="4" height="4" rx="0.5" />
<rect x="10" y="0" width="4" height="4" rx="0.5" />
<rect x="0" y="5" width="4" height="4" rx="0.5" />
<rect x="5" y="5" width="4" height="4" rx="0.5" />
<rect x="10" y="5" width="4" height="4" rx="0.5" />
<rect x="0" y="10" width="4" height="4" rx="0.5" />
<rect x="5" y="10" width="4" height="4" rx="0.5" />
<rect x="10" y="10" width="4" height="4" rx="0.5" />
</svg>
)}
{btn('list', 'Liste',
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="0" y1="2.5" x2="14" y2="2.5" /><line x1="0" y1="7" x2="14" y2="7" /><line x1="0" y1="11.5" x2="14" y2="11.5" />
{btn(
'list',
'Liste',
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<line x1="0" y1="2.5" x2="14" y2="2.5" />
<line x1="0" y1="7" x2="14" y2="7" />
<line x1="0" y1="11.5" x2="14" y2="11.5" />
</svg>
)}
{btn('table', 'Tableau',
{btn(
'table',
'Tableau',
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="0" y="0" width="14" height="3.5" rx="0.5" />
<rect x="0" y="5" width="6" height="2.5" rx="0.5" /><rect x="8" y="5" width="6" height="2.5" rx="0.5" />
<rect x="0" y="9.5" width="6" height="2.5" rx="0.5" /><rect x="8" y="9.5" width="6" height="2.5" rx="0.5" />
<rect x="0" y="5" width="6" height="2.5" rx="0.5" />
<rect x="8" y="5" width="6" height="2.5" rx="0.5" />
<rect x="0" y="9.5" width="6" height="2.5" rx="0.5" />
<rect x="8" y="9.5" width="6" height="2.5" rx="0.5" />
</svg>
)}
{btn('timeline', 'Chronologique',
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
{btn(
'timeline',
'Chronologique',
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<line x1="3" y1="0" x2="3" y2="14" />
<circle cx="3" cy="2.5" r="1.5" fill="currentColor" stroke="none" />
<line x1="5" y1="2.5" x2="14" y2="2.5" />
@@ -108,11 +156,23 @@ function ViewToggle({ view, setView }: { view: CardView; setView: (v: CardView)
// ─── TabButton ────────────────────────────────────────────────────────────────
function TabButton({ active, onClick, icon, label, count }: {
active: boolean; onClick: () => void; icon: string; label: string; count: number;
function TabButton({
active,
onClick,
icon,
label,
count,
}: {
active: boolean;
onClick: () => void;
icon: string;
label: string;
count: number;
}) {
return (
<button type="button" onClick={onClick}
<button
type="button"
onClick={onClick}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full font-medium text-sm transition-all duration-150 shadow-sm ${
active
? 'bg-primary text-primary-foreground shadow-md'
@@ -121,7 +181,9 @@ function TabButton({ active, onClick, icon, label, count }: {
>
<span>{icon}</span>
<span>{label}</span>
<span className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${active ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
<span
className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${active ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}
>
{count}
</span>
</button>
@@ -131,10 +193,17 @@ function TabButton({ active, onClick, icon, label, count }: {
// ─── TypeFilterDropdown ───────────────────────────────────────────────────────
function TypeFilterDropdown({
activeTab, setActiveTab, open, onOpenChange, counts,
activeTab,
setActiveTab,
open,
onOpenChange,
counts,
}: {
activeTab: WorkshopTabType; setActiveTab: (t: WorkshopTabType) => void;
open: boolean; onOpenChange: (v: boolean) => void; counts: Record<string, number>;
activeTab: WorkshopTabType;
setActiveTab: (t: WorkshopTabType) => void;
open: boolean;
onOpenChange: (v: boolean) => void;
counts: Record<string, number>;
}) {
const typeTabs = TYPE_TABS.filter((t) => t.value !== 'all' && t.value !== 'team');
const current = TYPE_TABS.find((t) => t.value === activeTab) ?? TYPE_TABS[0];
@@ -156,25 +225,55 @@ function TypeFilterDropdown({
>
<span>{isTypeSelected ? current.icon : '🔖'}</span>
<span>{isTypeSelected ? current.label : 'Type'}</span>
<span className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${isTypeSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}>
<span
className={`text-[11px] font-semibold px-1.5 py-0.5 rounded-full ${isTypeSelected ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'}`}
>
{isTypeSelected ? (counts[activeTab] ?? 0) : totalCount}
</span>
<svg className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg
className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute left-0 z-20 mt-2 w-48 rounded-xl border border-border bg-card py-1.5 shadow-lg">
<button type="button" onClick={() => { setActiveTab('all'); onOpenChange(false); }}
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover border-b border-border transition-colors">
<span className="flex items-center gap-2"><span>📋</span><span>Tous les types</span></span>
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">{totalCount}</span>
<button
type="button"
onClick={() => {
setActiveTab('all');
onOpenChange(false);
}}
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover border-b border-border transition-colors"
>
<span className="flex items-center gap-2">
<span>📋</span>
<span>Tous les types</span>
</span>
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{totalCount}
</span>
</button>
{typeTabs.map((t) => (
<button key={t.value} type="button" onClick={() => { setActiveTab(t.value); onOpenChange(false); }}
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover transition-colors">
<span className="flex items-center gap-2"><span>{t.icon}</span><span>{t.label}</span></span>
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">{counts[t.value] ?? 0}</span>
<button
key={t.value}
type="button"
onClick={() => {
setActiveTab(t.value);
onOpenChange(false);
}}
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left text-sm text-foreground hover:bg-card-hover transition-colors"
>
<span className="flex items-center gap-2">
<span>{t.icon}</span>
<span>{t.label}</span>
</span>
<span className="text-[11px] font-semibold px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{counts[t.value] ?? 0}
</span>
</button>
))}
</div>
@@ -186,16 +285,25 @@ function TypeFilterDropdown({
// ─── SessionsGrid ─────────────────────────────────────────────────────────────
function SessionsGrid({
sessions, view, isTeamCollab = false,
sessions,
view,
isTeamCollab = false,
}: {
sessions: AnySession[]; view: CardView; isTeamCollab?: boolean;
sessions: AnySession[];
view: CardView;
isTeamCollab?: boolean;
}) {
if (view === 'table') {
return (
<div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card">
<div className="grid text-[11px] font-semibold text-muted uppercase tracking-wider bg-card-hover/60 border-b border-border" style={{ gridTemplateColumns: TABLE_COLS }}>
<div
className="grid text-[11px] font-semibold text-muted uppercase tracking-wider bg-card-hover/60 border-b border-border"
style={{ gridTemplateColumns: TABLE_COLS }}
>
{SORT_COLUMNS.map((col) => (
<div key={col.key} className="px-4 py-2.5">{col.label}</div>
<div key={col.key} className="px-4 py-2.5">
{col.label}
</div>
))}
</div>
{sessions.map((s) => (
@@ -205,7 +313,11 @@ function SessionsGrid({
);
}
return (
<div className={view === 'list' ? 'flex flex-col gap-2' : 'grid gap-4 md:grid-cols-2 lg:grid-cols-3'}>
<div
className={
view === 'list' ? 'flex flex-col gap-2' : 'grid gap-4 md:grid-cols-2 lg:grid-cols-3'
}
>
{sessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab={isTeamCollab} view={view} />
))}
@@ -216,7 +328,10 @@ function SessionsGrid({
// ─── SortableTableView ────────────────────────────────────────────────────────
function SortableTableView({
sessions, sortCol, sortDir, onSort,
sessions,
sortCol,
sortDir,
onSort,
}: {
sessions: AnySession[];
sortCol: SortCol;
@@ -228,7 +343,10 @@ function SortableTableView({
}
return (
<div className="rounded-xl border border-border overflow-hidden overflow-x-auto bg-card">
<div className="grid bg-card-hover/60 border-b border-border" style={{ gridTemplateColumns: TABLE_COLS }}>
<div
className="grid bg-card-hover/60 border-b border-border"
style={{ gridTemplateColumns: TABLE_COLS }}
>
{SORT_COLUMNS.map((col) => (
<button
key={col.key}
@@ -258,20 +376,39 @@ function SortableTableView({
// ─── WorkshopTabs ─────────────────────────────────────────────────────────────
export function WorkshopTabs({
swotSessions, motivatorSessions, yearReviewSessions,
weeklyCheckInSessions, weatherSessions, gifMoodSessions,
swotSessions,
motivatorSessions,
yearReviewSessions,
weeklyCheckInSessions,
weatherSessions,
gifMoodSessions,
teamCollabSessions = [],
}: WorkshopTabsProps) {
const CARD_VIEW_STORAGE_KEY = 'sessions:cardView';
const isCardView = (value: string): value is CardView =>
value === 'grid' || value === 'list' || value === 'table' || value === 'timeline';
const searchParams = useSearchParams();
const router = useRouter();
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
const [cardView, setCardView] = useState<CardView>('grid');
const [cardView, setCardView] = useState<CardView>(() => {
if (typeof window === 'undefined') return 'grid';
const storedView = localStorage.getItem(CARD_VIEW_STORAGE_KEY);
return storedView && isCardView(storedView) ? storedView : 'grid';
});
const [sortCol, setSortCol] = useState<SortCol>('date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
localStorage.setItem(CARD_VIEW_STORAGE_KEY, cardView);
}, [cardView]);
const handleSort = (col: SortCol) => {
if (sortCol === col) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortCol(col); setSortDir('asc'); }
else {
setSortCol(col);
setSortDir('asc');
}
};
const tabParam = searchParams.get('tab');
@@ -288,19 +425,30 @@ export function WorkshopTabs({
};
const allSessions: AnySession[] = [
...swotSessions, ...motivatorSessions, ...yearReviewSessions,
...weeklyCheckInSessions, ...weatherSessions, ...gifMoodSessions,
...swotSessions,
...motivatorSessions,
...yearReviewSessions,
...weeklyCheckInSessions,
...weatherSessions,
...gifMoodSessions,
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const filteredSessions: AnySession[] =
activeTab === 'all' || activeTab === 'byPerson' ? allSessions
: activeTab === 'team' ? teamCollabSessions
: activeTab === 'swot' ? swotSessions
: activeTab === 'motivators' ? motivatorSessions
: activeTab === 'year-review' ? yearReviewSessions
: activeTab === 'weekly-checkin' ? weeklyCheckInSessions
: activeTab === 'gif-mood' ? gifMoodSessions
: weatherSessions;
activeTab === 'all' || activeTab === 'byPerson'
? allSessions
: activeTab === 'team'
? teamCollabSessions
: activeTab === 'swot'
? swotSessions
: activeTab === 'motivators'
? motivatorSessions
: activeTab === 'year-review'
? yearReviewSessions
: activeTab === 'weekly-checkin'
? weeklyCheckInSessions
: activeTab === 'gif-mood'
? gifMoodSessions
: weatherSessions;
const ownedSessions = filteredSessions.filter((s) => s.isOwner);
const sharedSessions = filteredSessions.filter(
@@ -309,11 +457,14 @@ export function WorkshopTabs({
const teamCollabFiltered = activeTab === 'all' ? teamCollabSessions : [];
const sessionsByPerson = groupByPerson(allSessions);
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) => a[0].localeCompare(b[0], 'fr'));
const sortedPersons = Array.from(sessionsByPerson.entries()).sort((a, b) =>
a[0].localeCompare(b[0], 'fr')
);
// Timeline grouping
const timelineSessions = [...(activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions)]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const timelineSessions = [
...(activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions),
].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const byMonth = new Map<string, AnySession[]>();
timelineSessions.forEach((s) => {
const key = getMonthGroup(s.updatedAt);
@@ -326,7 +477,8 @@ export function WorkshopTabs({
cardView === 'table' && activeTab !== 'byPerson'
? sortSessions(
activeTab === 'all' ? [...filteredSessions, ...teamCollabSessions] : filteredSessions,
sortCol, sortDir,
sortCol,
sortDir
)
: [];
@@ -334,19 +486,42 @@ export function WorkshopTabs({
<div className="space-y-8">
{/* Tabs + vue toggle */}
<div className="flex gap-1.5 items-center flex-wrap">
<TabButton active={activeTab === 'all'} onClick={() => setActiveTab('all')} icon="📋" label="Tous" count={allSessions.length} />
<TabButton active={activeTab === 'byPerson'} onClick={() => setActiveTab('byPerson')} icon="👥" label="Par personne" count={sessionsByPerson.size} />
<TabButton
active={activeTab === 'all'}
onClick={() => setActiveTab('all')}
icon="📋"
label="Tous"
count={allSessions.length}
/>
<TabButton
active={activeTab === 'byPerson'}
onClick={() => setActiveTab('byPerson')}
icon="👥"
label="Par personne"
count={sessionsByPerson.size}
/>
{teamCollabSessions.length > 0 && (
<TabButton active={activeTab === 'team'} onClick={() => setActiveTab('team')} icon="🏢" label="Équipe" count={teamCollabSessions.length} />
<TabButton
active={activeTab === 'team'}
onClick={() => setActiveTab('team')}
icon="🏢"
label="Équipe"
count={teamCollabSessions.length}
/>
)}
<div className="h-5 w-px bg-border mx-0.5 self-center" />
<TypeFilterDropdown
activeTab={activeTab} setActiveTab={setActiveTab}
open={typeDropdownOpen} onOpenChange={setTypeDropdownOpen}
activeTab={activeTab}
setActiveTab={setActiveTab}
open={typeDropdownOpen}
onOpenChange={setTypeDropdownOpen}
counts={{
swot: swotSessions.length, motivators: motivatorSessions.length,
'year-review': yearReviewSessions.length, 'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length, 'gif-mood': gifMoodSessions.length,
swot: swotSessions.length,
motivators: motivatorSessions.length,
'year-review': yearReviewSessions.length,
'weekly-checkin': weeklyCheckInSessions.length,
weather: weatherSessions.length,
'gif-mood': gifMoodSessions.length,
team: teamCollabSessions.length,
}}
/>
@@ -355,8 +530,12 @@ export function WorkshopTabs({
{/* ── Vue Tableau flat (colonnes triables) ──────────────────── */}
{cardView === 'table' && activeTab !== 'byPerson' ? (
<SortableTableView sessions={flatTableSessions} sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortableTableView
sessions={flatTableSessions}
sortCol={sortCol}
sortDir={sortDir}
onSort={handleSort}
/>
) : cardView === 'timeline' && activeTab !== 'byPerson' ? (
/* ── Vue Timeline ────────────────────────────────────────── */
byMonth.size === 0 ? (
@@ -367,19 +546,25 @@ export function WorkshopTabs({
<section key={period}>
<div className="flex items-center gap-3 mb-4">
<div className="h-px flex-1 bg-border" />
<span className="text-xs font-semibold text-muted uppercase tracking-widest px-2 capitalize">{period}</span>
<span className="text-xs font-semibold text-muted uppercase tracking-widest px-2 capitalize">
{period}
</span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex flex-col gap-2">
{sessions.map((s) => (
<SessionCard key={s.id} session={s} isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab} view="list" />
<SessionCard
key={s.id}
session={s}
isTeamCollab={(s as AnySession & { isTeamCollab?: boolean }).isTeamCollab}
view="list"
/>
))}
</div>
</section>
))}
</div>
)
) : activeTab === 'byPerson' ? (
/* ── Vue Par personne ───────────────────────────────────── */
sortedPersons.length === 0 ? (
@@ -396,21 +581,28 @@ export function WorkshopTabs({
{sessions.length} atelier{sessions.length > 1 ? 's' : ''}
</span>
</div>
<SessionsGrid sessions={sessions} view={cardView === 'timeline' ? 'list' : cardView} />
<SessionsGrid
sessions={sessions}
view={cardView === 'timeline' ? 'list' : cardView}
/>
</section>
);
})}
</div>
)
) : activeTab === 'team' ? (
/* ── Vue Équipe ─────────────────────────────────────────── */
teamCollabSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de vos collaborateurs (non partagés)</div>
<div className="text-center py-12 text-muted">
Aucun atelier de vos collaborateurs (non partagés)
</div>
) : (
<div className="space-y-8">
<section>
<SectionHeader label="Ateliers de l'équipe non partagés" count={teamCollabSessions.length} />
<SectionHeader
label="Ateliers de l'équipe non partagés"
count={teamCollabSessions.length}
/>
<p className="text-sm text-muted mb-5 -mt-2">
En tant qu&apos;admin d&apos;équipe, vous voyez les ateliers de vos collaborateurs
qui ne vous sont pas encore partagés.
@@ -419,10 +611,8 @@ export function WorkshopTabs({
</section>
</div>
)
) : filteredSessions.length === 0 ? (
<div className="text-center py-12 text-muted">Aucun atelier de ce type pour le moment</div>
) : (
/* ── Vue normale (tous / par type) ─────────────────────── */
<div className="space-y-10">