feat: add someday list tabs and side navigation arrows
Add tab system for someday lists allowing categorization (e.g. Private, Work, Family). Lists can be assigned to tabs via tag icon dropdown in list headers. Tabs appear in the someday label column for filtering. Double-click tab name to rename. Tabs persist in DB via new SomedayList.tab field. Also add hover-overlay prev/next day/week navigation arrows on left and right sides of the weekly grid, and fix first hour label clipping. i18n: tab translations for EN, DE, FR, ES, IT. v1.25.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fd2d4e5ed3
commit
3016293cd9
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.24.0",
|
"version": "1.25.0",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
prisma/migrations/20260310_add_someday_tab/migration.sql
Normal file
2
prisma/migrations/20260310_add_someday_tab/migration.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable: Add tab field to SomedayList
|
||||||
|
ALTER TABLE "SomedayList" ADD COLUMN IF NOT EXISTS "tab" TEXT;
|
||||||
@ -195,6 +195,7 @@ model SomedayList {
|
|||||||
userId String
|
userId String
|
||||||
title String
|
title String
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
|
tab String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
externalId String?
|
externalId String?
|
||||||
|
|||||||
@ -185,12 +185,12 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Single Update (Title)
|
// Handle Single Update (Title and/or Tab)
|
||||||
const { id, title } = body;
|
const { id, title, tab } = body;
|
||||||
|
|
||||||
if (!id || !title) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'ID and Title are required' },
|
{ error: 'ID is required' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -207,9 +207,13 @@ export async function PATCH(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data: Record<string, any> = {};
|
||||||
|
if (title !== undefined) data.title = title;
|
||||||
|
if (tab !== undefined) data.tab = tab;
|
||||||
|
|
||||||
const list = await prisma.somedayList.update({
|
const list = await prisma.somedayList.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { title }
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ list });
|
return NextResponse.json({ list });
|
||||||
|
|||||||
@ -1443,6 +1443,7 @@ h3 {
|
|||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
border-bottom: 1px solid var(--weekly-border);
|
border-bottom: 1px solid var(--weekly-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1492,6 +1493,72 @@ h3 {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Someday Tab Buttons (vertical in label column) */
|
||||||
|
.someday-tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--weekly-text-light, #999);
|
||||||
|
font-size: 0.45rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 3px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.someday-tab-btn:hover {
|
||||||
|
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.06));
|
||||||
|
color: var(--weekly-text, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.someday-tab-btn.active {
|
||||||
|
background: var(--weekly-accent, #6366f1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Someday Tab Buttons (horizontal in non-time-grid) */
|
||||||
|
.someday-tab-btn-h {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--weekly-text-light, #999);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.someday-tab-btn-h:hover {
|
||||||
|
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.06));
|
||||||
|
color: var(--weekly-text, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.someday-tab-btn-h.active {
|
||||||
|
background: var(--weekly-accent, #6366f1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Someday Tab Select (in list headers) */
|
||||||
|
.someday-tab-select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.someday-tab-select:hover {
|
||||||
|
color: var(--weekly-text, #333);
|
||||||
|
}
|
||||||
|
|
||||||
/* Preferences Slide-in Panel */
|
/* Preferences Slide-in Panel */
|
||||||
.preferences-panel {
|
.preferences-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -1857,6 +1924,11 @@ h3 {
|
|||||||
.time-slot {
|
.time-slot {
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide side nav on mobile (swipe navigation is used instead) */
|
||||||
|
.side-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Phone portrait (≤ 480px): 1-day view === */
|
/* === Phone portrait (≤ 480px): 1-day view === */
|
||||||
@ -1927,6 +1999,55 @@ h3 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Side Navigation Arrows (hover overlays) */
|
||||||
|
.side-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.35rem 0.15rem;
|
||||||
|
gap: 0.1rem;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav .side-nav-btn {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav:hover .side-nav-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--weekly-text-light, #999);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-btn:hover {
|
||||||
|
background: var(--weekly-hover-bg, rgba(0, 0, 0, 0.06));
|
||||||
|
color: var(--weekly-text, #333);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Time Column (Hours) */
|
/* Time Column (Hours) */
|
||||||
@ -1949,6 +2070,7 @@ h3 {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding-top: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -59,6 +59,7 @@ import {
|
|||||||
Cable,
|
Cable,
|
||||||
Link,
|
Link,
|
||||||
Globe,
|
Globe,
|
||||||
|
Tag,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@ -144,6 +145,7 @@ interface SomedayList {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
|
tab?: string | null;
|
||||||
externalProvider?: string | null;
|
externalProvider?: string | null;
|
||||||
externalId?: string | null;
|
externalId?: string | null;
|
||||||
externalListId?: string | null;
|
externalListId?: string | null;
|
||||||
@ -254,6 +256,11 @@ const translations: Record<string, any> = {
|
|||||||
confirmPassword: "Confirm Password",
|
confirmPassword: "Confirm Password",
|
||||||
someday: "SOMEDAY",
|
someday: "SOMEDAY",
|
||||||
lists: "Lists",
|
lists: "Lists",
|
||||||
|
allTabs: "All",
|
||||||
|
newTab: "New tab",
|
||||||
|
newTabName: "New tab name:",
|
||||||
|
assignTab: "Assign to tab",
|
||||||
|
renameTab: "Double-click to rename",
|
||||||
loading: "Loading your tasks...",
|
loading: "Loading your tasks...",
|
||||||
sycing: "Syncing...",
|
sycing: "Syncing...",
|
||||||
synced: "Synced",
|
synced: "Synced",
|
||||||
@ -436,6 +443,11 @@ const translations: Record<string, any> = {
|
|||||||
confirmPassword: "Passwort bestätigen",
|
confirmPassword: "Passwort bestätigen",
|
||||||
someday: "IRGENDWANN",
|
someday: "IRGENDWANN",
|
||||||
lists: "Listen",
|
lists: "Listen",
|
||||||
|
allTabs: "Alle",
|
||||||
|
newTab: "Neuer Tab",
|
||||||
|
newTabName: "Neuer Tab-Name:",
|
||||||
|
assignTab: "Tab zuweisen",
|
||||||
|
renameTab: "Doppelklick zum Umbenennen",
|
||||||
loading: "Lade Aufgaben...",
|
loading: "Lade Aufgaben...",
|
||||||
syncing: "Synchronisiere...",
|
syncing: "Synchronisiere...",
|
||||||
synced: "Synchronisiert",
|
synced: "Synchronisiert",
|
||||||
@ -617,6 +629,11 @@ const translations: Record<string, any> = {
|
|||||||
confirmPassword: "Confirmer le mot de passe",
|
confirmPassword: "Confirmer le mot de passe",
|
||||||
someday: "UN JOUR",
|
someday: "UN JOUR",
|
||||||
lists: "Listes",
|
lists: "Listes",
|
||||||
|
allTabs: "Tous",
|
||||||
|
newTab: "Nouvel onglet",
|
||||||
|
newTabName: "Nom du nouvel onglet :",
|
||||||
|
assignTab: "Assigner à un onglet",
|
||||||
|
renameTab: "Double-cliquez pour renommer",
|
||||||
loading: "Chargement de vos tâches…",
|
loading: "Chargement de vos tâches…",
|
||||||
sycing: "Synchronisation…",
|
sycing: "Synchronisation…",
|
||||||
synced: "Synchronisé",
|
synced: "Synchronisé",
|
||||||
@ -798,6 +815,11 @@ const translations: Record<string, any> = {
|
|||||||
confirmPassword: "Confirmar contraseña",
|
confirmPassword: "Confirmar contraseña",
|
||||||
someday: "ALGÚN DÍA",
|
someday: "ALGÚN DÍA",
|
||||||
lists: "Listas",
|
lists: "Listas",
|
||||||
|
allTabs: "Todas",
|
||||||
|
newTab: "Nueva pestaña",
|
||||||
|
newTabName: "Nombre de nueva pestaña:",
|
||||||
|
assignTab: "Asignar a pestaña",
|
||||||
|
renameTab: "Doble clic para renombrar",
|
||||||
loading: "Cargando tus tareas…",
|
loading: "Cargando tus tareas…",
|
||||||
sycing: "Sincronizando…",
|
sycing: "Sincronizando…",
|
||||||
synced: "Sincronizado",
|
synced: "Sincronizado",
|
||||||
@ -979,6 +1001,11 @@ const translations: Record<string, any> = {
|
|||||||
confirmPassword: "Conferma password",
|
confirmPassword: "Conferma password",
|
||||||
someday: "UN GIORNO",
|
someday: "UN GIORNO",
|
||||||
lists: "Liste",
|
lists: "Liste",
|
||||||
|
allTabs: "Tutte",
|
||||||
|
newTab: "Nuova scheda",
|
||||||
|
newTabName: "Nome nuova scheda:",
|
||||||
|
assignTab: "Assegna a scheda",
|
||||||
|
renameTab: "Doppio clic per rinominare",
|
||||||
loading: "Caricamento delle attività…",
|
loading: "Caricamento delle attività…",
|
||||||
sycing: "Sincronizzazione…",
|
sycing: "Sincronizzazione…",
|
||||||
synced: "Sincronizzato",
|
synced: "Sincronizzato",
|
||||||
@ -1398,6 +1425,64 @@ export default function WeeklyView() {
|
|||||||
const [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]);
|
const [projects, setProjects] = useState<{ id: string; name: string; icon?: string | null; color?: string | null }[]>([]);
|
||||||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||||
const [draggingListId, setDraggingListId] = useState<string | null>(null);
|
const [draggingListId, setDraggingListId] = useState<string | null>(null);
|
||||||
|
const [listToDelete, setListToDelete] = useState<string | null>(null);
|
||||||
|
const [activeSomedayTab, setActiveSomedayTab] = useState<string | null>(null);
|
||||||
|
const [editingTabName, setEditingTabName] = useState<string | null>(null);
|
||||||
|
const [renamingTabValue, setRenamingTabValue] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const saved = localStorage.getItem("weekly_active_someday_tab");
|
||||||
|
if (saved) setActiveSomedayTab(saved === "__all__" ? null : saved);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const somedayTabs = useMemo(() => {
|
||||||
|
const tabs = new Set<string>();
|
||||||
|
somedayLists.forEach(l => { if (l.tab) tabs.add(l.tab); });
|
||||||
|
return Array.from(tabs).sort();
|
||||||
|
}, [somedayLists]);
|
||||||
|
|
||||||
|
const setSomedayTab = (tab: string | null) => {
|
||||||
|
setActiveSomedayTab(tab);
|
||||||
|
localStorage.setItem("weekly_active_someday_tab", tab ?? "__all__");
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignListToTab = async (listId: string, tab: string | null) => {
|
||||||
|
setSomedayLists(prev => prev.map(l => l.id === listId ? { ...l, tab } : l));
|
||||||
|
try {
|
||||||
|
await fetch("/api/someday-lists", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: listId, tab }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update list tab:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameTab = async (oldName: string, newName: string) => {
|
||||||
|
if (!newName.trim() || newName === oldName) return;
|
||||||
|
const listsToUpdate = somedayLists.filter(l => l.tab === oldName);
|
||||||
|
setSomedayLists(prev => prev.map(l => l.tab === oldName ? { ...l, tab: newName.trim() } : l));
|
||||||
|
if (activeSomedayTab === oldName) setSomedayTab(newName.trim());
|
||||||
|
for (const list of listsToUpdate) {
|
||||||
|
try {
|
||||||
|
await fetch("/api/someday-lists", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: list.id, tab: newName.trim() }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to rename tab for list:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredSomedayLists = useMemo(() => {
|
||||||
|
if (activeSomedayTab === null) return somedayLists;
|
||||||
|
return somedayLists.filter(l => (l.tab || null) === activeSomedayTab);
|
||||||
|
}, [somedayLists, activeSomedayTab]);
|
||||||
const [dropTargetListIndex, setDropTargetListIndex] = useState<number | null>(null);
|
const [dropTargetListIndex, setDropTargetListIndex] = useState<number | null>(null);
|
||||||
const [activeAddSlot, setActiveAddSlot] = useState<{ listId: string; slotIdx: number } | null>(null);
|
const [activeAddSlot, setActiveAddSlot] = useState<{ listId: string; slotIdx: number } | null>(null);
|
||||||
const isDragFromHandle = useRef(false);
|
const isDragFromHandle = useRef(false);
|
||||||
@ -5142,7 +5227,7 @@ export default function WeeklyView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop Header: Left, Center, Right */}
|
{/* Desktop Header: Left, Center, Right */}
|
||||||
<header className="group flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200" style={isMobile ? { display: "none" } : {}}>
|
<header className="group relative flex items-center justify-between w-full px-4 py-2 border-b border-gray-200 bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-white transition-colors duration-200" style={isMobile ? { display: "none" } : {}}>
|
||||||
{/* LEFT SECTION: Slot Duration & Days to Show */}
|
{/* LEFT SECTION: Slot Duration & Days to Show */}
|
||||||
<div className="weekly-header-controls flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
|
<div className="weekly-header-controls flex items-center gap-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100" style={{ zIndex: 1 }}>
|
||||||
{/* Slot Duration */}
|
{/* Slot Duration */}
|
||||||
@ -5235,25 +5320,39 @@ export default function WeeklyView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */}
|
{/* CENTER SECTION: Week/Year, Goal, Focus Mode - Reveal on Hover */}
|
||||||
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group" style={{ zIndex: 0 }}>
|
<div className="flex items-center justify-center gap-6 absolute left-1/2 transform -translate-x-1/2 group z-10 opacity-100" style={{ pointerEvents: "auto" }}>
|
||||||
{/* Week & Year */}
|
{/* Week & Year */}
|
||||||
<div className="whitespace-nowrap flex items-center gap-2">
|
<div className="whitespace-nowrap flex items-center gap-2">
|
||||||
{syncError ? (
|
{/* Date Picker Toggle - Moved to front */}
|
||||||
<div className="flex items-center gap-1 text-red-500" title={syncError}>
|
<div className="relative">
|
||||||
<AlertCircle size={14} />
|
|
||||||
<span className="text-xs">{syncError}</span>
|
|
||||||
</div>
|
|
||||||
) : (isLoading || isSyncing || syncStatus === "syncing") ? (
|
|
||||||
<div className="weekly-spinner" title="Syncing..."></div>
|
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { fetchCalendarEvents(true); fetchTasks(); }}
|
ref={datePickerBtnRef}
|
||||||
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100"
|
className={`p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors ${showDatePicker ? "text-teal-600 bg-teal-50 opacity-100" : "text-gray-500 hover:text-black opacity-0 group-hover:opacity-100"}`}
|
||||||
title="Refresh Calendar & Tasks"
|
onClick={() => setShowDatePicker(!showDatePicker)}
|
||||||
|
title="Jump to date"
|
||||||
>
|
>
|
||||||
<RefreshCcw size={14} />
|
<Calendar size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
{showDatePicker && (
|
||||||
|
<SimpleDatePicker
|
||||||
|
selected={currentWeekStart}
|
||||||
|
onSelect={(date) => {
|
||||||
|
setCurrentWeekStart(getStartOfWeek(date));
|
||||||
|
setShowDatePicker(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setShowDatePicker(false)}
|
||||||
|
language={language}
|
||||||
|
anchorRef={datePickerBtnRef}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clickable Week & Year */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 cursor-pointer hover:opacity-80"
|
||||||
|
onClick={() => setShowDatePicker(!showDatePicker)}
|
||||||
|
title="Jump to date"
|
||||||
|
>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: profile.cwFontFamily || "Inter",
|
fontFamily: profile.cwFontFamily || "Inter",
|
||||||
fontSize: profile.cwFontSize || "1.125rem",
|
fontSize: profile.cwFontSize || "1.125rem",
|
||||||
@ -5276,10 +5375,26 @@ export default function WeeklyView() {
|
|||||||
{currentWeekStart.getFullYear()}
|
{currentWeekStart.getFullYear()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Goal */}
|
{/* Goal */}
|
||||||
<div className="flex items-center text-sm">
|
<div className="flex items-center text-sm">
|
||||||
<span className="text-gray-300 mx-2">-</span>
|
{syncError ? (
|
||||||
|
<div className="flex items-center gap-1 text-red-500 mr-2" title={syncError}>
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span className="text-xs">{syncError}</span>
|
||||||
|
</div>
|
||||||
|
) : (isLoading || isSyncing || syncStatus === "syncing") ? (
|
||||||
|
<div className="weekly-spinner mr-2" title="Syncing..."></div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => { fetchCalendarEvents(true); fetchTasks(); }}
|
||||||
|
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all text-gray-400 hover:text-gray-600 opacity-0 group-hover:opacity-100 mr-1"
|
||||||
|
title="Refresh Calendar & Tasks"
|
||||||
|
>
|
||||||
|
<RefreshCcw size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isEditingGoal ? (
|
{isEditingGoal ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -5314,7 +5429,7 @@ export default function WeeklyView() {
|
|||||||
fontWeight: profile.goalFontWeight || undefined,
|
fontWeight: profile.goalFontWeight || undefined,
|
||||||
color: adjustColorForDarkMode((profile.goalFallbackType === "quote" ? profile.taskColor : undefined) || "#333333", darkMode),
|
color: adjustColorForDarkMode((profile.goalFallbackType === "quote" ? profile.taskColor : undefined) || "#333333", darkMode),
|
||||||
filter: "brightness(var(--weekly-goal-brightness, 1))",
|
filter: "brightness(var(--weekly-goal-brightness, 1))",
|
||||||
maxWidth: "500px",
|
maxWidth: "800px",
|
||||||
textAlign: "center" as const,
|
textAlign: "center" as const,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "-webkit-box",
|
display: "-webkit-box",
|
||||||
@ -5354,7 +5469,6 @@ export default function WeeklyView() {
|
|||||||
: (goal || (profile.goalFallbackType === "quote" ? motivationalQuote : goal))}
|
: (goal || (profile.goalFallbackType === "quote" ? motivationalQuote : goal))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-gray-300 mx-2">-</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -5476,29 +5590,6 @@ export default function WeeklyView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date Picker Toggle */}
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
ref={datePickerBtnRef}
|
|
||||||
className={`p-1.5 hover:bg-gray-100 rounded-md transition-colors ${showDatePicker ? "text-teal-600 bg-teal-50" : "text-gray-500 hover:text-black"}`}
|
|
||||||
onClick={() => setShowDatePicker(!showDatePicker)}
|
|
||||||
title="Jump to date"
|
|
||||||
>
|
|
||||||
<Calendar size={18} />
|
|
||||||
</button>
|
|
||||||
{showDatePicker && (
|
|
||||||
<SimpleDatePicker
|
|
||||||
selected={currentWeekStart}
|
|
||||||
onSelect={(date) => {
|
|
||||||
setCurrentWeekStart(getStartOfWeek(date));
|
|
||||||
setShowDatePicker(false);
|
|
||||||
}}
|
|
||||||
onClose={() => setShowDatePicker(false)}
|
|
||||||
language={language}
|
|
||||||
anchorRef={datePickerBtnRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<button
|
<button
|
||||||
@ -5548,6 +5639,15 @@ export default function WeeklyView() {
|
|||||||
|
|
||||||
{/* Main Grid with Time Column */}
|
{/* Main Grid with Time Column */}
|
||||||
<div className="time-grid-wrapper">
|
<div className="time-grid-wrapper">
|
||||||
|
{/* Side Navigation Arrows (hover overlays) */}
|
||||||
|
<div className="side-nav side-nav-left">
|
||||||
|
<button onClick={goToPrevDay} title="Previous Day" className="side-nav-btn">
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button onClick={goToPrevWeek} title="Previous Week" className="side-nav-btn">
|
||||||
|
<ChevronsLeft size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/* Time Column */}
|
{/* Time Column */}
|
||||||
{showTimeGrid && (
|
{showTimeGrid && (
|
||||||
<div className="time-column">
|
<div className="time-column">
|
||||||
@ -6139,6 +6239,16 @@ export default function WeeklyView() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Right Navigation Arrows (after grid so it paints on top) */}
|
||||||
|
<div className="side-nav side-nav-right">
|
||||||
|
<button onClick={goToNextDay} title="Next Day" className="side-nav-btn">
|
||||||
|
<ChevronLeft size={16} className="rotate-180" />
|
||||||
|
</button>
|
||||||
|
<button onClick={goToNextWeek} title="Next Week" className="side-nav-btn">
|
||||||
|
<ChevronsLeft size={16} className="rotate-180" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* All-Day Events Section (below position) */}
|
{/* All-Day Events Section (below position) */}
|
||||||
@ -6227,6 +6337,69 @@ export default function WeeklyView() {
|
|||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
{/* Someday Tabs */}
|
||||||
|
{somedayTabs.length > 0 && (
|
||||||
|
<div className="someday-tabs" style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1px",
|
||||||
|
width: "100%",
|
||||||
|
marginTop: "4px",
|
||||||
|
borderTop: "1px solid var(--weekly-border)",
|
||||||
|
paddingTop: "4px",
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
className={`someday-tab-btn ${activeSomedayTab === null ? "active" : ""}`}
|
||||||
|
onClick={() => setSomedayTab(null)}
|
||||||
|
title={t.allTabs}
|
||||||
|
>
|
||||||
|
{t.allTabs}
|
||||||
|
</button>
|
||||||
|
{somedayTabs.map(tab => (
|
||||||
|
editingTabName === tab ? (
|
||||||
|
<input
|
||||||
|
key={tab}
|
||||||
|
className="someday-tab-rename-input"
|
||||||
|
value={renamingTabValue}
|
||||||
|
onChange={(e) => setRenamingTabValue(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
renameTab(tab, renamingTabValue);
|
||||||
|
setEditingTabName(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") e.currentTarget.blur();
|
||||||
|
if (e.key === "Escape") setEditingTabName(null);
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
fontSize: "0.5rem",
|
||||||
|
textAlign: "center",
|
||||||
|
border: "1px solid var(--weekly-border)",
|
||||||
|
borderRadius: "3px",
|
||||||
|
padding: "2px",
|
||||||
|
background: "var(--weekly-bg)",
|
||||||
|
color: "var(--weekly-text)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
className={`someday-tab-btn ${activeSomedayTab === tab ? "active" : ""}`}
|
||||||
|
onClick={() => setSomedayTab(tab)}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setEditingTabName(tab);
|
||||||
|
setRenamingTabValue(tab);
|
||||||
|
}}
|
||||||
|
title={t.renameTab}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!showTimeGrid && (
|
{!showTimeGrid && (
|
||||||
@ -6290,6 +6463,26 @@ export default function WeeklyView() {
|
|||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
{/* Horizontal tabs for non-time-grid */}
|
||||||
|
{somedayTabs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={`someday-tab-btn-h ${activeSomedayTab === null ? "active" : ""}`}
|
||||||
|
onClick={() => setSomedayTab(null)}
|
||||||
|
>{t.allTabs}</button>
|
||||||
|
{somedayTabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
className={`someday-tab-btn-h ${activeSomedayTab === tab ? "active" : ""}`}
|
||||||
|
onClick={() => setSomedayTab(tab)}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setEditingTabName(tab);
|
||||||
|
setRenamingTabValue(tab);
|
||||||
|
}}
|
||||||
|
>{tab}</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={somedayGridRef} style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
|
<div ref={somedayGridRef} style={{ flex: 1, minWidth: 0, overflowX: "auto" }}>
|
||||||
@ -6305,10 +6498,10 @@ export default function WeeklyView() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const baseLists = somedayLists.length > 0
|
const baseLists = filteredSomedayLists.length > 0
|
||||||
? somedayLists
|
? filteredSomedayLists
|
||||||
: [{ id: "default", title: "LISTE", tasks: [] as Task[] }];
|
: [{ id: "default", title: "LISTE", tasks: [] as Task[] }];
|
||||||
return baseLists.slice(0, Math.max(somedayLists.length, viewDays));
|
return baseLists.slice(0, Math.max(filteredSomedayLists.length, viewDays));
|
||||||
})()
|
})()
|
||||||
.flatMap((list, listIdx, arr) => {
|
.flatMap((list, listIdx, arr) => {
|
||||||
const indicator = draggingListId && dropTargetListIndex === listIdx && draggingListId !== list.id ? (
|
const indicator = draggingListId && dropTargetListIndex === listIdx && draggingListId !== list.id ? (
|
||||||
@ -6465,6 +6658,27 @@ export default function WeeklyView() {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{listToDelete === list.id ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", width: "100%", gap: "8px", padding: "4px" }}>
|
||||||
|
<span style={{ fontSize: "0.9rem", fontWeight: "bold" }}>Delete this list?</span>
|
||||||
|
{list.externalProvider && <span style={{ fontSize: "0.75rem", color: "#888" }}>Note: This list is not deleted from {list.externalProvider}, just from this view.</span>}
|
||||||
|
<div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
|
||||||
|
<button onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setListToDelete(null);
|
||||||
|
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#eee", color: "#333", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Cancel</button>
|
||||||
|
<button onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await fetch(`/api/someday-lists?id=${list.id}`, { method: "DELETE" });
|
||||||
|
setSomedayLists((prev) => prev.filter((l) => l.id !== list.id));
|
||||||
|
setListToDelete(null);
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
}} style={{ padding: "4px 8px", borderRadius: "4px", backgroundColor: "#dc2626", color: "#fff", border: "none", cursor: "pointer", fontSize: "0.8rem" }}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className="someday-drag-handle"
|
className="someday-drag-handle"
|
||||||
title="Drag to reorder"
|
title="Drag to reorder"
|
||||||
@ -6508,10 +6722,14 @@ export default function WeeklyView() {
|
|||||||
if (e.key === "Enter") e.currentTarget.blur();
|
if (e.key === "Enter") e.currentTarget.blur();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{list.externalProvider && (
|
{list.externalProvider && (() => {
|
||||||
|
const providerUrl = list.externalProvider === "google" ? "https://tasks.google.com/tasks/" :
|
||||||
|
list.externalProvider === "outlook" ? "https://to-do.live.com/tasks/" :
|
||||||
|
list.externalProvider === "apple" ? "https://www.icloud.com/reminders/" : null;
|
||||||
|
const iconContent = (
|
||||||
<span
|
<span
|
||||||
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider}`}
|
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider}`}
|
||||||
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0 }}
|
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0, cursor: providerUrl ? "pointer" : "default" }}
|
||||||
>
|
>
|
||||||
{list.externalProvider === "outlook" ? (
|
{list.externalProvider === "outlook" ? (
|
||||||
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#0078D4] dark:hover:text-[#00A4EF] transition-colors" />
|
<FontAwesomeIcon icon={faMicrosoft} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#0078D4] dark:hover:text-[#00A4EF] transition-colors" />
|
||||||
@ -6525,50 +6743,77 @@ export default function WeeklyView() {
|
|||||||
<RefreshCcw size={14} className="text-zinc-400" />
|
<RefreshCcw size={14} className="text-zinc-400" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
);
|
||||||
|
return providerUrl ? (
|
||||||
|
<a href={providerUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{iconContent}
|
||||||
|
</a>
|
||||||
|
) : iconContent;
|
||||||
|
})()}
|
||||||
|
<div className="someday-tab-assign" style={{ marginLeft: "auto", position: "relative", display: "flex", alignItems: "center" }}>
|
||||||
|
<Tag size={13} style={{ color: list.tab ? "var(--weekly-accent, #6366f1)" : "#bbb", flexShrink: 0 }} />
|
||||||
|
<select
|
||||||
|
className="someday-tab-select"
|
||||||
|
value={list.tab || ""}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === "__new__") {
|
||||||
|
const name = prompt(t.newTabName || "New tab name:");
|
||||||
|
if (name?.trim()) assignListToTab(list.id, name.trim());
|
||||||
|
} else {
|
||||||
|
assignListToTab(list.id, val || null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
opacity: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
title={t.assignTab || "Assign to tab"}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{somedayTabs.map(tab => (
|
||||||
|
<option key={tab} value={tab}>{tab}</option>
|
||||||
|
))}
|
||||||
|
<option value="__new__">+ {t.newTab || "New tab"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="someday-list-delete-btn"
|
className="someday-list-delete-btn"
|
||||||
onClick={async (e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirm("Delete this list?")) {
|
setListToDelete(list.id);
|
||||||
try {
|
|
||||||
await fetch(
|
|
||||||
`/api/someday-lists?id=${list.id}`,
|
|
||||||
{ method: "DELETE" },
|
|
||||||
);
|
|
||||||
setSomedayLists((prev) =>
|
|
||||||
prev.filter((l) => l.id !== list.id),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: "none",
|
border: "none",
|
||||||
background: "none",
|
background: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "1rem",
|
|
||||||
color: "#ccc",
|
color: "#ccc",
|
||||||
marginLeft: "auto",
|
marginLeft: "4px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
padding: "4px"
|
||||||
}}
|
}}
|
||||||
title="Delete List"
|
title="Delete List"
|
||||||
>
|
>
|
||||||
×
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="weekly-task-list"
|
className="weekly-task-list"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
@ -9165,7 +9410,7 @@ function SettingsSidebar({
|
|||||||
|
|
||||||
setShowAppleCalendarModal(false);
|
setShowAppleCalendarModal(false);
|
||||||
showConnMsg("success", "Apple Calendar connected successfully!");
|
showConnMsg("success", "Apple Calendar connected successfully!");
|
||||||
setTimeout(() => window.location.reload(), 1200);
|
setTimeout(() => { window.location.href = window.location.pathname + "?calendar=apple_connected&openSettings=calendars"; }, 1200);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setAppleCalError(err.message || "Connection failed");
|
setAppleCalError(err.message || "Connection failed");
|
||||||
} finally {
|
} finally {
|
||||||
@ -9210,7 +9455,7 @@ function SettingsSidebar({
|
|||||||
|
|
||||||
setShowSynologyCalendarModal(false);
|
setShowSynologyCalendarModal(false);
|
||||||
showConnMsg("success", "Synology Calendar connected successfully!");
|
showConnMsg("success", "Synology Calendar connected successfully!");
|
||||||
setTimeout(() => window.location.reload(), 1200);
|
setTimeout(() => { window.location.href = window.location.pathname + "?calendar=synology_connected&openSettings=calendars"; }, 1200);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setSynologyCalError(err.message || "Connection failed");
|
setSynologyCalError(err.message || "Connection failed");
|
||||||
} finally {
|
} finally {
|
||||||
@ -9498,6 +9743,7 @@ function SettingsSidebar({
|
|||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
gap: "4px",
|
gap: "4px",
|
||||||
borderBottom: "1px solid var(--weekly-border, #eee)",
|
borderBottom: "1px solid var(--weekly-border, #eee)",
|
||||||
padding: "0 24px",
|
padding: "0 24px",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user