feat: add weather display, projects tab, iPhone today fix, stale calendar fix
- Add weather feature with Open-Meteo API: hourly temp/icon overlay on time slots, location search via geocoding, weather toggle in settings - Move project management to dedicated settings tab with emoji icon picker - Fix iPhone single-day view starting on yesterday instead of today - Fix deleted Apple calendar events persisting until app reboot by scheduling a delayed re-fetch after stale background sync v1.44.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a20f6673ec
commit
36c4652f8e
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.43.2",
|
"version": "1.44.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": {
|
||||||
|
|||||||
@ -110,6 +110,10 @@ model User {
|
|||||||
calendarConnections CalendarConnection[]
|
calendarConnections CalendarConnection[]
|
||||||
projects Project[]
|
projects Project[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
weatherEnabled Boolean @default(false)
|
||||||
|
weatherLat Float?
|
||||||
|
weatherLon Float?
|
||||||
|
weatherLocation String?
|
||||||
notificationsEnabled Boolean @default(false)
|
notificationsEnabled Boolean @default(false)
|
||||||
somedayLists SomedayList[]
|
somedayLists SomedayList[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
|||||||
@ -97,6 +97,10 @@ export async function GET(request: NextRequest) {
|
|||||||
darkTheme: true,
|
darkTheme: true,
|
||||||
notificationsEnabled: true,
|
notificationsEnabled: true,
|
||||||
mobileFontScale: true,
|
mobileFontScale: true,
|
||||||
|
weatherEnabled: true,
|
||||||
|
weatherLat: true,
|
||||||
|
weatherLon: true,
|
||||||
|
weatherLocation: true,
|
||||||
createdAt: true
|
createdAt: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -139,7 +143,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
||||||
showTaskCheckboxes, dayHeaderGap,
|
showTaskCheckboxes, dayHeaderGap,
|
||||||
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
|
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
|
||||||
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale
|
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale,
|
||||||
|
weatherEnabled, weatherLat, weatherLon, weatherLocation
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
@ -225,6 +230,10 @@ export async function PATCH(request: NextRequest) {
|
|||||||
...(darkTheme !== undefined && { darkTheme }),
|
...(darkTheme !== undefined && { darkTheme }),
|
||||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||||
...(mobileFontScale !== undefined && { mobileFontScale: parseFloat(mobileFontScale) || 1.0 }),
|
...(mobileFontScale !== undefined && { mobileFontScale: parseFloat(mobileFontScale) || 1.0 }),
|
||||||
|
...(weatherEnabled !== undefined && { weatherEnabled }),
|
||||||
|
...(weatherLat !== undefined && { weatherLat: weatherLat !== null ? parseFloat(weatherLat) : null }),
|
||||||
|
...(weatherLon !== undefined && { weatherLon: weatherLon !== null ? parseFloat(weatherLon) : null }),
|
||||||
|
...(weatherLocation !== undefined && { weatherLocation }),
|
||||||
};
|
};
|
||||||
if (password && password.trim() !== "") {
|
if (password && password.trim() !== "") {
|
||||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||||
@ -319,6 +328,10 @@ export async function PATCH(request: NextRequest) {
|
|||||||
darkTheme: true,
|
darkTheme: true,
|
||||||
notificationsEnabled: true,
|
notificationsEnabled: true,
|
||||||
mobileFontScale: true,
|
mobileFontScale: true,
|
||||||
|
weatherEnabled: true,
|
||||||
|
weatherLat: true,
|
||||||
|
weatherLon: true,
|
||||||
|
weatherLocation: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
28
src/app/api/weather/geocode/route.ts
Normal file
28
src/app/api/weather/geocode/route.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const query = searchParams.get('q');
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
return NextResponse.json({ results: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=5&language=en&format=json`
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
const results = (data.results || []).map((r: any) => ({
|
||||||
|
name: r.name,
|
||||||
|
country: r.country,
|
||||||
|
admin1: r.admin1,
|
||||||
|
lat: r.latitude,
|
||||||
|
lon: r.longitude,
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ results });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ results: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/weather/route.ts
Normal file
67
src/app/api/weather/route.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
// In-memory cache: { key: { data, fetchedAt } }
|
||||||
|
const weatherCache = new Map<string, { data: any; fetchedAt: number }>();
|
||||||
|
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { weatherEnabled: true, weatherLat: true, weatherLon: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.weatherEnabled || !user.weatherLat || !user.weatherLon) {
|
||||||
|
return NextResponse.json({ error: 'Weather not configured' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const startDate = searchParams.get('start') || new Date().toISOString().slice(0, 10);
|
||||||
|
const endDate = searchParams.get('end') || startDate;
|
||||||
|
|
||||||
|
const cacheKey = `${user.weatherLat},${user.weatherLon},${startDate},${endDate}`;
|
||||||
|
const cached = weatherCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||||
|
return NextResponse.json(cached.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${user.weatherLat}&longitude=${user.weatherLon}&hourly=temperature_2m,weather_code&start_date=${startDate}&end_date=${endDate}&timezone=auto`;
|
||||||
|
const res = await fetch(url, { next: { revalidate: 900 } });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: 'Weather API failed' }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await res.json();
|
||||||
|
|
||||||
|
// Transform into { "2026-03-17T08:00": { temp: 5, code: 2 }, ... }
|
||||||
|
const hourly: Record<string, { temp: number; code: number }> = {};
|
||||||
|
if (raw.hourly?.time && raw.hourly?.temperature_2m && raw.hourly?.weather_code) {
|
||||||
|
for (let i = 0; i < raw.hourly.time.length; i++) {
|
||||||
|
hourly[raw.hourly.time[i]] = {
|
||||||
|
temp: Math.round(raw.hourly.temperature_2m[i]),
|
||||||
|
code: raw.hourly.weather_code[i],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { hourly, timezone: raw.timezone };
|
||||||
|
weatherCache.set(cacheKey, { data, fetchedAt: Date.now() });
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WEATHER] Fetch failed:', err);
|
||||||
|
return NextResponse.json({ error: 'Weather fetch failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1560,6 +1560,9 @@ export default function WeeklyView() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Weather data: { "2026-03-17T08:00": { temp: 5, code: 2 }, ... }
|
||||||
|
const [weatherData, setWeatherData] = useState<Record<string, { temp: number; code: number }>>({});
|
||||||
|
|
||||||
// Extend events with editable flag from connections
|
// Extend events with editable flag from connections
|
||||||
const calendarEvents = useMemo(() => {
|
const calendarEvents = useMemo(() => {
|
||||||
return rawCalendarEvents.map((event) => {
|
return rawCalendarEvents.map((event) => {
|
||||||
@ -1804,7 +1807,7 @@ export default function WeeklyView() {
|
|||||||
// Moved state definitions to the top
|
// Moved state definitions to the top
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"calendar" | "general" | "account" | "styling" | "motivation" | "about"
|
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "projects"
|
||||||
>("general");
|
>("general");
|
||||||
const [exportStartDate, setExportStartDate] = useState("");
|
const [exportStartDate, setExportStartDate] = useState("");
|
||||||
const [exportEndDate, setExportEndDate] = useState("");
|
const [exportEndDate, setExportEndDate] = useState("");
|
||||||
@ -1898,6 +1901,10 @@ export default function WeeklyView() {
|
|||||||
quoteSourceUrl: "",
|
quoteSourceUrl: "",
|
||||||
quoteSourceUrls: [],
|
quoteSourceUrls: [],
|
||||||
quoteLanguages: ["en", "de"],
|
quoteLanguages: ["en", "de"],
|
||||||
|
weatherEnabled: false,
|
||||||
|
weatherLat: null,
|
||||||
|
weatherLon: null,
|
||||||
|
weatherLocation: "",
|
||||||
});
|
});
|
||||||
const [motivationalQuote, setMotivationalQuote] = useState("");
|
const [motivationalQuote, setMotivationalQuote] = useState("");
|
||||||
const [showSummary, setShowSummary] = useState(false);
|
const [showSummary, setShowSummary] = useState(false);
|
||||||
@ -2278,6 +2285,22 @@ export default function WeeklyView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Slot and Header height based on cell duration
|
// Slot and Header height based on cell duration
|
||||||
|
// WMO weather code → emoji icon
|
||||||
|
const getWeatherIcon = (code: number): string => {
|
||||||
|
if (code === 0) return "☀️";
|
||||||
|
if (code <= 3) return "⛅";
|
||||||
|
if (code >= 45 && code <= 48) return "🌫️";
|
||||||
|
if (code >= 51 && code <= 55) return "🌦️";
|
||||||
|
if (code >= 56 && code <= 57) return "🌧️";
|
||||||
|
if (code >= 61 && code <= 65) return "🌧️";
|
||||||
|
if (code >= 66 && code <= 67) return "🌨️";
|
||||||
|
if (code >= 71 && code <= 77) return "❄️";
|
||||||
|
if (code >= 80 && code <= 82) return "🌧️";
|
||||||
|
if (code >= 85 && code <= 86) return "❄️";
|
||||||
|
if (code >= 95) return "⛈️";
|
||||||
|
return "☁️";
|
||||||
|
};
|
||||||
|
|
||||||
const getSlotHeight = (duration: number) => {
|
const getSlotHeight = (duration: number) => {
|
||||||
switch (duration) {
|
switch (duration) {
|
||||||
case 15: return 25;
|
case 15: return 25;
|
||||||
@ -2329,6 +2352,21 @@ export default function WeeklyView() {
|
|||||||
if (data.events) {
|
if (data.events) {
|
||||||
setRawCalendarEvents(data.events);
|
setRawCalendarEvents(data.events);
|
||||||
}
|
}
|
||||||
|
// If stale connections were refreshing in background, re-fetch after they finish
|
||||||
|
if (data.staleConnectionCount > 0 && !forceRefresh) {
|
||||||
|
setTimeout(() => {
|
||||||
|
fetch("/api/calendar/sync", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
timeMin: new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
timeMax: new Date(currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
}),
|
||||||
|
}).then(r => r.json()).then(d => {
|
||||||
|
if (d.events) setRawCalendarEvents(d.events);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, 5000); // 5s delay for background refresh to finish
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
"Failed to parse calendar sync response:",
|
"Failed to parse calendar sync response:",
|
||||||
@ -2344,6 +2382,26 @@ export default function WeeklyView() {
|
|||||||
}
|
}
|
||||||
}, [currentWeekStart, startSync, endSync]);
|
}, [currentWeekStart, startSync, endSync]);
|
||||||
|
|
||||||
|
// Weather fetch
|
||||||
|
const fetchWeather = useCallback(async () => {
|
||||||
|
if (!profile.weatherEnabled) return;
|
||||||
|
try {
|
||||||
|
const start = new Date(currentWeekStart.getTime() - 1 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||||
|
const end = new Date(currentWeekStart.getTime() + 8 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||||
|
const res = await fetch(`/api/weather?start=${start}&end=${end}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.hourly) setWeatherData(data.hourly);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Weather fetch failed:", e);
|
||||||
|
}
|
||||||
|
}, [currentWeekStart, profile.weatherEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile.weatherEnabled) fetchWeather();
|
||||||
|
}, [fetchWeather, profile.weatherEnabled]);
|
||||||
|
|
||||||
// Calendar Event Handlers
|
// Calendar Event Handlers
|
||||||
const handleEventSave = async (eventData: any) => {
|
const handleEventSave = async (eventData: any) => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@ -2985,8 +3043,16 @@ export default function WeeklyView() {
|
|||||||
todayHighlightColor: data.user.todayHighlightColor || "#f0fafa",
|
todayHighlightColor: data.user.todayHighlightColor || "#f0fafa",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Apply start day offset (e.g. -1 for yesterday)
|
// Apply start day offset (e.g. -1 for yesterday) — only for multi-day views
|
||||||
if (data.user.startDayOffset && data.user.startDayOffset !== 0) {
|
// On single-day view (phones), always start on today
|
||||||
|
const effectiveViewDays = (() => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
if (width <= 480) return 1;
|
||||||
|
if (width <= 768) return 3;
|
||||||
|
if (width <= 1024) return Math.min(data.user.viewDays || 7, 5);
|
||||||
|
return data.user.viewDays || 7;
|
||||||
|
})();
|
||||||
|
if (data.user.startDayOffset && data.user.startDayOffset !== 0 && effectiveViewDays > 1) {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(0, 0, 0, 0);
|
d.setHours(0, 0, 0, 0);
|
||||||
d.setDate(d.getDate() + data.user.startDayOffset);
|
d.setDate(d.getDate() + data.user.startDayOffset);
|
||||||
@ -6075,7 +6141,7 @@ export default function WeeklyView() {
|
|||||||
<button onClick={() => { const nv = !showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); setShowHeaderMore(false); }}>{showNextTask ? <Play size={16} /> : <Target size={16} />} <span>{showNextTask ? "Next Task" : "Goal"}</span></button>
|
<button onClick={() => { const nv = !showNextTask; setShowNextTask(nv); saveSetting("showNextTask", nv); setShowHeaderMore(false); }}>{showNextTask ? <Play size={16} /> : <Target size={16} />} <span>{showNextTask ? "Next Task" : "Goal"}</span></button>
|
||||||
<button onClick={() => { setShowFocusMode(true); setShowHeaderMore(false); }}><Zap size={16} /> <span>{language === "de" ? "Fokus" : "Focus"}</span></button>
|
<button onClick={() => { setShowFocusMode(true); setShowHeaderMore(false); }}><Zap size={16} /> <span>{language === "de" ? "Fokus" : "Focus"}</span></button>
|
||||||
<button onClick={() => { setDarkMode(!darkMode); setShowHeaderMore(false); }}>{darkMode ? <Sun size={16} /> : <Moon size={16} />} <span>{darkMode ? "Light" : "Dark"}</span></button>
|
<button onClick={() => { setDarkMode(!darkMode); setShowHeaderMore(false); }}>{darkMode ? <Sun size={16} /> : <Moon size={16} />} <span>{darkMode ? "Light" : "Dark"}</span></button>
|
||||||
<button onClick={() => { setShowSettings(true); setShowHeaderMore(false); }}><FolderPlus size={16} /> <span>{language === "de" ? "Neues Projekt" : "New Project"}</span></button>
|
<button onClick={() => { setShowSettings(true); setActiveTab("projects"); setShowHeaderMore(false); }}><FolderPlus size={16} /> <span>{language === "de" ? "Neues Projekt" : "New Project"}</span></button>
|
||||||
<div style={{ borderTop: "1px solid var(--border-color, #e5e7eb)", margin: "2px 0" }} />
|
<div style={{ borderTop: "1px solid var(--border-color, #e5e7eb)", margin: "2px 0" }} />
|
||||||
<div style={{ padding: "6px 12px", display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" }}>
|
<div style={{ padding: "6px 12px", display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" }}>
|
||||||
<span style={{ fontSize: "12px", color: "#888", marginRight: "4px" }}>{language === "de" ? "Tage" : "Days"}:</span>
|
<span style={{ fontSize: "12px", color: "#888", marginRight: "4px" }}>{language === "de" ? "Tage" : "Days"}:</span>
|
||||||
@ -6110,7 +6176,7 @@ export default function WeeklyView() {
|
|||||||
|
|
||||||
{/* Desktop only: Recurring Tasks + New Project */}
|
{/* Desktop only: Recurring Tasks + New Project */}
|
||||||
<button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button>
|
<button className="weekly-btn-icon header-desktop-only" onClick={() => setIsRecurringTasksOpen(true)} title="Recurring Tasks"><Repeat size={17} /></button>
|
||||||
<button className="weekly-btn-icon header-desktop-only" onClick={() => setShowSettings(true)} title="New Project"><FolderPlus size={17} /></button>
|
<button className="weekly-btn-icon header-desktop-only" onClick={() => { setShowSettings(true); setActiveTab("projects"); }} title="New Project"><FolderPlus size={17} /></button>
|
||||||
|
|
||||||
{/* User Menu */}
|
{/* User Menu */}
|
||||||
<UserMenu
|
<UserMenu
|
||||||
@ -6775,6 +6841,20 @@ export default function WeeklyView() {
|
|||||||
}
|
}
|
||||||
onDrop={handleSlotDrop}
|
onDrop={handleSlotDrop}
|
||||||
>
|
>
|
||||||
|
{/* Weather indicator for hour-start slots */}
|
||||||
|
{isHourStart && profile.weatherEnabled && (() => {
|
||||||
|
const h = parseInt(slot.split(":")[0]);
|
||||||
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(h).padStart(2, "0")}:00`;
|
||||||
|
const w = weatherData[dateStr];
|
||||||
|
if (!w) return null;
|
||||||
|
const icon = getWeatherIcon(w.code);
|
||||||
|
return (
|
||||||
|
<div style={{ position: "absolute", top: "1px", right: "3px", display: "flex", alignItems: "center", gap: "2px", fontSize: "10px", opacity: 0.6, pointerEvents: "none", zIndex: 1, lineHeight: 1 }}>
|
||||||
|
<span>{icon}</span>
|
||||||
|
<span style={{ fontWeight: 500 }}>{w.temp}°</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{/* Drop preview indicator */}
|
{/* Drop preview indicator */}
|
||||||
{isDropTarget && !isProtected && !isOccupiedByTask && (
|
{isDropTarget && !isProtected && !isOccupiedByTask && (
|
||||||
<div className="drop-preview" />
|
<div className="drop-preview" />
|
||||||
@ -8235,7 +8315,7 @@ export default function WeeklyView() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` });
|
setCalendarEventModal({ isOpen: true, event: undefined, initialDate: now, initialStartTime: `${String(now.getHours()).padStart(2, "0")}:00` });
|
||||||
},
|
},
|
||||||
onAddProject: () => { setShowSettings(true); setActiveTab("general"); },
|
onAddProject: () => { setShowSettings(true); setActiveTab("projects"); },
|
||||||
onRecurringTasks: () => setIsRecurringTasksOpen(true),
|
onRecurringTasks: () => setIsRecurringTasksOpen(true),
|
||||||
onToggleNextTask: () => { const newVal = !showNextTask; setShowNextTask(newVal); saveSetting("showNextTask", newVal); },
|
onToggleNextTask: () => { const newVal = !showNextTask; setShowNextTask(newVal); saveSetting("showNextTask", newVal); },
|
||||||
onFocusMode: () => setShowFocusMode(true),
|
onFocusMode: () => setShowFocusMode(true),
|
||||||
@ -8321,7 +8401,7 @@ export default function WeeklyView() {
|
|||||||
<div className="mobile-fab-menu-icon" style={{ background: "#10b981" }}><Search size={18} /></div>
|
<div className="mobile-fab-menu-icon" style={{ background: "#10b981" }}><Search size={18} /></div>
|
||||||
<span>{language === "de" ? "Suche" : "Search"}</span>
|
<span>{language === "de" ? "Suche" : "Search"}</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="mobile-fab-menu-item" onClick={() => { setShowMobileFabMenu(false); setShowSettings(true); }}>
|
<button className="mobile-fab-menu-item" onClick={() => { setShowMobileFabMenu(false); setShowSettings(true); setActiveTab("projects"); }}>
|
||||||
<div className="mobile-fab-menu-icon" style={{ background: "#6366f1" }}><FolderPlus size={18} /></div>
|
<div className="mobile-fab-menu-icon" style={{ background: "#6366f1" }}><FolderPlus size={18} /></div>
|
||||||
<span>{language === "de" ? "Projekt" : "Project"}</span>
|
<span>{language === "de" ? "Projekt" : "Project"}</span>
|
||||||
</button>
|
</button>
|
||||||
@ -9559,7 +9639,7 @@ interface SettingsSidebarProps {
|
|||||||
fetchAvailableTaskLists: (
|
fetchAvailableTaskLists: (
|
||||||
provider: "google" | "apple" | "outlook" | "synology",
|
provider: "google" | "apple" | "outlook" | "synology",
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about";
|
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about" | "projects";
|
||||||
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
||||||
onProjectsChanged: () => void;
|
onProjectsChanged: () => void;
|
||||||
kanbanStages: KanbanStage[];
|
kanbanStages: KanbanStage[];
|
||||||
@ -9825,7 +9905,7 @@ function SettingsSidebar({
|
|||||||
mobileActions,
|
mobileActions,
|
||||||
}: SettingsSidebarProps) {
|
}: SettingsSidebarProps) {
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
|
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation" | "projects"
|
||||||
>(initialTab || "general");
|
>(initialTab || "general");
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
@ -9868,6 +9948,7 @@ function SettingsSidebar({
|
|||||||
const [editProjectColor, setEditProjectColor] = useState("");
|
const [editProjectColor, setEditProjectColor] = useState("");
|
||||||
const [editProjectIcon, setEditProjectIcon] = useState("");
|
const [editProjectIcon, setEditProjectIcon] = useState("");
|
||||||
const [showEditProjectIconPicker, setShowEditProjectIconPicker] = useState(false);
|
const [showEditProjectIconPicker, setShowEditProjectIconPicker] = useState(false);
|
||||||
|
const [weatherSearchResults, setWeatherSearchResults] = useState<any[]>([]);
|
||||||
|
|
||||||
const projectEmojis = [
|
const projectEmojis = [
|
||||||
"📁", "📂", "💼", "🎯", "🚀", "⭐", "💡", "🔥", "🎨", "🎵",
|
"📁", "📂", "💼", "🎯", "🚀", "⭐", "💡", "🔥", "🎨", "🎵",
|
||||||
@ -10494,6 +10575,7 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
{([
|
{([
|
||||||
{ key: "general", icon: <Settings size={18} />, label: t.general },
|
{ key: "general", icon: <Settings size={18} />, label: t.general },
|
||||||
|
{ key: "projects", icon: <FolderOpen size={18} />, label: t.projects },
|
||||||
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation },
|
{ key: "localisation", icon: <Globe size={18} />, label: t.localisation },
|
||||||
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
|
{ key: "calendar", icon: <Link size={18} />, label: t.calendar },
|
||||||
{ key: "account", icon: <User size={18} />, label: t.account },
|
{ key: "account", icon: <User size={18} />, label: t.account },
|
||||||
@ -11100,187 +11182,72 @@ function SettingsSidebar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Projects Section */}
|
{/* Weather Settings */}
|
||||||
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
|
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
|
||||||
<h4 style={{ fontSize: "0.95rem", fontWeight: 700, marginBottom: "8px", display: "flex", alignItems: "center", gap: "6px" }}>
|
<h4 style={{ fontSize: "0.95rem", fontWeight: 700, marginBottom: "8px", display: "flex", alignItems: "center", gap: "6px" }}>
|
||||||
<FolderOpen size={16} /> {t.projects}
|
☀️ {profile.language === "de" ? "Wetter" : "Weather"}
|
||||||
</h4>
|
</h4>
|
||||||
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "12px" }}>{t.projectsDesc}</p>
|
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "12px" }}>
|
||||||
{projects.length === 0 && (
|
{profile.language === "de" ? "Zeige Temperatur und Wettericons im Stundenraster." : "Show temperature and weather icons in the time grid."}
|
||||||
<p style={{ fontSize: "0.85rem", color: "#aaa", fontStyle: "italic", marginBottom: "8px" }}>{t.noProjects}</p>
|
</p>
|
||||||
)}
|
<label style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "12px", cursor: "pointer" }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
|
<input
|
||||||
{projects.map((p) => (
|
type="checkbox"
|
||||||
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "8px 12px", borderRadius: "10px", background: "var(--bg-secondary, #f9fafb)", borderLeft: `3px solid ${p.color || "#999"}` }}>
|
checked={profile.weatherEnabled || false}
|
||||||
{editingProjectId === p.id ? (
|
onChange={(e) => {
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
|
setProfile({ ...profile, weatherEnabled: e.target.checked });
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
saveSetting("weatherEnabled", e.target.checked);
|
||||||
<div style={{ position: "relative" }}>
|
}}
|
||||||
<button
|
style={{ width: "18px", height: "18px" }}
|
||||||
onClick={() => setShowEditProjectIconPicker(!showEditProjectIconPicker)}
|
/>
|
||||||
style={{ width: "36px", height: "36px", borderRadius: "8px", border: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-primary, #fff)", cursor: "pointer", fontSize: "18px", display: "flex", alignItems: "center", justifyContent: "center" }}
|
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>{profile.language === "de" ? "Wetter anzeigen" : "Show weather"}</span>
|
||||||
title="Change icon"
|
</label>
|
||||||
>
|
{profile.weatherEnabled && (
|
||||||
{editProjectIcon || "📁"}
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
</button>
|
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||||
{showEditProjectIconPicker && (
|
<input
|
||||||
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50, background: "var(--bg-primary, #fff)", border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "10px", padding: "8px", boxShadow: "0 8px 24px rgba(0,0,0,0.12)", width: "220px" }}>
|
type="text"
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px" }}>
|
placeholder={profile.language === "de" ? "Stadt suchen..." : "Search city..."}
|
||||||
{projectEmojis.map((emoji) => (
|
className="weekly-input"
|
||||||
<button
|
style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }}
|
||||||
key={emoji}
|
onChange={async (e) => {
|
||||||
onClick={() => { setEditProjectIcon(emoji); setShowEditProjectIconPicker(false); }}
|
const q = e.target.value;
|
||||||
style={{ width: "24px", height: "24px", border: "none", background: editProjectIcon === emoji ? "var(--bg-secondary, #f3f4f6)" : "transparent", borderRadius: "4px", cursor: "pointer", fontSize: "14px", display: "flex", alignItems: "center", justifyContent: "center" }}
|
if (q.length < 2) { setWeatherSearchResults([]); return; }
|
||||||
>
|
try {
|
||||||
{emoji}
|
const res = await fetch(`/api/weather/geocode?q=${encodeURIComponent(q)}`);
|
||||||
</button>
|
const data = await res.json();
|
||||||
))}
|
setWeatherSearchResults(data.results || []);
|
||||||
</div>
|
} catch { setWeatherSearchResults([]); }
|
||||||
</div>
|
}}
|
||||||
)}
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
{weatherSearchResults.length > 0 && (
|
||||||
type="text"
|
<div style={{ border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "8px", overflow: "hidden" }}>
|
||||||
value={editProjectName}
|
{weatherSearchResults.map((r: any, i: number) => (
|
||||||
onChange={(e) => setEditProjectName(e.target.value)}
|
|
||||||
className="weekly-input"
|
|
||||||
style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
fetch("/api/projects", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }),
|
|
||||||
}).then(() => { onProjectsChanged(); setEditingProjectId(null); });
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") setEditingProjectId(null);
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={editProjectColor}
|
|
||||||
onChange={(e) => setEditProjectColor(e.target.value)}
|
|
||||||
style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: "0.75rem", color: "#888" }}>{profile.language === "de" ? "Farbe" : "Color"}</span>
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingProjectId(null)}
|
|
||||||
style={{ padding: "4px 10px", fontSize: "0.8rem", background: "none", border: "1px solid var(--border-color, #ddd)", borderRadius: "6px", cursor: "pointer", color: "var(--text-secondary, #666)" }}
|
|
||||||
>
|
|
||||||
{profile.language === "de" ? "Abbrechen" : "Cancel"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
fetch("/api/projects", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }),
|
|
||||||
}).then(() => { onProjectsChanged(); setEditingProjectId(null); });
|
|
||||||
}}
|
|
||||||
className="weekly-btn-primary"
|
|
||||||
style={{ padding: "4px 12px", fontSize: "0.8rem" }}
|
|
||||||
>
|
|
||||||
<Check size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span style={{ fontSize: "18px", lineHeight: 1 }}>{p.icon || "📁"}</span>
|
|
||||||
<span style={{ flex: 1, fontSize: "0.85rem", fontWeight: 600 }}>{p.name}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => { setEditingProjectId(p.id); setEditProjectName(p.name); setEditProjectColor(p.color || "#999"); setEditProjectIcon(p.icon || "📁"); setShowEditProjectIconPicker(false); }}
|
|
||||||
style={{ padding: "4px", opacity: 0.5, cursor: "pointer", background: "none", border: "none", borderRadius: "4px" }}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Pencil size={13} />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
|
key={i}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(profile.language === "de" ? `Projekt "${p.name}" löschen?` : `Delete project "${p.name}"?`)) {
|
setProfile({ ...profile, weatherLat: r.lat, weatherLon: r.lon, weatherLocation: `${r.name}, ${r.country}` });
|
||||||
fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged());
|
saveSetting("weatherLat", r.lat);
|
||||||
}
|
saveSetting("weatherLon", r.lon);
|
||||||
|
saveSetting("weatherLocation", `${r.name}, ${r.country}`);
|
||||||
|
setWeatherSearchResults([]);
|
||||||
}}
|
}}
|
||||||
style={{ padding: "4px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "4px" }}
|
style={{ display: "block", width: "100%", padding: "8px 12px", textAlign: "left", border: "none", borderBottom: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-secondary, #f9fafb)", cursor: "pointer", fontSize: "0.85rem" }}
|
||||||
title="Delete"
|
|
||||||
>
|
>
|
||||||
<Trash2 size={13} />
|
{r.name}{r.admin1 ? `, ${r.admin1}` : ""}, {r.country} <span style={{ color: "#888", fontSize: "0.75rem" }}>({r.lat.toFixed(2)}, {r.lon.toFixed(2)})</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
{profile.weatherLocation && (
|
||||||
</div>
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 12px", borderRadius: "8px", background: "var(--bg-secondary, #f9fafb)" }}>
|
||||||
{/* Add new project */}
|
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>📍 {profile.weatherLocation}</span>
|
||||||
<div style={{ padding: "10px 12px", borderRadius: "10px", border: "1px dashed var(--border-color, #d1d5db)", background: "var(--bg-secondary, #f9fafb)" }}>
|
<span style={{ fontSize: "0.75rem", color: "#888" }}>({profile.weatherLat?.toFixed(2)}, {profile.weatherLon?.toFixed(2)})</span>
|
||||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
</div>
|
||||||
<div style={{ position: "relative" }}>
|
)}
|
||||||
<button
|
|
||||||
onClick={() => setShowNewProjectIconPicker(!showNewProjectIconPicker)}
|
|
||||||
style={{ width: "36px", height: "36px", borderRadius: "8px", border: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-primary, #fff)", cursor: "pointer", fontSize: "18px", display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
||||||
title="Choose icon"
|
|
||||||
>
|
|
||||||
{newProjectIcon}
|
|
||||||
</button>
|
|
||||||
{showNewProjectIconPicker && (
|
|
||||||
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50, background: "var(--bg-primary, #fff)", border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "10px", padding: "8px", boxShadow: "0 8px 24px rgba(0,0,0,0.12)", width: "220px" }}>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "2px" }}>
|
|
||||||
{projectEmojis.map((emoji) => (
|
|
||||||
<button
|
|
||||||
key={emoji}
|
|
||||||
onClick={() => { setNewProjectIcon(emoji); setShowNewProjectIconPicker(false); }}
|
|
||||||
style={{ width: "24px", height: "24px", border: "none", background: newProjectIcon === emoji ? "var(--bg-secondary, #f3f4f6)" : "transparent", borderRadius: "4px", cursor: "pointer", fontSize: "14px", display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
||||||
>
|
|
||||||
{emoji}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={newProjectColor}
|
|
||||||
onChange={(e) => setNewProjectColor(e.target.value)}
|
|
||||||
style={{ width: "28px", height: "28px", border: "none", cursor: "pointer", padding: 0, borderRadius: "6px" }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newProjectName}
|
|
||||||
onChange={(e) => setNewProjectName(e.target.value)}
|
|
||||||
placeholder={t.projectName}
|
|
||||||
className="weekly-input"
|
|
||||||
style={{ flex: 1, padding: "6px 10px", fontSize: "0.85rem" }}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && newProjectName.trim()) {
|
|
||||||
fetch("/api/projects", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }),
|
|
||||||
}).then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("📁"); });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (!newProjectName.trim()) return;
|
|
||||||
fetch("/api/projects", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }),
|
|
||||||
}).then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("📁"); });
|
|
||||||
}}
|
|
||||||
className="weekly-btn-primary"
|
|
||||||
style={{ padding: "6px 12px", fontSize: "0.8rem", whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
<Plus size={14} /> {t.addProject}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -11559,6 +11526,125 @@ function SettingsSidebar({
|
|||||||
|
|
||||||
<div />
|
<div />
|
||||||
</div>
|
</div>
|
||||||
|
) : activeTab === "projects" ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: "1.1rem", fontWeight: 700, marginBottom: "4px", display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<FolderOpen size={20} /> {t.projects}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "16px" }}>{t.projectsDesc}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", padding: "32px 16px" }}>
|
||||||
|
<div style={{ width: "56px", height: "56px", borderRadius: "50%", background: "var(--bg-secondary, #f3f4f6)", display: "flex", alignItems: "center", justifyContent: "center", margin: "0 auto 12px", fontSize: "24px" }}>📁</div>
|
||||||
|
<p style={{ fontSize: "0.9rem", color: "#aaa", fontStyle: "italic" }}>{t.noProjects}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "10px 14px", borderRadius: "12px", background: "var(--bg-secondary, #f9fafb)", borderLeft: `4px solid ${p.color || "#999"}`, transition: "box-shadow 0.15s" }}>
|
||||||
|
{editingProjectId === p.id ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEditProjectIconPicker(!showEditProjectIconPicker)}
|
||||||
|
style={{ width: "40px", height: "40px", borderRadius: "10px", border: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-primary, #fff)", cursor: "pointer", fontSize: "20px", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||||
|
title="Change icon"
|
||||||
|
>
|
||||||
|
{editProjectIcon || "📁"}
|
||||||
|
</button>
|
||||||
|
{showEditProjectIconPicker && (
|
||||||
|
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50, background: "var(--bg-primary, #fff)", border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "12px", padding: "10px", boxShadow: "0 8px 24px rgba(0,0,0,0.12)", width: "240px" }}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "4px" }}>
|
||||||
|
{projectEmojis.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
onClick={() => { setEditProjectIcon(emoji); setShowEditProjectIconPicker(false); }}
|
||||||
|
style={{ width: "28px", height: "28px", border: "none", background: editProjectIcon === emoji ? "var(--bg-secondary, #f3f4f6)" : "transparent", borderRadius: "6px", cursor: "pointer", fontSize: "16px", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editProjectName}
|
||||||
|
onChange={(e) => setEditProjectName(e.target.value)}
|
||||||
|
className="weekly-input"
|
||||||
|
style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }) }).then(() => { onProjectsChanged(); setEditingProjectId(null); });
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") setEditingProjectId(null);
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<input type="color" value={editProjectColor} onChange={(e) => setEditProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} />
|
||||||
|
<span style={{ fontSize: "0.8rem", color: "#888" }}>{profile.language === "de" ? "Farbe" : "Color"}</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button onClick={() => setEditingProjectId(null)} style={{ padding: "6px 14px", fontSize: "0.8rem", background: "none", border: "1px solid var(--border-color, #ddd)", borderRadius: "8px", cursor: "pointer", color: "var(--text-secondary, #666)" }}>
|
||||||
|
{profile.language === "de" ? "Abbrechen" : "Cancel"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { fetch("/api/projects", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: p.id, name: editProjectName, color: editProjectColor, icon: editProjectIcon }) }).then(() => { onProjectsChanged(); setEditingProjectId(null); }); }} className="weekly-btn-primary" style={{ padding: "6px 14px", fontSize: "0.8rem" }}>
|
||||||
|
<Check size={14} /> {profile.language === "de" ? "Speichern" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ fontSize: "22px", lineHeight: 1 }}>{p.icon || "📁"}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span style={{ fontSize: "0.9rem", fontWeight: 600, display: "block" }}>{p.name}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setEditingProjectId(p.id); setEditProjectName(p.name); setEditProjectColor(p.color || "#999"); setEditProjectIcon(p.icon || "📁"); setShowEditProjectIconPicker(false); }} style={{ padding: "6px", opacity: 0.5, cursor: "pointer", background: "none", border: "none", borderRadius: "6px" }} title="Edit">
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { if (confirm(profile.language === "de" ? `Projekt "${p.name}" löschen?` : `Delete project "${p.name}"?`)) { fetch(`/api/projects?id=${p.id}`, { method: "DELETE" }).then(() => onProjectsChanged()); } }} style={{ padding: "6px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none", borderRadius: "6px" }} title="Delete">
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add new project */}
|
||||||
|
<div style={{ padding: "14px", borderRadius: "12px", border: "2px dashed var(--border-color, #d1d5db)", background: "var(--bg-secondary, #f9fafb)" }}>
|
||||||
|
<p style={{ fontSize: "0.8rem", fontWeight: 600, color: "#888", marginBottom: "10px" }}>{t.addProject}</p>
|
||||||
|
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button onClick={() => setShowNewProjectIconPicker(!showNewProjectIconPicker)} style={{ width: "40px", height: "40px", borderRadius: "10px", border: "1px solid var(--border-color, #e5e7eb)", background: "var(--bg-primary, #fff)", cursor: "pointer", fontSize: "20px", display: "flex", alignItems: "center", justifyContent: "center" }} title="Choose icon">
|
||||||
|
{newProjectIcon}
|
||||||
|
</button>
|
||||||
|
{showNewProjectIconPicker && (
|
||||||
|
<div style={{ position: "absolute", top: "100%", left: 0, marginTop: "4px", zIndex: 50, background: "var(--bg-primary, #fff)", border: "1px solid var(--border-color, #e5e7eb)", borderRadius: "12px", padding: "10px", boxShadow: "0 8px 24px rgba(0,0,0,0.12)", width: "240px" }}>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: "4px" }}>
|
||||||
|
{projectEmojis.map((emoji) => (
|
||||||
|
<button key={emoji} onClick={() => { setNewProjectIcon(emoji); setShowNewProjectIconPicker(false); }} style={{ width: "28px", height: "28px", border: "none", background: newProjectIcon === emoji ? "var(--bg-secondary, #f3f4f6)" : "transparent", borderRadius: "6px", cursor: "pointer", fontSize: "16px", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input type="color" value={newProjectColor} onChange={(e) => setNewProjectColor(e.target.value)} style={{ width: "32px", height: "32px", border: "none", cursor: "pointer", padding: 0, borderRadius: "8px" }} />
|
||||||
|
<input type="text" value={newProjectName} onChange={(e) => setNewProjectName(e.target.value)} placeholder={t.projectName} className="weekly-input" style={{ flex: 1, padding: "8px 12px", fontSize: "0.9rem" }} onKeyDown={(e) => { if (e.key === "Enter" && newProjectName.trim()) { fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) }).then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("📁"); }); } }} />
|
||||||
|
<button onClick={() => { if (!newProjectName.trim()) return; fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newProjectName.trim(), color: newProjectColor, icon: newProjectIcon }) }).then(() => { onProjectsChanged(); setNewProjectName(""); setNewProjectIcon("📁"); }); }} className="weekly-btn-primary" style={{ padding: "8px 16px", fontSize: "0.85rem", whiteSpace: "nowrap" }}>
|
||||||
|
<Plus size={14} /> {t.addProject}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : activeTab === "calendar" ? (
|
) : activeTab === "calendar" ? (
|
||||||
isLoading ? (
|
isLoading ? (
|
||||||
<p>Loading connections...</p>
|
<p>Loading connections...</p>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user