diff --git a/index.html b/index.html index db58d79..3a45f82 100644 --- a/index.html +++ b/index.html @@ -5,35 +5,7 @@ ~ - +
diff --git a/plugins/inject-theme-script.js b/plugins/inject-theme-script.js index e6d9fec..66b794d 100644 --- a/plugins/inject-theme-script.js +++ b/plugins/inject-theme-script.js @@ -9,54 +9,9 @@ export function injectThemeScript() { 'utf-8' ) - const themesModule = fs.readFileSync( - './src/lib/config/themes.js', - 'utf-8' - ) - const defaultThemeMatch = themesModule.match( - /export const defaultTheme = ['"](.+?)['"]/ - ) - - if (!defaultThemeMatch) { - console.error('Failed to extract default theme') - return html - } - - const defaultTheme = defaultThemeMatch[1] - const styleTag = `` - const themeScript = `` - return html - .replace( - /
-
+
{#if settings.showClock || settings.showStats}
{#if settings.showClock} @@ -97,18 +157,18 @@ {/if}
{/if} - {#if settings.showWeather || settings.showTasks} + {#if orderedVisibleWidgets.length}
- {#if settings.showWeather} - - {/if} - {#if settings.showTasks} - - {/if} + {#each orderedVisibleWidgets as widgetId (widgetId)} + {@const WidgetComponent = widgetComponents[widgetId]} + + {/each}
{/if} {#if settings.showLinks} - + {/if}
@@ -137,12 +197,49 @@ display: flex; flex-direction: column; gap: 1.5rem; + opacity: 0; } .top, .widgets { display: flex; gap: 1.5rem; } + .top, + .links { + opacity: 0; + transform: translate3d(0, 0.5rem, 0); + } + .widgets { + opacity: 0; + } + .container.ready { + opacity: 1; + } + .container.ready .top, + .container.ready .links { + opacity: 1; + transform: translate3d(0, 0, 0); + transition: + opacity 220ms cubic-bezier(0.22, 1, 0.36, 1), + transform 280ms cubic-bezier(0.22, 1, 0.36, 1); + } + .container.ready .widgets { + opacity: 1; + transition: opacity 180ms cubic-bezier(0.22, 1, 0.36, 1); + } + .container.ready .top { + transition-delay: 30ms; + } + .container.ready .widgets { + transition-delay: 70ms; + } + .container.ready .links { + transition-delay: 110ms; + } + .widgets :global(.panel-wrapper) { + flex: 1 1 0; + min-width: 0; + } .settings-btn { position: fixed; top: 0; @@ -163,4 +260,15 @@ opacity: 1; animation: none; } + + @media (prefers-reduced-motion: reduce) { + .container, + .top, + .widgets, + .links { + opacity: 1; + transform: none; + transition: none; + } + } diff --git a/src/lib/api/google-calendar-api.js b/src/lib/api/google-calendar-api.js new file mode 100644 index 0000000..3ac85b4 --- /dev/null +++ b/src/lib/api/google-calendar-api.js @@ -0,0 +1,227 @@ +import { isChrome } from '../utils/browser-detect.js' + +class GoogleCalendarAPI { + constructor() { + this.scopes = ['https://www.googleapis.com/auth/calendar.readonly'] + this.baseUrl = 'https://www.googleapis.com/calendar/v3' + this.accessToken = null + this.tokenPromise = null + } + + async getAuthToken(interactive = false) { + if (!isChrome()) { + throw new Error( + 'Chrome identity API not available. Google Calendar only works in Chrome.' + ) + } + + if (this.tokenPromise) { + return this.tokenPromise + } + + this.tokenPromise = new Promise((resolve, reject) => { + chrome.identity.getAuthToken( + { + interactive, + scopes: this.scopes, + }, + (token) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)) + return + } + + if (!token) { + reject(new Error('No token returned')) + return + } + + this.accessToken = token + resolve(token) + } + ) + }) + + try { + return await this.tokenPromise + } finally { + this.tokenPromise = null + } + } + + async signIn() { + await this.getAuthToken(true) + return this.accessToken + } + + async signOut() { + if (!isChrome()) return + + let token = this.accessToken + + if (!token) { + try { + token = await this.getAuthToken(false) + } catch { + token = null + } + } + + if (token) { + await new Promise((resolve) => { + chrome.identity.removeCachedAuthToken({ token }, () => { + resolve() + }) + }) + } + + this.accessToken = null + } + + async apiRequest(endpoint, options = {}, isRetry = false) { + const token = await this.getAuthToken(false) + const response = await fetch(`${this.baseUrl}${endpoint}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + if (!response.ok) { + if (response.status === 401 && !isRetry) { + await new Promise((resolve) => { + chrome.identity.removeCachedAuthToken({ token }, () => + resolve() + ) + }) + this.accessToken = null + return this.apiRequest(endpoint, options, true) + } + + if (response.status === 401) { + throw new Error( + 'Authentication expired. Please sign in again.' + ) + } + + throw new Error( + `Calendar API request failed: ${response.status} ${response.statusText}` + ) + } + + if ( + response.status === 204 || + response.headers.get('content-length') === '0' + ) { + return null + } + + return response.json() + } + + async getCalendars() { + const params = new URLSearchParams({ + minAccessRole: 'reader', + showDeleted: 'false', + showHidden: 'false', + maxResults: '250', + }) + + const data = await this.apiRequest(`/users/me/calendarList?${params}`) + + return (data?.items || []) + .filter((calendar) => !calendar.deleted) + .map((calendar) => ({ + id: calendar.id, + name: calendar.summary || '(untitled calendar)', + primary: Boolean(calendar.primary), + })) + .sort((a, b) => { + if (a.primary !== b.primary) return a.primary ? -1 : 1 + return a.name.localeCompare(b.name) + }) + } + + async getEventsForCalendar(calendar, maxResults, timeMin) { + const params = new URLSearchParams({ + singleEvents: 'true', + orderBy: 'startTime', + maxResults: String(maxResults), + timeMin, + }) + + const data = await this.apiRequest( + `/calendars/${encodeURIComponent(calendar.id)}/events?${params.toString()}` + ) + + return (data?.items || []) + .filter((event) => event.status !== 'cancelled' && event.start) + .map((event) => { + const isAllDay = Boolean( + event.start?.date && !event.start?.dateTime + ) + const startRaw = event.start?.dateTime || event.start?.date + const endRaw = event.end?.dateTime || event.end?.date + + const start = isAllDay + ? new Date(`${startRaw}T00:00:00`) + : new Date(startRaw) + const end = endRaw + ? isAllDay + ? new Date(`${endRaw}T00:00:00`) + : new Date(endRaw) + : null + + return { + id: event.id, + title: event.summary || '(untitled)', + location: event.location || '', + link: event.htmlLink || '', + allDay: isAllDay, + calendarId: calendar.id, + calendarName: calendar.name, + calendarPrimary: calendar.primary, + start, + end, + } + }) + } + + async getUpcomingEvents(maxResults = 5, visibleCalendarIds = null) { + const safeMaxResults = Math.min( + 20, + Math.max(1, parseInt(maxResults, 10) || 5) + ) + const calendars = await this.getCalendars() + if (calendars.length === 0) return [] + + let selectedCalendars = calendars + if (Array.isArray(visibleCalendarIds)) { + const visibleSet = new Set( + visibleCalendarIds.map((calendarId) => String(calendarId)) + ) + selectedCalendars = calendars.filter((calendar) => + visibleSet.has(String(calendar.id)) + ) + } + + if (selectedCalendars.length === 0) return [] + + const perCalendarLimit = Math.max(10, safeMaxResults) + const timeMin = new Date().toISOString() + const eventArrays = await Promise.all( + selectedCalendars.map((calendar) => + this.getEventsForCalendar(calendar, perCalendarLimit, timeMin) + ) + ) + + return eventArrays + .flat() + .sort((a, b) => a.start.getTime() - b.start.getTime()) + .slice(0, safeMaxResults) + } +} + +export default GoogleCalendarAPI diff --git a/src/lib/api/weather-api.js b/src/lib/api/weather-api.js index 85d33cf..cd8885c 100644 --- a/src/lib/api/weather-api.js +++ b/src/lib/api/weather-api.js @@ -222,6 +222,7 @@ class WeatherAPI { time: hourlyData.time[index], temperature: hourlyData.temperature_2m[index].toFixed(0), weatherCode: hourlyData.weather_code[index], + isDay: hourlyData.is_day[index] === 1, description: this._getWeatherDescription( hourlyData.weather_code[index], hourlyData.is_day[index] === 1 @@ -249,6 +250,7 @@ class WeatherAPI { temperatureMax: dailyData.temperature_2m_max[i].toFixed(0), temperatureMin: dailyData.temperature_2m_min[i].toFixed(0), weatherCode: dailyData.weather_code[i], + isDay: true, description: this._getWeatherDescription( dailyData.weather_code[i], true diff --git a/src/lib/components/Calendar.svelte b/src/lib/components/Calendar.svelte new file mode 100644 index 0000000..8964c43 --- /dev/null +++ b/src/lib/components/Calendar.svelte @@ -0,0 +1,216 @@ + + +
+ +
+ {#if error} +
{error}
+ {:else if noCalendarsSelected} +
no calendars selected
+ {:else if events.length === 0} +
no upcoming events
+ {:else} +
+ {#each events as event} +
+
+ {formatEventDate(event.start, event.allDay)} +
+ {#if event.link} + + {event.title} + + {:else} +
{event.title}
+ {/if} + {#if event.location} +
{event.location}
+ {/if} + {#if !event.calendarPrimary} +
#{event.calendarName}
+ {/if} +
+ {/each} +
+ {/if} +
+
+ + diff --git a/src/lib/components/Links.svelte b/src/lib/components/Links.svelte index 25f0d4b..855e148 100644 --- a/src/lib/components/Links.svelte +++ b/src/lib/components/Links.svelte @@ -1,7 +1,13 @@
links
+
+ > + + +
+ {#if launcherFeedback} +
{launcherFeedback}
+ {/if} +
{#each columns as column}
.panel { display: flex; + flex-wrap: wrap; gap: 1.5rem; } + .launcher { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + } + .launcher-prefix { + color: var(--txt-3); + } + .launcher-input { + flex: 1; + min-width: 0; + background: var(--bg-2); + border: 2px solid var(--bg-3); + padding: 0.25rem 0.5rem; + } + .launcher-input:focus { + border-color: var(--txt-3); + } + .launcher-input::placeholder { + color: var(--txt-4); + } + .launcher-open { + color: var(--txt-2); + } + .launcher-feedback { + width: 100%; + color: var(--txt-3); + margin-top: -1rem; + margin-bottom: -0.25rem; + } .link:hover .prefix, .link:hover .icon { color: var(--txt-2); diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 7dcb2c4..c679bf2 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -5,6 +5,7 @@ saveSettings, settings, resetSettings, + normalizeMainWidgetOrder, } from '../stores/settings-store.svelte.js' import { themeNames, @@ -14,6 +15,7 @@ import RadioButton from './ui/RadioButton.svelte' import Checkbox from './ui/Checkbox.svelte' import { createTaskBackend } from '../backends/index.js' + import GoogleCalendarAPI from '../api/google-calendar-api.js' import { isChrome } from '../utils/browser-detect.js' import { guessIconSlug, isValidSlug, extractDomain } from '../utils/link-icons.js' import IconPicker from './IconPicker.svelte' @@ -23,6 +25,7 @@ // Check if Google Tasks is available (Chrome only) const googleTasksAvailable = isChrome() + const googleCalendarAvailable = isChrome() // @ts-ignore const version = __APP_VERSION__ @@ -30,6 +33,24 @@ let googleTasksApi = $state(null) let signingIn = $state(false) let signInError = $state('') + let googleCalendarApi = $state(null) + let calendarSigningIn = $state(false) + let calendarSignInError = $state('') + let googleCalendars = $state([]) + let googleCalendarsLoading = $state(false) + let googleCalendarsError = $state('') + let googleCalendarsRequestId = 0 + let todoistProjects = $state([]) + let todoistProjectsLoading = $state(false) + let todoistProjectsError = $state('') + let todoistProjectsRequestId = 0 + const widgetLabels = { + weather: 'weather', + tasks: 'tasks', + calendar: 'calendar', + } + + let widgetReorderMode = $state(false) function googleSignInLabel() { if (settings.googleTasksSignedIn) return 'sign out' @@ -74,6 +95,252 @@ } } + function googleCalendarSignInLabel() { + if (settings.googleCalendarSignedIn) return 'sign out' + if (calendarSignInError) return calendarSignInError + if (calendarSigningIn) return 'signing in...' + return 'sign in with google' + } + + function ensureGoogleCalendarApi() { + if (!googleCalendarApi) { + googleCalendarApi = new GoogleCalendarAPI() + } + return googleCalendarApi + } + + async function handleGoogleCalendarSignIn() { + try { + calendarSigningIn = true + calendarSignInError = '' + await ensureGoogleCalendarApi().signIn() + settings.googleCalendarSignedIn = true + saveSettings(settings) + } catch (err) { + console.error('google calendar sign in failed:', err) + calendarSignInError = 'sign in failed' + settings.googleCalendarSignedIn = false + } finally { + calendarSigningIn = false + } + } + + async function handleGoogleCalendarSignOut() { + try { + await ensureGoogleCalendarApi().signOut() + settings.googleCalendarSignedIn = false + saveSettings(settings) + calendarSignInError = '' + } catch (err) { + console.error('google calendar sign out failed:', err) + } + } + + async function loadGoogleCalendars() { + const requestId = ++googleCalendarsRequestId + + if (!settings.googleCalendarSignedIn) { + googleCalendars = [] + googleCalendarsLoading = false + googleCalendarsError = '' + return + } + + googleCalendarsLoading = true + googleCalendarsError = '' + + try { + const calendars = await ensureGoogleCalendarApi().getCalendars() + if (requestId !== googleCalendarsRequestId) return + + googleCalendars = calendars + + if (Array.isArray(settings.googleCalendarVisibleCalendarIds)) { + const validCalendarIds = new Set( + googleCalendars.map((calendar) => String(calendar.id)) + ) + settings.googleCalendarVisibleCalendarIds = + settings.googleCalendarVisibleCalendarIds.filter( + (calendarId) => + validCalendarIds.has(String(calendarId)) + ) + } + } catch (err) { + if (requestId !== googleCalendarsRequestId) return + googleCalendars = [] + googleCalendarsError = 'failed to load calendars' + console.error('google calendar list load failed:', err) + } finally { + if (requestId === googleCalendarsRequestId) { + googleCalendarsLoading = false + } + } + } + + function isGoogleCalendarSelected(calendarId) { + if (settings.googleCalendarVisibleCalendarIds === null) return true + const id = String(calendarId) + return (settings.googleCalendarVisibleCalendarIds || []).some( + (selectedId) => String(selectedId) === id + ) + } + + function setGoogleCalendarVisibility(calendarId, visible) { + const id = String(calendarId) + const allCalendarIds = googleCalendars.map((calendar) => + String(calendar.id) + ) + const currentCalendarIds = + settings.googleCalendarVisibleCalendarIds === null + ? allCalendarIds + : (settings.googleCalendarVisibleCalendarIds || []).map( + (selectedId) => String(selectedId) + ) + + const nextCalendarIds = new Set(currentCalendarIds) + if (visible) { + nextCalendarIds.add(id) + } else { + nextCalendarIds.delete(id) + } + + settings.googleCalendarVisibleCalendarIds = Array.from(nextCalendarIds) + } + + async function loadTodoistProjects(token) { + const trimmedToken = token?.trim() + if (!trimmedToken) { + todoistProjectsRequestId++ + todoistProjects = [] + todoistProjectsLoading = false + todoistProjectsError = '' + return + } + + const requestId = ++todoistProjectsRequestId + todoistProjectsLoading = true + todoistProjectsError = '' + + try { + const formData = new FormData() + formData.append('sync_token', '*') + formData.append('resource_types', JSON.stringify(['projects'])) + + const response = await fetch('https://api.todoist.com/api/v1/sync', { + method: 'POST', + headers: { + Authorization: `Bearer ${trimmedToken}`, + }, + body: formData, + }) + + if (!response.ok) { + throw new Error(`todoist projects fetch failed: ${response.status}`) + } + + const data = await response.json() + + if (requestId !== todoistProjectsRequestId) return + + todoistProjects = (data.projects || []) + .filter((project) => !project.is_deleted) + .map((project) => ({ + id: project.id, + name: project.name, + childOrder: project.child_order ?? 0, + })) + .sort((a, b) => a.childOrder - b.childOrder) + .map(({ id, name }) => ({ id, name })) + + if (Array.isArray(settings.todoistVisibleProjectIds)) { + const validProjectIds = new Set( + todoistProjects.map((project) => String(project.id)) + ) + settings.todoistVisibleProjectIds = + settings.todoistVisibleProjectIds.filter((id) => + validProjectIds.has(String(id)) + ) + } + } catch (err) { + if (requestId !== todoistProjectsRequestId) return + todoistProjects = [] + todoistProjectsError = 'failed to load projects' + console.error('todoist projects sync failed:', err) + } finally { + if (requestId === todoistProjectsRequestId) { + todoistProjectsLoading = false + } + } + } + + function isTodoistProjectSelected(projectId) { + if (settings.todoistVisibleProjectIds === null) return true + const id = String(projectId) + return (settings.todoistVisibleProjectIds || []).some( + (selectedId) => String(selectedId) === id + ) + } + + function setTodoistProjectVisibility(projectId, visible) { + const id = String(projectId) + const allProjectIds = todoistProjects.map((project) => + String(project.id) + ) + const currentProjectIds = + settings.todoistVisibleProjectIds === null + ? allProjectIds + : (settings.todoistVisibleProjectIds || []).map((selectedId) => + String(selectedId) + ) + + const nextProjectIds = new Set(currentProjectIds) + if (visible) { + nextProjectIds.add(id) + } else { + nextProjectIds.delete(id) + } + + settings.todoistVisibleProjectIds = Array.from(nextProjectIds) + } + + $effect(() => { + const shouldLoadTodoistProjects = + showSettings && + settings.taskBackend === 'todoist' && + Boolean(settings.todoistApiToken?.trim()) + + if (!shouldLoadTodoistProjects) { + todoistProjectsRequestId++ + todoistProjects = [] + todoistProjectsLoading = false + todoistProjectsError = '' + return + } + + loadTodoistProjects(settings.todoistApiToken) + }) + + $effect(() => { + const shouldLoadGoogleCalendars = + showSettings && + googleCalendarAvailable && + settings.googleCalendarSignedIn + + if (!shouldLoadGoogleCalendars) { + googleCalendarsRequestId++ + googleCalendars = [] + googleCalendarsLoading = false + googleCalendarsError = '' + return + } + + loadGoogleCalendars() + }) + + $effect(() => { + ensureMainWidgetOrder() + }) + let iconPickerOpen = $state(null) let iconPickerRef = $state(null) @@ -111,6 +378,9 @@ function handleClose() { saveSettings(settings) + widgetReorderMode = false + draggedWidgetIndex = null + widgetDropSlotIndex = null closeSettings() } @@ -182,10 +452,104 @@ { key: 'txtErr', label: 'error' }, ] + function arraysEqual(a, b) { + if (a.length !== b.length) return false + return a.every((value, index) => value === b[index]) + } + + function getWidgetOrderForEditor(order) { + const normalized = normalizeMainWidgetOrder(order) + if (googleCalendarAvailable) return normalized + return normalized.filter((widgetId) => widgetId !== 'calendar') + } + + function ensureMainWidgetOrder() { + const normalized = normalizeMainWidgetOrder(settings.mainWidgetOrder) + if (!arraysEqual(normalized, settings.mainWidgetOrder || [])) { + settings.mainWidgetOrder = normalized + } + } + + function isWidgetShown(widgetId) { + switch (widgetId) { + case 'weather': + return settings.showWeather + case 'tasks': + return settings.showTasks + case 'calendar': + return googleCalendarAvailable && settings.showCalendar + default: + return false + } + } + + function toggleWidgetReorderMode() { + widgetReorderMode = !widgetReorderMode + if (widgetReorderMode) { + ensureMainWidgetOrder() + } else { + draggedWidgetIndex = null + widgetDropSlotIndex = null + } + } + + function reorderMainWidgets(slotIndex) { + ensureMainWidgetOrder() + + const visibleOrder = getWidgetOrderForEditor(settings.mainWidgetOrder) + if (draggedWidgetIndex === null || draggedWidgetIndex >= visibleOrder.length) { + return + } + + if ( + slotIndex === draggedWidgetIndex || + slotIndex === draggedWidgetIndex + 1 + ) { + return + } + + const nextVisibleOrder = [...visibleOrder] + const draggedItem = nextVisibleOrder[draggedWidgetIndex] + nextVisibleOrder.splice(draggedWidgetIndex, 1) + const adjustedSlotIndex = + draggedWidgetIndex < slotIndex ? slotIndex - 1 : slotIndex + nextVisibleOrder.splice(adjustedSlotIndex, 0, draggedItem) + + if (googleCalendarAvailable) { + settings.mainWidgetOrder = nextVisibleOrder + return + } + + const normalizedOrder = normalizeMainWidgetOrder(settings.mainWidgetOrder) + const calendarIndex = normalizedOrder.indexOf('calendar') + const orderWithoutCalendar = normalizedOrder.filter( + (widgetId) => widgetId !== 'calendar' + ) + + if (!arraysEqual(orderWithoutCalendar, nextVisibleOrder)) { + let insertAt = nextVisibleOrder.length + if (calendarIndex >= 0 && calendarIndex < normalizedOrder.length - 1) { + const nextWidgetAfterCalendar = normalizedOrder[calendarIndex + 1] + const nextIndex = nextVisibleOrder.indexOf(nextWidgetAfterCalendar) + if (nextIndex >= 0) { + insertAt = nextIndex + } + } + + nextVisibleOrder.splice(insertAt, 0, 'calendar') + } + + settings.mainWidgetOrder = normalizeMainWidgetOrder(nextVisibleOrder) + } + // Drag and drop state let draggedIndex = $state(null) let dropSlotIndex = $state(null) // Which slot (between items) to drop into + // Main widget reorder state + let draggedWidgetIndex = $state(null) + let widgetDropSlotIndex = $state(null) + function handleDragStart(event, index) { draggedIndex = index event.dataTransfer.effectAllowed = 'move' @@ -240,6 +604,34 @@ dropSlotIndex = null } + function handleWidgetDragStart(event, index) { + draggedWidgetIndex = index + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', String(index)) + } + + function handleWidgetDropZoneDragOver(event, slotIndex) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + widgetDropSlotIndex = slotIndex + } + + function handleWidgetDropZoneDragLeave() { + widgetDropSlotIndex = null + } + + function handleWidgetDropZoneDrop(event, slotIndex) { + event.preventDefault() + reorderMainWidgets(slotIndex) + draggedWidgetIndex = null + widgetDropSlotIndex = null + } + + function handleWidgetDragEnd() { + draggedWidgetIndex = null + widgetDropSlotIndex = null + } + let locationLoading = $state(false) let locationError = $state(null) let locationErrorTimeout = null @@ -322,7 +714,12 @@
-
widgets
+
+
widgets
+ +
clock stats @@ -330,8 +727,69 @@ >weather tasks + {#if googleCalendarAvailable} + calendar + {/if} links
+ {#if widgetReorderMode} + {@const widgetEditorOrder = getWidgetOrderForEditor(settings.mainWidgetOrder)} +
+ drag to reorder the main widget row +
+
+ {#each widgetEditorOrder as widgetId, index} +
handleWidgetDropZoneDragOver(e, index)} + ondragleave={handleWidgetDropZoneDragLeave} + ondrop={(e) => handleWidgetDropZoneDrop(e, index)} + role="none" + >
+
+ handleWidgetDragStart(e, index)} + ondragend={handleWidgetDragEnd} + role="button" + tabindex="0">= + {widgetLabels[widgetId]} + + {isWidgetShown(widgetId) ? 'shown' : 'hidden'} + +
+ {/each} +
+ handleWidgetDropZoneDragOver( + e, + widgetEditorOrder.length + )} + ondragleave={handleWidgetDropZoneDragLeave} + ondrop={(e) => + handleWidgetDropZoneDrop( + e, + widgetEditorOrder.length + )} + role="none" + >
+
+ {/if}
theme
@@ -456,6 +914,64 @@ bind:value={settings.todoistApiToken} />
+ + {#if settings.todoistApiToken?.trim()} +
+
todoist projects
+
+ + + +
+ {#if todoistProjectsLoading} +
loading projects...
+ {:else if todoistProjectsError} +
+ {todoistProjectsError} +
+ {:else if todoistProjects.length === 0} +
no projects found
+ {:else} +
+ {#each todoistProjects as project} + + setTodoistProjectVisibility( + project.id, + event.target.checked + )} + > + {project.name} + + {/each} +
+ {/if} +
+ {/if} {/if} {#if settings.taskBackend === 'google-tasks'} @@ -473,6 +989,90 @@
{/if} + {#if googleCalendarAvailable} +
+
google calendar authentication
+ +
+
+ + +
+ + {#if settings.googleCalendarSignedIn} +
+
google calendars
+
+ + + +
+ {#if googleCalendarsLoading} +
loading calendars...
+ {:else if googleCalendarsError} +
{googleCalendarsError}
+ {:else if googleCalendars.length === 0} +
no calendars found
+ {:else} +
+ {#each googleCalendars as calendar} + + setGoogleCalendarVisibility( + calendar.id, + event.target.checked + )} + > + {calendar.name} + {calendar.primary ? ' (primary)' : ''} + + {/each} +
+ {/if} +
+ {/if} + {/if} +
weather forecast
@@ -839,12 +1439,24 @@ flex: 1; margin-bottom: 1.5rem; } + .top-gap { + margin-top: 0.5rem; + } .group > label, .col > label, .setting-label { display: block; margin-bottom: 0.5rem; } + .widgets-header { + display: flex; + justify-content: space-between; + align-items: center; + + .setting-label { + margin: 0; + } + } .group input[type='text'], .group input[type='password'], .group input[type='number'], @@ -899,6 +1511,35 @@ height: 2px; background-color: var(--txt-2); } + .widget-order-list { + display: flex; + flex-direction: column; + margin-top: 0.5rem; + border: 2px solid var(--bg-3); + background: var(--bg-2); + padding: 0.25rem 0.5rem; + } + .widget-order-item { + display: flex; + align-items: center; + min-height: 2rem; + gap: 0.25rem; + } + .widget-order-item.dragging { + opacity: 0.5; + } + .widget-order-name { + color: var(--txt-2); + flex: 1; + } + .widget-order-state { + color: var(--txt-3); + font-size: 0.75rem; + text-transform: uppercase; + } + .widget-order-state.active { + color: var(--txt-2); + } .link { display: flex; align-items: center; @@ -953,6 +1594,22 @@ gap: 1rem; margin-bottom: 1rem; } + .todoist-project-actions { + display: flex; + gap: 1rem; + margin-bottom: 0.5rem; + } + .todoist-project-list { + display: grid; + gap: 0.25rem; + max-height: 12rem; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--bg-3) var(--bg-1); + } + .helper-text { + color: var(--txt-3); + } .bracket { color: var(--txt-3); } diff --git a/src/lib/components/Tasks.svelte b/src/lib/components/Tasks.svelte index 1e321d4..156a387 100644 --- a/src/lib/components/Tasks.svelte +++ b/src/lib/components/Tasks.svelte @@ -22,7 +22,26 @@ let initialLoad = $state(true) let previousToken = $state(null) let previousBackend = $state(null) - let taskCount = $derived(tasks.filter((task) => !task.checked).length) + let filteredTasks = $derived.by(() => { + if (settings.taskBackend !== 'todoist') { + return tasks + } + + if (settings.todoistVisibleProjectIds === null) { + return tasks + } + + const visibleProjectIds = new Set( + (settings.todoistVisibleProjectIds || []).map((id) => String(id)) + ) + + return tasks.filter((task) => + visibleProjectIds.has(String(task.project_id)) + ) + }) + let taskCount = $derived( + filteredTasks.filter((task) => !task.checked).length + ) let taskLabel = $derived(taskCount === 1 ? 'task' : 'tasks') let backendUrl = $derived.by(() => { if (settings.taskBackend === 'todoist') @@ -209,6 +228,17 @@ id: p.id, name: p.name, })) + + // Remove project IDs that no longer exist. + if (Array.isArray(settings.todoistVisibleProjectIds)) { + const validProjectIds = new Set( + availableProjects.map((project) => String(project.id)) + ) + settings.todoistVisibleProjectIds = + settings.todoistVisibleProjectIds.filter((id) => + validProjectIds.has(String(id)) + ) + } } else if (settings.taskBackend === 'google-tasks') { availableProjects = (api.data?.tasklists || []).map((tl) => ({ id: tl.id, @@ -432,7 +462,7 @@ {parsedProject} disabled={addingTask} loading={addingTask} - show={tasks.length === 0} + show={filteredTasks.length === 0} onsubmit={addTask} />
@@ -440,7 +470,7 @@
- {#each tasks as task} + {#each filteredTasks as task}