+
{#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
+
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
+
+
+ {/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}