feat: improve PDF export — header, layout, icons, tab filter
- Add page header per PDF page: KW + date range (headline font/accent
color) + motivational quote (right-aligned italic, from local quotes)
- Move all-day strip above weekday/date header to match webapp layout
- Increase font sizes across the board (task titles 7.5pt, time labels
7.5pt, day date 8pt) to better match the web view
- Remove time prefix from task rows (hour-row label already provides
context); completed tasks get strikethrough styling
- Calendar event chips in all-day strip now use solid calendar color +
white text + small dot indicator (replaces 📅 emoji, which Helvetica
cannot render in PDFs)
- Fix multi-day all-day events: expand CachedCalendarEvents across every
day they span (iCal endDate is exclusive); deduplicate by title to
prevent double-rendering when the same event exists in both Task and
CachedCalendarEvent tables
- Someday page is now landscape (matches main week pages)
- Print modal: fetch available someday tabs on open; show pill-checkbox
filter below "Aufgaben ohne Uhrzeit" toggle when multiple tabs exist;
selected tabs are passed to the export route as noTimeTabs param
- New API endpoint GET /api/user/someday-tabs for fetching tab list
- Export route filters someday lists by noTimeTabs when provided
v1.97.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1051dfd96c
commit
ac17ca2635
@ -19,12 +19,36 @@
|
|||||||
"Bash(PRISMA_HIDE_UPDATE_MESSAGE=1 npx prisma db push)",
|
"Bash(PRISMA_HIDE_UPDATE_MESSAGE=1 npx prisma db push)",
|
||||||
"Bash(npm run:*)",
|
"Bash(npm run:*)",
|
||||||
"WebFetch(domain:thequoteshub.com)",
|
"WebFetch(domain:thequoteshub.com)",
|
||||||
"WebFetch(domain:api.zitat-service.de)"
|
"WebFetch(domain:api.zitat-service.de)",
|
||||||
|
"Bash(ldd \"/home/sparky/.cache/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome\")",
|
||||||
|
"Bash(\"/home/sparky/.cache/puppeteer/chrome/linux-131.0.6778.204/chrome-linux64/chrome\" --version)",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'openclaw config --help 2>&1 | head -40; echo ---; openclaw help 2>&1 | head -40'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'openclaw config get agents.defaults.model 2>&1; echo ---; openclaw config get agents.defaults 2>&1 | grep -i timeout'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -r -i \\\\\"timeout\\\\\\\\|fallback\\\\\" /usr/lib/node_modules/openclaw/dist/ 2>/dev/null | grep -i \\\\\"model\\\\\\\\|fallback\\\\\\\\|timeout\\\\\" | grep -v \\\\\".map:\\\\\" | head -30'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'cat /usr/lib/node_modules/openclaw/dist/timeout-DwUOu7ws.js 2>/dev/null | grep -o \\\\\"[a-zA-Z]*[Tt]imeout[a-zA-Z]*[^;,\\)]*\\\\\" | sort -u | head -30'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'cat /usr/lib/node_modules/openclaw/dist/timeout-DwUOu7ws.js'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -r -h \\\\\"firstToken\\\\\\\\|connect.*[Tt]imeout\\\\\\\\|requestTimeout\\\\\\\\|loadTimeout\\\\\\\\|modelTimeout\\\\\\\\|fallback.*[Tt]imeout\\\\\\\\|[Tt]imeout.*fallback\\\\\" /usr/lib/node_modules/openclaw/dist/*.js 2>/dev/null | grep -v sourceMappingURL | sort -u | head -30'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -rh \\\\\"firstToken\\\\\\\\|first_token\\\\\\\\|ttft\\\\\\\\|modelLoad\\\\\\\\|model.*load\\\\\" /usr/lib/node_modules/openclaw/dist/*.js 2>/dev/null | grep -i timeout | grep -v sourceMappingURL | sort -u | head -20'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -rh \\\\\"requestTimeout\\\\\\\\|connectionTimeoutMs\\\\\\\\|connectTimeout\\\\\" /usr/lib/node_modules/openclaw/dist/*.js 2>/dev/null | grep -i \\\\\"cfg\\\\\\\\|config\\\\\\\\|default\\\\\\\\|agents\\\\\\\\|models\\\\\\\\|providers\\\\\" | grep -v sourceMappingURL | grep -v \\\\\"//\\\\\" | sort -u | head -20'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -rh \\\\\"models.*providers\\\\|providers.*requestTimeout\\\\|provider.*timeout\\\\|dgx.*timeout\\\\\" /usr/lib/node_modules/openclaw/dist/*.js 2>/dev/null | grep -v sourceMappingURL | grep -v \\\\\"^//\\\\\" | sort -u | head -20'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'grep -rh \\\\\"requestTimeout\\\\|connectTimeoutMs\\\\|connectionTimeoutMs\\\\\" /usr/lib/node_modules/openclaw/dist/*.js 2>/dev/null | grep -i \\\\\"provider\\\\|models\\\\|baseUrl\\\\|openai\\\\\" | grep -v sourceMappingURL | grep -v \\\\\"^//\\\\\" | sort -u | head -20'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'openclaw config schema 2>&1 | grep -A5 -i \\\\\"timeout\\\\\\\\|requestTimeout\\\\\\\\|connectTimeout\\\\\" | head -60'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'openclaw config schema 2>&1 | grep -B2 -A8 \\\\\"requestTimeout\\\\\\\\|connectTimeout\\\\\\\\|modelTimeout\\\\\\\\|loadTimeout\\\\\\\\|firstToken\\\\\"'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 'pct exec 200 -- bash -c '\\\\''openclaw config schema 2>&1 | python3 -c \":*)",
|
||||||
|
"Bash(ssh root@192.168.178.4 'pct exec 200 -- bash -c '\\\\''python3 -c \":*)",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- systemctl restart openclaw-gateway && sleep 2 && systemctl is-active openclaw-gateway\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'journalctl -u openclaw-gateway -n 20 --no-pager'\")",
|
||||||
|
"Bash(ssh root@192.168.178.4 \"pct exec 200 -- bash -c 'sleep 3 && systemctl is-active openclaw-gateway && journalctl -u openclaw-gateway -n 5 --no-pager'\")",
|
||||||
|
"Bash(openssl rand:*)",
|
||||||
|
"Bash(ssh root@192.168.178.4:*)",
|
||||||
|
"Bash(ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@192.168.178.8 \"echo connected && find / -name 'docker-compose.yml' -path '*/orchestrator*' 2>/dev/null | head -5\")",
|
||||||
|
"Bash(ssh-copy-id root@192.168.178.8)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/home/sparky/Projekte/My-Weekly-ToDo-List",
|
"/home/sparky/Projekte/My-Weekly-ToDo-List",
|
||||||
"/home/sparky/Projekte/My-Weekly-ToDo-List/src/app/api/calendar/cross-sync/rules",
|
"/home/sparky/Projekte/My-Weekly-ToDo-List/src/app/api/calendar/cross-sync/rules",
|
||||||
"/home/sparky/Projekte/My-Weekly-ToDo-List/src/components"
|
"/home/sparky/Projekte/My-Weekly-ToDo-List/src/components",
|
||||||
|
"/home/sparky/Projekte/My-Weekly-ToDo-List/src/app/api/user"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.96.3",
|
"version": "1.97.2",
|
||||||
"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,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getRandomLocalQuote } from '@/lib/quotes';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Document, Page, Text, View, StyleSheet, Font, renderToBuffer,
|
Document, Page, Text, View, StyleSheet, Font, renderToBuffer,
|
||||||
@ -58,8 +59,9 @@ const NO_TIME_H = 52; // anyday strip (user tasks without a time, shows ~4)
|
|||||||
const TIME_COL_W = 32; // left time-label column
|
const TIME_COL_W = 32; // left time-label column
|
||||||
|
|
||||||
// Derived
|
// Derived
|
||||||
const CONTENT_H = PH - PAD_V * 2 - FOOTER_H; // ≈ 549.28
|
const CONTENT_H = PH - PAD_V * 2 - FOOTER_H; // ≈ 549.28
|
||||||
const USABLE_W = PW - PAD_H * 2; // ≈ 801.89
|
const USABLE_W = PW - PAD_H * 2; // ≈ 801.89
|
||||||
|
const PAGE_HDR_H = 26; // page header strip: week label + motivational quote
|
||||||
|
|
||||||
// Fixed colours
|
// Fixed colours
|
||||||
const WHITE = '#ffffff';
|
const WHITE = '#ffffff';
|
||||||
@ -228,6 +230,7 @@ function WeekCalendarPDF({
|
|||||||
userStyle,
|
userStyle,
|
||||||
somedayLists,
|
somedayLists,
|
||||||
totalPages,
|
totalPages,
|
||||||
|
quote,
|
||||||
}: {
|
}: {
|
||||||
pages: Date[][];
|
pages: Date[][];
|
||||||
tasksByDay: Map<string, DayData>;
|
tasksByDay: Map<string, DayData>;
|
||||||
@ -241,6 +244,7 @@ function WeekCalendarPDF({
|
|||||||
userStyle: UserStyle;
|
userStyle: UserStyle;
|
||||||
somedayLists: SomedayList[];
|
somedayLists: SomedayList[];
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
quote: { text: string; author: string } | null;
|
||||||
}) {
|
}) {
|
||||||
const todayKey = new Date().toLocaleDateString('en-CA');
|
const todayKey = new Date().toLocaleDateString('en-CA');
|
||||||
const locale = de ? 'de-DE' : 'en-GB';
|
const locale = de ? 'de-DE' : 'en-GB';
|
||||||
@ -252,8 +256,8 @@ function WeekCalendarPDF({
|
|||||||
? (parseInt(userStyle.headlineFontWeight) >= 600 ? 700 : 400)
|
? (parseInt(userStyle.headlineFontWeight) >= 600 ? 700 : 400)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// GRID_H = remaining space after day header and optional all-day strip
|
// GRID_H = remaining space after page header, day header, and optional all-day strip
|
||||||
const GRID_H = CONTENT_H - DAY_HDR_H - (showAllDay ? ALL_DAY_H : 0);
|
const GRID_H = CONTENT_H - (PAGE_HDR_H + 8) - DAY_HDR_H - (showAllDay ? ALL_DAY_H : 0);
|
||||||
|
|
||||||
const exportedOn = new Date().toLocaleDateString(locale, {
|
const exportedOn = new Date().toLocaleDateString(locale, {
|
||||||
day: '2-digit', month: 'long', year: 'numeric',
|
day: '2-digit', month: 'long', year: 'numeric',
|
||||||
@ -267,7 +271,7 @@ function WeekCalendarPDF({
|
|||||||
const dayColW = (USABLE_W - TIME_COL_W) / numDays;
|
const dayColW = (USABLE_W - TIME_COL_W) / numDays;
|
||||||
|
|
||||||
// Adaptive font size — full names always, size scales with available column width
|
// Adaptive font size — full names always, size scales with available column width
|
||||||
const dayNameSize = numDays <= 2 ? 16 : numDays <= 3 ? 13 : numDays <= 5 ? 10 : 8.5;
|
const dayNameSize = numDays <= 2 ? 17 : numDays <= 3 ? 14 : numDays <= 5 ? 11 : 9.5;
|
||||||
|
|
||||||
const calendarH = DAY_HDR_H + (showAllDay ? ALL_DAY_H : 0) + GRID_H;
|
const calendarH = DAY_HDR_H + (showAllDay ? ALL_DAY_H : 0) + GRID_H;
|
||||||
|
|
||||||
@ -294,6 +298,41 @@ function WeekCalendarPDF({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Page header: KW + date range on left, motivational quote on right
|
||||||
|
React.createElement(View, {
|
||||||
|
style: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
height: PAGE_HDR_H,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e8eaed',
|
||||||
|
paddingBottom: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
React.createElement(Text, {
|
||||||
|
style: {
|
||||||
|
fontFamily: headlineFontFamily,
|
||||||
|
fontWeight: headlineFW,
|
||||||
|
fontSize: 12,
|
||||||
|
color: userStyle.weekdayColor,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
}, `${de ? 'KW' : 'Wk'} ${getWeekNumber(firstDay)} · ${rangeLabel}`),
|
||||||
|
quote ? React.createElement(Text, {
|
||||||
|
style: {
|
||||||
|
fontSize: 7,
|
||||||
|
color: MUTED,
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'right',
|
||||||
|
paddingLeft: 10,
|
||||||
|
paddingBottom: 1,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
}, `"${quote.text}" — ${quote.author}`) : null,
|
||||||
|
),
|
||||||
|
|
||||||
// ── Calendar grid: [time col] [day cols...]
|
// ── Calendar grid: [time col] [day cols...]
|
||||||
React.createElement(View, {
|
React.createElement(View, {
|
||||||
style: {
|
style: {
|
||||||
@ -317,24 +356,7 @@ function WeekCalendarPDF({
|
|||||||
backgroundColor: '#fafafa',
|
backgroundColor: '#fafafa',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Spacer matching day header row
|
// All-day label spacer (top — matches day column order)
|
||||||
React.createElement(View, {
|
|
||||||
style: {
|
|
||||||
height: DAY_HDR_H,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: BORDER,
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
paddingRight: 4,
|
|
||||||
paddingBottom: 4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
React.createElement(Text, {
|
|
||||||
style: { fontSize: 6, color: '#94a3b8', textAlign: 'right' },
|
|
||||||
}, `${de ? 'KW' : 'Wk'} ${getWeekNumber(firstDay)}`),
|
|
||||||
),
|
|
||||||
|
|
||||||
// All-day label spacer
|
|
||||||
...(showAllDay ? [
|
...(showAllDay ? [
|
||||||
React.createElement(View, {
|
React.createElement(View, {
|
||||||
style: {
|
style: {
|
||||||
@ -350,7 +372,7 @@ function WeekCalendarPDF({
|
|||||||
},
|
},
|
||||||
React.createElement(Text, {
|
React.createElement(Text, {
|
||||||
style: {
|
style: {
|
||||||
fontSize: 5.5,
|
fontSize: 6,
|
||||||
fontFamily: 'Helvetica-Bold',
|
fontFamily: 'Helvetica-Bold',
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
@ -360,6 +382,15 @@ function WeekCalendarPDF({
|
|||||||
),
|
),
|
||||||
] : []),
|
] : []),
|
||||||
|
|
||||||
|
// Spacer matching day header row
|
||||||
|
React.createElement(View, {
|
||||||
|
style: {
|
||||||
|
height: DAY_HDR_H,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: BORDER,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
// Hour labels — flex rows, aligned with day-column hour rows
|
// Hour labels — flex rows, aligned with day-column hour rows
|
||||||
React.createElement(View, { style: { height: GRID_H, flexDirection: 'column' } },
|
React.createElement(View, { style: { height: GRID_H, flexDirection: 'column' } },
|
||||||
...Array.from({ length: numHours }, (_, i) =>
|
...Array.from({ length: numHours }, (_, i) =>
|
||||||
@ -376,7 +407,7 @@ function WeekCalendarPDF({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
React.createElement(Text, {
|
React.createElement(Text, {
|
||||||
style: { fontFamily: 'Helvetica-Bold', fontSize: 6.5, color: MUTED, textAlign: 'right' },
|
style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: MUTED, textAlign: 'right' },
|
||||||
}, fmtHour(startHour + i)),
|
}, fmtHour(startHour + i)),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -405,6 +436,59 @@ function WeekCalendarPDF({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── All-day cell (top — above weekday header, matches webapp layout)
|
||||||
|
...(showAllDay ? [
|
||||||
|
React.createElement(View, {
|
||||||
|
style: {
|
||||||
|
height: ALL_DAY_H,
|
||||||
|
backgroundColor: ALLDAY_BG,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#bfdbfe',
|
||||||
|
paddingHorizontal: 3,
|
||||||
|
paddingTop: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...allDayTasks.slice(0, 5).map(t => {
|
||||||
|
if (t.completed) {
|
||||||
|
return React.createElement(View, {
|
||||||
|
key: t.id,
|
||||||
|
style: { borderRadius: 2, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2, backgroundColor: DONE_BG },
|
||||||
|
}, React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT } }, (t.title || '').slice(0, 36)));
|
||||||
|
}
|
||||||
|
if (t.isExternal) {
|
||||||
|
// Calendar event: solid color chip with white text + dot indicator (matches webapp style)
|
||||||
|
const evColor = t.project?.color || '#6366f1';
|
||||||
|
const evTextColor = contrastColor(evColor);
|
||||||
|
return React.createElement(View, {
|
||||||
|
key: t.id,
|
||||||
|
style: {
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
borderRadius: 2,
|
||||||
|
paddingVertical: 2, paddingHorizontal: 4, marginBottom: 2,
|
||||||
|
backgroundColor: evColor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Calendar indicator dot (replaces the 📅 emoji the webapp uses)
|
||||||
|
React.createElement(View, {
|
||||||
|
style: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: evTextColor, opacity: 0.7, marginRight: 3, flexShrink: 0 },
|
||||||
|
}),
|
||||||
|
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: evTextColor, flex: 1 } }, (t.title || '').slice(0, 36)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// User-created all-day task: left border only, no background fill
|
||||||
|
const accent = t.project?.color || userStyle.taskColor;
|
||||||
|
return React.createElement(View, {
|
||||||
|
key: t.id,
|
||||||
|
style: { borderLeftWidth: 2, borderLeftColor: accent, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2 },
|
||||||
|
}, React.createElement(Text, { style: { fontSize: 7.5, color: '#374151' } }, (t.title || '').slice(0, 36)));
|
||||||
|
}),
|
||||||
|
allDayTasks.length > 5 ? React.createElement(Text, {
|
||||||
|
style: { fontSize: 6, color: '#3b82f6', marginTop: 1 },
|
||||||
|
}, `+${allDayTasks.length - 5} ${de ? 'weitere' : 'more'}`) : null,
|
||||||
|
),
|
||||||
|
] : []),
|
||||||
|
|
||||||
// ── Day header: date first (top), then weekday name (bottom) — matches webapp "above" layout
|
// ── Day header: date first (top), then weekday name (bottom) — matches webapp "above" layout
|
||||||
React.createElement(View, {
|
React.createElement(View, {
|
||||||
style: {
|
style: {
|
||||||
@ -419,7 +503,7 @@ function WeekCalendarPDF({
|
|||||||
},
|
},
|
||||||
React.createElement(Text, {
|
React.createElement(Text, {
|
||||||
style: {
|
style: {
|
||||||
fontSize: 7,
|
fontSize: 8,
|
||||||
color: dayColor(d, isToday, userStyle),
|
color: dayColor(d, isToday, userStyle),
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontFamily: 'Helvetica',
|
fontFamily: 'Helvetica',
|
||||||
@ -440,51 +524,6 @@ function WeekCalendarPDF({
|
|||||||
}, dayName),
|
}, dayName),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── All-day cell (external calendar events)
|
|
||||||
...(showAllDay ? [
|
|
||||||
React.createElement(View, {
|
|
||||||
style: {
|
|
||||||
height: ALL_DAY_H,
|
|
||||||
backgroundColor: ALLDAY_BG,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#bfdbfe',
|
|
||||||
paddingHorizontal: 3,
|
|
||||||
paddingTop: 3,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...allDayTasks.slice(0, 5).map(t => {
|
|
||||||
if (t.completed) {
|
|
||||||
return React.createElement(View, {
|
|
||||||
key: t.id,
|
|
||||||
style: { borderRadius: 2, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2, backgroundColor: DONE_BG },
|
|
||||||
}, React.createElement(Text, { style: { fontSize: 6.5, color: DONE_TEXT } }, (t.title || '').slice(0, 36)));
|
|
||||||
}
|
|
||||||
if (t.isExternal) {
|
|
||||||
// Calendar event: light tinted chip with left border
|
|
||||||
const evColor = t.project?.color || '#6366f1';
|
|
||||||
return React.createElement(View, {
|
|
||||||
key: t.id,
|
|
||||||
style: {
|
|
||||||
borderRadius: 2, borderLeftWidth: 2, borderLeftColor: evColor,
|
|
||||||
paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2,
|
|
||||||
backgroundColor: lighten(evColor, 0.78),
|
|
||||||
},
|
|
||||||
}, React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 6.5, color: '#1e293b' } }, (t.title || '').slice(0, 36)));
|
|
||||||
}
|
|
||||||
// User-created all-day task: left border only, no background fill
|
|
||||||
const accent = t.project?.color || userStyle.taskColor;
|
|
||||||
return React.createElement(View, {
|
|
||||||
key: t.id,
|
|
||||||
style: { borderLeftWidth: 2, borderLeftColor: accent, paddingVertical: 1.5, paddingHorizontal: 4, marginBottom: 2 },
|
|
||||||
}, React.createElement(Text, { style: { fontSize: 6.5, color: '#374151' } }, (t.title || '').slice(0, 36)));
|
|
||||||
}),
|
|
||||||
allDayTasks.length > 5 ? React.createElement(Text, {
|
|
||||||
style: { fontSize: 5.5, color: '#3b82f6', marginTop: 1 },
|
|
||||||
}, `+${allDayTasks.length - 5} ${de ? 'weitere' : 'more'}`) : null,
|
|
||||||
),
|
|
||||||
] : []),
|
|
||||||
|
|
||||||
// ── Time grid: per-hour rows (agenda/list style, no absolute positioning)
|
// ── Time grid: per-hour rows (agenda/list style, no absolute positioning)
|
||||||
React.createElement(View, {
|
React.createElement(View, {
|
||||||
style: {
|
style: {
|
||||||
@ -509,9 +548,9 @@ function WeekCalendarPDF({
|
|||||||
borderBottomColor: '#ececec',
|
borderBottomColor: '#ececec',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
paddingTop: 1,
|
paddingTop: 2,
|
||||||
paddingLeft: 2,
|
paddingLeft: 3,
|
||||||
paddingRight: hw ? 22 : 2,
|
paddingRight: hw ? 24 : 3,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Per-hour weather: small colored dot + temperature
|
// Per-hour weather: small colored dot + temperature
|
||||||
@ -524,7 +563,7 @@ function WeekCalendarPDF({
|
|||||||
style: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: wmoColor(hw.code), marginRight: 1.5 },
|
style: { width: 5, height: 5, borderRadius: 2.5, backgroundColor: wmoColor(hw.code), marginRight: 1.5 },
|
||||||
}),
|
}),
|
||||||
hw.temp != null ? React.createElement(Text, {
|
hw.temp != null ? React.createElement(Text, {
|
||||||
style: { fontSize: 5.5, color: '#94a3b8' },
|
style: { fontSize: 6, color: '#94a3b8' },
|
||||||
}, `${Math.round(hw.temp)}°`) : null,
|
}, `${Math.round(hw.temp)}°`) : null,
|
||||||
),
|
),
|
||||||
] : []),
|
] : []),
|
||||||
@ -534,10 +573,9 @@ function WeekCalendarPDF({
|
|||||||
if (t.completed) {
|
if (t.completed) {
|
||||||
return React.createElement(View, {
|
return React.createElement(View, {
|
||||||
key: t.id,
|
key: t.id,
|
||||||
style: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 1, paddingLeft: 1 },
|
style: { marginBottom: 1, paddingLeft: 1, paddingVertical: 1 },
|
||||||
},
|
},
|
||||||
React.createElement(Text, { style: { fontSize: 5.5, color: DONE_TEXT, width: 22, flexShrink: 0, lineHeight: 1.3 } }, fmtTime(t.startTime || '')),
|
React.createElement(Text, { style: { fontSize: 7.5, color: DONE_TEXT, lineHeight: 1.3, textDecoration: 'line-through' } }, (t.title || '').slice(0, 60)),
|
||||||
React.createElement(Text, { style: { fontSize: 6.5, color: DONE_TEXT, flex: 1, lineHeight: 1.3 } }, (t.title || '').slice(0, 52)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -546,14 +584,12 @@ function WeekCalendarPDF({
|
|||||||
return React.createElement(View, {
|
return React.createElement(View, {
|
||||||
key: t.id,
|
key: t.id,
|
||||||
style: {
|
style: {
|
||||||
flexDirection: 'row', alignItems: 'flex-start',
|
|
||||||
backgroundColor: lighten(evColor, 0.85),
|
backgroundColor: lighten(evColor, 0.85),
|
||||||
borderRadius: 2, borderLeftWidth: 2, borderLeftColor: evColor,
|
borderRadius: 2, borderLeftWidth: 2, borderLeftColor: evColor,
|
||||||
paddingLeft: 3, paddingVertical: 1, marginBottom: 1,
|
paddingLeft: 3, paddingVertical: 1, marginBottom: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
React.createElement(Text, { style: { fontSize: 5.5, color: evColor, width: 22, flexShrink: 0, lineHeight: 1.3 } }, fmtTime(t.startTime || '')),
|
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, (t.title || '').slice(0, 60)),
|
||||||
React.createElement(Text, { style: { fontFamily: 'Helvetica-Bold', fontSize: 6.5, color: '#1e293b', flex: 1, lineHeight: 1.3 } }, (t.title || '').slice(0, 52)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -561,13 +597,11 @@ function WeekCalendarPDF({
|
|||||||
return React.createElement(View, {
|
return React.createElement(View, {
|
||||||
key: t.id,
|
key: t.id,
|
||||||
style: {
|
style: {
|
||||||
flexDirection: 'row', alignItems: 'flex-start',
|
|
||||||
...(pc ? { borderLeftWidth: 2, borderLeftColor: pc, paddingLeft: 3 } : { paddingLeft: 1 }),
|
...(pc ? { borderLeftWidth: 2, borderLeftColor: pc, paddingLeft: 3 } : { paddingLeft: 1 }),
|
||||||
paddingVertical: 1, marginBottom: 1,
|
paddingVertical: 1, marginBottom: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
React.createElement(Text, { style: { fontSize: 5.5, color: pc || MUTED, width: 22, flexShrink: 0, lineHeight: 1.3 } }, fmtTime(t.startTime || '')),
|
React.createElement(Text, { style: { fontSize: 7.5, color: '#1e293b', lineHeight: 1.3 } }, (t.title || '').slice(0, 60)),
|
||||||
React.createElement(Text, { style: { fontSize: 6.5, color: '#1e293b', flex: 1, lineHeight: 1.3 } }, (t.title || '').slice(0, 52)),
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -612,12 +646,13 @@ function WeekCalendarPDF({
|
|||||||
React.createElement(Page, {
|
React.createElement(Page, {
|
||||||
key: 'someday',
|
key: 'someday',
|
||||||
size: 'A4',
|
size: 'A4',
|
||||||
orientation: 'portrait',
|
orientation: 'landscape',
|
||||||
style: {
|
style: {
|
||||||
fontFamily: 'Helvetica',
|
fontFamily: 'Helvetica',
|
||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
color: '#1e293b',
|
color: '#1e293b',
|
||||||
padding: 28,
|
padding: 24,
|
||||||
|
paddingBottom: 24 + 18,
|
||||||
backgroundColor: WHITE,
|
backgroundColor: WHITE,
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
},
|
},
|
||||||
@ -719,7 +754,7 @@ function WeekCalendarPDF({
|
|||||||
React.createElement(View, {
|
React.createElement(View, {
|
||||||
style: {
|
style: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 20, left: 28, right: 28,
|
bottom: 24, left: 24, right: 24,
|
||||||
borderTopWidth: 1, borderTopColor: BORDER,
|
borderTopWidth: 1, borderTopColor: BORDER,
|
||||||
paddingTop: 3,
|
paddingTop: 3,
|
||||||
flexDirection: 'row', justifyContent: 'space-between',
|
flexDirection: 'row', justifyContent: 'space-between',
|
||||||
@ -766,6 +801,9 @@ export async function GET(request: Request) {
|
|||||||
const showNoTime = searchParams.get('showNoTime') !== '0';
|
const showNoTime = searchParams.get('showNoTime') !== '0';
|
||||||
const showWeather = searchParams.get('showWeather') === '1';
|
const showWeather = searchParams.get('showWeather') === '1';
|
||||||
const inline = searchParams.get('inline') === '1';
|
const inline = searchParams.get('inline') === '1';
|
||||||
|
// noTimeTabs: comma-separated tab names to include; absent = include all
|
||||||
|
const noTimeTabsRaw = searchParams.get('noTimeTabs');
|
||||||
|
const noTimeTabFilter: string[] | null = noTimeTabsRaw ? noTimeTabsRaw.split(',').filter(Boolean) : null;
|
||||||
|
|
||||||
if (!startDate || !endDate) return new NextResponse('Missing startDate or endDate', { status: 400 });
|
if (!startDate || !endDate) return new NextResponse('Missing startDate or endDate', { status: 400 });
|
||||||
|
|
||||||
@ -852,22 +890,43 @@ export async function GET(request: Request) {
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true, title: true, calendarColor: true,
|
id: true, title: true, calendarColor: true,
|
||||||
startDate: true, startDateTime: true, endDateTime: true,
|
startDate: true, startDateTime: true, endDateTime: true, endDate: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const ev of calEvents) {
|
for (const ev of calEvents) {
|
||||||
if (ev.startDate) {
|
if (ev.startDate) {
|
||||||
// All-day external event (e.g. Google all-day)
|
// All-day external event — may span multiple days (iCal endDate is exclusive)
|
||||||
if (!showAllDay) continue;
|
if (!showAllDay) continue;
|
||||||
const dk = ev.startDate as string;
|
const evStart = ev.startDate as string;
|
||||||
if (dk < startDate || dk > endDate) continue;
|
// endDate is exclusive in iCalendar; treat null/same as single-day
|
||||||
if (!tasksByDay.has(dk)) tasksByDay.set(dk, { allDay: [], timed: [] });
|
const evEndExcl = (ev.endDate as string | null) || '';
|
||||||
tasksByDay.get(dk)!.allDay.push({
|
const daysToFill: string[] = [];
|
||||||
id: `cal-${ev.id}`, title: ev.title as string | null,
|
if (evEndExcl > evStart) {
|
||||||
startTime: null, endTime: null, completed: false, isExternal: true,
|
// Expand across all days in the span (capped to visible range)
|
||||||
project: { name: '', color: (ev.calendarColor as string | null) || '#6366f1' },
|
let cur = new Date(evStart + 'T00:00:00Z');
|
||||||
});
|
const stop = new Date(evEndExcl + 'T00:00:00Z');
|
||||||
|
while (cur < stop) {
|
||||||
|
const dk = cur.toLocaleDateString('en-CA');
|
||||||
|
if (dk >= startDate && dk <= endDate) daysToFill.push(dk);
|
||||||
|
cur = new Date(cur.getTime() + 86400000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (evStart >= startDate && evStart <= endDate) daysToFill.push(evStart);
|
||||||
|
}
|
||||||
|
const evTitle = (ev.title as string | null) || '';
|
||||||
|
const evColor = (ev.calendarColor as string | null) || '#6366f1';
|
||||||
|
for (const dk of daysToFill) {
|
||||||
|
if (!tasksByDay.has(dk)) tasksByDay.set(dk, { allDay: [], timed: [] });
|
||||||
|
const arr = tasksByDay.get(dk)!.allDay;
|
||||||
|
// Deduplicate: skip if an entry with the same title already exists in this day
|
||||||
|
if (arr.some(x => (x.title || '').toLowerCase() === evTitle.toLowerCase())) continue;
|
||||||
|
arr.push({
|
||||||
|
id: `cal-${ev.id}-${dk}`, title: ev.title as string | null,
|
||||||
|
startTime: null, endTime: null, completed: false, isExternal: true,
|
||||||
|
project: { name: '', color: evColor },
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (ev.startDateTime) {
|
} else if (ev.startDateTime) {
|
||||||
// Timed external event
|
// Timed external event
|
||||||
const dk = localDateStr(ev.startDateTime as Date, userTz);
|
const dk = localDateStr(ev.startDateTime as Date, userTz);
|
||||||
@ -925,8 +984,19 @@ export async function GET(request: Request) {
|
|||||||
};
|
};
|
||||||
let somedayLists: SomedayListWithTasks[] = [];
|
let somedayLists: SomedayListWithTasks[] = [];
|
||||||
if (showNoTime) {
|
if (showNoTime) {
|
||||||
|
// Build tab filter condition (noTimeTabFilter encodes null tab as the locale-specific default label)
|
||||||
|
const defaultTabLabel = de ? 'Allgemein' : 'General';
|
||||||
|
const tabWhere = noTimeTabFilter ? {
|
||||||
|
OR: [
|
||||||
|
...(noTimeTabFilter.includes(defaultTabLabel) ? [{ tab: null }] : []),
|
||||||
|
...(noTimeTabFilter.filter(t => t !== defaultTabLabel).length > 0
|
||||||
|
? [{ tab: { in: noTimeTabFilter.filter(t => t !== defaultTabLabel) } }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
} : {};
|
||||||
|
|
||||||
const raw = await prisma.somedayList.findMany({
|
const raw = await prisma.somedayList.findMany({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id, ...tabWhere },
|
||||||
orderBy: [{ tab: 'asc' }, { order: 'asc' }],
|
orderBy: [{ tab: 'asc' }, { order: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@ -952,6 +1022,9 @@ export async function GET(request: Request) {
|
|||||||
new Date(endDate + 'T00:00:00'),
|
new Date(endDate + 'T00:00:00'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const quoteData = getRandomLocalQuote(de ? 'de' : 'en');
|
||||||
|
const quote = quoteData ? { text: quoteData.text, author: quoteData.author } : null;
|
||||||
|
|
||||||
const pdfBuffer = await renderToBuffer(
|
const pdfBuffer = await renderToBuffer(
|
||||||
React.createElement(WeekCalendarPDF, {
|
React.createElement(WeekCalendarPDF, {
|
||||||
pages, tasksByDay, weatherHourly,
|
pages, tasksByDay, weatherHourly,
|
||||||
@ -960,6 +1033,7 @@ export async function GET(request: Request) {
|
|||||||
userStyle,
|
userStyle,
|
||||||
somedayLists,
|
somedayLists,
|
||||||
totalPages: pages.length,
|
totalPages: pages.length,
|
||||||
|
quote,
|
||||||
}) as React.ReactElement<any>
|
}) as React.ReactElement<any>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
26
src/app/api/user/someday-tabs/route.ts
Normal file
26
src/app/api/user/someday-tabs/route.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from '@/lib/auth';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
// Returns the distinct set of tabs (and list titles within each tab) for the
|
||||||
|
// current user's someday lists — used by the print/export modal to show a
|
||||||
|
// tab-filter selector.
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) return new NextResponse('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: session.user.email },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!user) return new NextResponse('Not found', { status: 404 });
|
||||||
|
|
||||||
|
const lists = await prisma.somedayList.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { id: true, title: true, tab: true },
|
||||||
|
orderBy: [{ tab: 'asc' }, { order: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ lists });
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from "react";
|
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
import { X, Download, Printer, Eye } from "lucide-react";
|
import { X, Download, Printer, Eye } from "lucide-react";
|
||||||
|
|
||||||
// ── Types ───────────────────────────────────────────────────────────────────
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
@ -113,6 +113,11 @@ export default function WeekPrintModal({
|
|||||||
const [showWeather, setShowWeather] = useState(weatherEnabled);
|
const [showWeather, setShowWeather] = useState(weatherEnabled);
|
||||||
const [lang, setLang] = useState(language || "de");
|
const [lang, setLang] = useState(language || "de");
|
||||||
|
|
||||||
|
// Someday tab filter — fetched once when modal opens
|
||||||
|
const [somedayTabs, setSomedayTabs] = useState<{ key: string; label: string }[]>([]);
|
||||||
|
const [selectedTabs, setSelectedTabs] = useState<Set<string>>(new Set());
|
||||||
|
const [tabsLoaded, setTabsLoaded] = useState(false);
|
||||||
|
|
||||||
// ── Computed
|
// ── Computed
|
||||||
const endDate = useMemo(() => {
|
const endDate = useMemo(() => {
|
||||||
const d = new Date(startDate + "T00:00:00");
|
const d = new Date(startDate + "T00:00:00");
|
||||||
@ -138,6 +143,28 @@ export default function WeekPrintModal({
|
|||||||
|
|
||||||
const numPages = useMemo(() => Math.ceil(numDays / 7), [numDays]);
|
const numPages = useMemo(() => Math.ceil(numDays / 7), [numDays]);
|
||||||
|
|
||||||
|
// Fetch available someday tabs once when the modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || tabsLoaded) return;
|
||||||
|
fetch("/api/user/someday-tabs")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((data: { lists: { id: string; title: string; tab: string | null }[] }) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const tabs: { key: string; label: string }[] = [];
|
||||||
|
const defaultLabel = lang === "de" ? "Allgemein" : "General";
|
||||||
|
for (const list of data.lists || []) {
|
||||||
|
// Encode null tab as the default label so it round-trips through URL params
|
||||||
|
const key = list.tab ?? defaultLabel;
|
||||||
|
const label = list.tab ?? defaultLabel;
|
||||||
|
if (!seen.has(key)) { seen.add(key); tabs.push({ key, label }); }
|
||||||
|
}
|
||||||
|
setSomedayTabs(tabs);
|
||||||
|
setSelectedTabs(new Set(tabs.map(t => t.key))); // all selected by default
|
||||||
|
setTabsLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => setTabsLoaded(true));
|
||||||
|
}, [isOpen, tabsLoaded, lang]);
|
||||||
|
|
||||||
const pdfUrl = useMemo(() => {
|
const pdfUrl = useMemo(() => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
startDate,
|
startDate,
|
||||||
@ -149,8 +176,12 @@ export default function WeekPrintModal({
|
|||||||
showNoTime: showNoTime ? "1" : "0",
|
showNoTime: showNoTime ? "1" : "0",
|
||||||
showWeather: showWeather ? "1" : "0",
|
showWeather: showWeather ? "1" : "0",
|
||||||
});
|
});
|
||||||
|
// Only send tab filter if not all tabs are selected (avoids unnecessary URL noise)
|
||||||
|
if (showNoTime && somedayTabs.length > 1 && selectedTabs.size < somedayTabs.length) {
|
||||||
|
params.set("noTimeTabs", Array.from(selectedTabs).join(","));
|
||||||
|
}
|
||||||
return `/api/user/export-week-view?${params}`;
|
return `/api/user/export-week-view?${params}`;
|
||||||
}, [startDate, endDate, startHour, endHour, lang, showAllDay, showNoTime, showWeather]);
|
}, [startDate, endDate, startHour, endHour, lang, showAllDay, showNoTime, showWeather, selectedTabs, somedayTabs]);
|
||||||
|
|
||||||
const printUrl = useMemo(() => `${pdfUrl}&inline=1`, [pdfUrl]);
|
const printUrl = useMemo(() => `${pdfUrl}&inline=1`, [pdfUrl]);
|
||||||
|
|
||||||
@ -334,11 +365,56 @@ export default function WeekPrintModal({
|
|||||||
checked={showAllDay}
|
checked={showAllDay}
|
||||||
onChange={setShowAllDay}
|
onChange={setShowAllDay}
|
||||||
/>
|
/>
|
||||||
<ToggleRow
|
<div>
|
||||||
label={t.showNoTime}
|
<ToggleRow
|
||||||
checked={showNoTime}
|
label={t.showNoTime}
|
||||||
onChange={setShowNoTime}
|
checked={showNoTime}
|
||||||
/>
|
onChange={setShowNoTime}
|
||||||
|
/>
|
||||||
|
{/* Tab filter — shown when the someday section is enabled and there are multiple tabs */}
|
||||||
|
{showNoTime && somedayTabs.length > 1 && (
|
||||||
|
<div style={{ paddingLeft: 46, marginTop: 8 }}>
|
||||||
|
<div style={{ fontSize: "0.73rem", fontWeight: 600, color: "#6b7280", marginBottom: 6, textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
|
{de ? "Tabs einschließen" : "Include tabs"}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||||
|
{somedayTabs.map(({ key, label }) => {
|
||||||
|
const checked = selectedTabs.has(key);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 5,
|
||||||
|
cursor: "pointer", fontSize: "0.82rem",
|
||||||
|
padding: "3px 10px 3px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${checked ? "#6366f1" : "#d1d5db"}`,
|
||||||
|
background: checked ? "#eff6ff" : "#f9fafb",
|
||||||
|
color: checked ? "#4f46e5" : "#6b7280",
|
||||||
|
fontWeight: checked ? 600 : 400,
|
||||||
|
transition: "all 0.15s",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={e => {
|
||||||
|
const next = new Set(selectedTabs);
|
||||||
|
if (e.target.checked) next.add(key);
|
||||||
|
else if (next.size > 1) next.delete(key); // keep at least 1 selected
|
||||||
|
setSelectedTabs(next);
|
||||||
|
}}
|
||||||
|
style={{ accentColor: "#6366f1", width: 13, height: 13, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label={t.showWeather}
|
label={t.showWeather}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user