adds strava integration

This commit is contained in:
Christian Beutel
2025-02-04 11:43:48 +01:00
parent 3ab591177a
commit a0b6480854
32 changed files with 1737 additions and 57 deletions

View File

@@ -3,7 +3,7 @@ import { pb } from '$lib/pocketbase';
import { currentUser } from '$lib/stores/user_store';
pb.authStore.loadFromCookie(document.cookie)
pb.authStore.onChange(() => {
pb.authStore.onChange(() => {
currentUser.set(pb.authStore.model as User)
document.cookie = pb.authStore.exportToCookie({ httpOnly: false, secure: location.protocol === "https:" })
document.cookie = pb.authStore.exportToCookie({ httpOnly: false, secure: location.protocol === "https:" || location.hostname === "localhost", sameSite: 'none' })
}, true)

View File

@@ -67,7 +67,7 @@ const auth: Handle = async ({ event, resolve }) => {
try {
// get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any)
if (pb.authStore.isValid) {
await pb.collection('users').authRefresh({requestKey: null})
await pb.collection('users').authRefresh({ requestKey: null })
}
} catch (_) {
// clear the auth store on failed refresh
@@ -78,7 +78,7 @@ const auth: Handle = async ({ event, resolve }) => {
let settings: Settings | undefined;
if (pb.authStore.model) {
meiliApiKey = pb.authStore.model.token
settings = await pb.collection('settings').getFirstListItem<Settings>(`user="${pb.authStore.model.id}"`, {requestKey: null})
settings = await pb.collection('settings').getFirstListItem<Settings>(`user="${pb.authStore.model.id}"`, { requestKey: null })
} else {
const r = await event.fetch(pb.buildUrl("/public/search/token"));
const response = await r.json();
@@ -105,7 +105,7 @@ const auth: Handle = async ({ event, resolve }) => {
// send back the default 'pb_auth' cookie to the client with the latest store state
response.headers.set(
'set-cookie',
pb.authStore.exportToCookie({ httpOnly: false, secure: event.url.protocol === "https:" })
pb.authStore.exportToCookie({ httpOnly: false, secure: event.url.protocol === "https:" || event.url.hostname === "localhost", sameSite: "none" })
)
return response

View File

@@ -1,11 +1,11 @@
<script lang="ts">
interface Props {
name?: string;
value?: boolean;
label?: string;
error?: string;
onchange?: (value: boolean) => void
disabled?: boolean;
onchange?: (value: boolean) => void;
}
let {
@@ -13,30 +13,35 @@
value = $bindable(false),
label = "",
error = "",
onchange
disabled = false,
onchange,
}: Props = $props();
function handleToggleChange() {
onchange?.(value);
}
</script>
<label class="relative my-2 inline-flex items-center cursor-pointer">
<input
{name}
bind:checked={value}
type="checkbox"
class="sr-only peer"
value="1"
onchange={handleToggleChange}
/>
<div
class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"
></div>
<span class="ms-3 text-sm font-medium">{label}</span>
</label>
<div>
<label class="relative my-2 inline-flex items-center" class:cursor-pointer={!disabled}>
<input
{name}
bind:checked={value}
type="checkbox"
class="sr-only peer"
value="1"
{disabled}
onchange={handleToggleChange}
/>
<div
class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"
></div>
{#if label}
<span class="ms-3 text-sm font-medium">{label}</span>
{/if}
</label>
<span class="toggle-error text-xs text-red-400">
{error}
</span>
<p class="toggle-error text-xs text-red-400">
{error}
</p>
</div>

View File

@@ -362,7 +362,7 @@
{:else}
<EmptyStateDescription></EmptyStateDescription>
{/if}
<h4 class="text-2xl font-semibold mb-6 mt-12">{$_("route")}</h4>
<h4 class="text-2xl font-semibold mb-6 mt-12">{$_("route", { values: { n: 2 } })}</h4>
{#if mode === "overview"}
<div
class="relative border border-input-border rounded-xl p-2 mb-6 text-xs"

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Löschen bestätigen",
"confirm-publish": "Veröffentlichen bestätigen",
"confirm-share": "Teilen bestätigen",
"connect": "",
"contribute": "Mitwirken",
"copy-link": "Link kopieren",
"create-new-list": "Neue Liste erstellen",
@@ -95,6 +96,7 @@
"english": "Englisch",
"entry": "Eintrag",
"error-creating-user": "Fehler beim Erstellen des Nutzers",
"error-disabling-strava-integration": "",
"error-during-login": "Fehler beim Login",
"error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden",
"error-exporting-trail": "Fehler beim Exportieren des Trails",
@@ -103,6 +105,7 @@
"error-reading-file": "Fehler beim Lesen der Datei",
"error-saving-list": "Fehler beim Speichern der Liste",
"error-saving-trail": "Fehler beim Speichern der Route",
"error-setting-up-strava-integration": "",
"error-updating-password": "Fehler beim Aktualisieren des Passworts",
"est-duration": "Gesch. Dauer",
"explore": "Erkunden",
@@ -140,6 +143,7 @@
"import": "Importieren",
"import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...",
"include-description": "Beschreibung übernehmen",
"integrations": "",
"invalid-date": "Ungültiges Datum",
"invalid-username": "Ungültiger Nutzername",
"italian": "Italienisch",
@@ -229,7 +233,7 @@
"removed-trail-from": "Route entfernt aus",
"required": "Pflichtfeld",
"reset-password": "Passwort zurücksetzen",
"route": "",
"route": "{n, plural, =1 {Route} other {Routen}}",
"route-point": "Routenpunkt",
"save": "Speichern",
"save-list": "Liste speichern",
@@ -291,4 +295,4 @@
"welcome_to": "Willkommen bei",
"wrong-username-or-password": "Falscher Nutzername oder falsches Passwort",
"you-can": "Du kannst"
}
}

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirm Deletion",
"confirm-publish": "Confirm publishing",
"confirm-share": "Confirm share",
"connect": "Connect",
"contribute": "Contribute",
"copy-link": "Copy Link",
"create-new-list": "Create new list",
@@ -95,6 +96,7 @@
"english": "English",
"entry": "Entry",
"error-creating-user": "Error creating user",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-during-login": "Error during login",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail",
@@ -103,6 +105,7 @@
"error-reading-file": "Error reading file",
"error-saving-list": "Error saving list",
"error-saving-trail": "Error saving trail",
"error-setting-up-strava-integration": "Error setting up strava integration",
"error-updating-password": "Error updating password",
"est-duration": "Est. duration",
"explore": "Explore",
@@ -140,6 +143,7 @@
"import": "Import",
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Include description",
"integrations": "Integrations",
"invalid-date": "Invalid Date",
"invalid-username": "Invalid username",
"italian": "Italian",
@@ -229,7 +233,7 @@
"removed-trail-from": "Removed trail from",
"required": "Required",
"reset-password": "Reset Password",
"route": "Route",
"route": "{n, plural, =1 {Route} other {Routes}}",
"route-point": "Route Point",
"save": "Save",
"save-list": "Save List",
@@ -291,4 +295,4 @@
"welcome_to": "Welcome to",
"wrong-username-or-password": "Wrong username or password",
"you-can": "You can"
}
}

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirmar Borrado",
"confirm-publish": "Confirmar publicación",
"confirm-share": "Confirmar compartir",
"connect": "",
"contribute": "Contribuir",
"copy-link": "Copiar Enlace",
"create-new-list": "Crear una nueva lista",
@@ -95,6 +96,7 @@
"english": "Inglés",
"entry": "Entrada",
"error-creating-user": "Error creando el usuario",
"error-disabling-strava-integration": "",
"error-during-login": "Error durante el acceso",
"error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico",
"error-exporting-trail": "Error exportando la ruta",
@@ -103,6 +105,7 @@
"error-reading-file": "Error leyendo el archivo",
"error-saving-list": "Error guardando la lista",
"error-saving-trail": "Error guardando la ruta",
"error-setting-up-strava-integration": "",
"error-updating-password": "Error actualizando la contraseña",
"est-duration": "Duración estimada",
"explore": "Explora",
@@ -140,6 +143,7 @@
"import": "Importar",
"import-hint": "Selecciona o arrastra aquí archivos GPX, FIT, KML o TCX...",
"include-description": "Incluir descripción",
"integrations": "",
"invalid-date": "Fecha no válida",
"invalid-username": "Usuario no válido",
"italian": "Italiano",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirmer la suppression",
"confirm-publish": "Confirmer la publication",
"confirm-share": "Confirmer le partage",
"connect": "",
"contribute": "Contribuer",
"copy-link": "Copier le lien",
"create-new-list": "Créer une nouvelle liste",
@@ -95,6 +96,7 @@
"english": "Anglais",
"entry": "Entrée",
"error-creating-user": "Erreur durant la création de l'utilisateur",
"error-disabling-strava-integration": "",
"error-during-login": "Erreur durant la connexion",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Erreur lors de l'export de l'itinéraire",
@@ -103,6 +105,7 @@
"error-reading-file": "Erreur de lecture du fichier",
"error-saving-list": "Error saving list",
"error-saving-trail": "Erreur lors de l'enregistrement de l'itinéraire",
"error-setting-up-strava-integration": "",
"error-updating-password": "Erreur lors de la mise à jour du mot de passe",
"est-duration": "Temps estimé",
"explore": "Explorer",
@@ -140,6 +143,7 @@
"import": "Importer",
"import-hint": "Sélectionnez ou glissez des fichiers GPX, FIT, KML ou TCX ici...",
"include-description": "Inclure la description",
"integrations": "",
"invalid-date": "Date invalide",
"invalid-username": "Nom d'utilisateur invalide",
"italian": "Italien",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirm Deletion",
"confirm-publish": "Confirm publishing",
"confirm-share": "Confirm share",
"connect": "",
"contribute": "Hozzájárulás",
"copy-link": "Copy Link",
"create-new-list": "Új lista létrehozása",
@@ -95,6 +96,7 @@
"english": "Angol",
"entry": "Bejegyzés",
"error-creating-user": "Hiba felhasználó hozzáadása közben",
"error-disabling-strava-integration": "",
"error-during-login": "Hiba bejelentkezés közben",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail",
@@ -103,6 +105,7 @@
"error-reading-file": "Hiba a fájl olvasása közben",
"error-saving-list": "Error saving list",
"error-saving-trail": "Error saving trail",
"error-setting-up-strava-integration": "",
"error-updating-password": "Error updating password",
"est-duration": "Becsült időtartam",
"explore": "Felfedezés",
@@ -140,6 +143,7 @@
"import": "Import",
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Include description",
"integrations": "",
"invalid-date": "Érvénytelen dátum",
"invalid-username": "Érvénytelen felhasználó",
"italian": "Olasz",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Conferma eliminazione",
"confirm-publish": "Conferma pubblicazione",
"confirm-share": "Conferma condivisione",
"connect": "",
"contribute": "Contribuisci",
"copy-link": "Copia link",
"create-new-list": "Crea nuova lista",
@@ -95,6 +96,7 @@
"english": "Inglese",
"entry": "Voce",
"error-creating-user": "Errore nella creazione dell'utente",
"error-disabling-strava-integration": "",
"error-during-login": "Errore durante il login",
"error-during-password-reset": "Impossibile inviare email per ripristinare la password",
"error-exporting-trail": "Errore durante l'esportazione del percorso",
@@ -103,6 +105,7 @@
"error-reading-file": "Errore durante la lettura del file",
"error-saving-list": "Errore salvando la lista",
"error-saving-trail": "Errore nel salvataggio del percorso",
"error-setting-up-strava-integration": "",
"error-updating-password": "Errore nell'aggiornamento della password",
"est-duration": "Durata stimata",
"explore": "Esplora",
@@ -140,6 +143,7 @@
"import": "Importa",
"import-hint": "Seleziona o trascina qui i file GPX, FIT, KML o TCX...",
"include-description": "Adotta descrizione",
"integrations": "",
"invalid-date": "Data non valida",
"invalid-username": "Nome utente non valido",
"italian": "Italiano",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirm Deletion",
"confirm-publish": "Confirm publishing",
"confirm-share": "Confirm share",
"connect": "",
"contribute": "Bijdragen",
"copy-link": "Copy Link",
"create-new-list": "Nieuwe lijst",
@@ -95,6 +96,7 @@
"english": "Engels",
"entry": "Item",
"error-creating-user": "Het account kan niet worden aangemaakt",
"error-disabling-strava-integration": "",
"error-during-login": "Het inloggen is mislukt",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail",
@@ -103,6 +105,7 @@
"error-reading-file": "Het bestand kan niet worden ingelezen",
"error-saving-list": "Error saving list",
"error-saving-trail": "Error saving trail",
"error-setting-up-strava-integration": "",
"error-updating-password": "Error updating password",
"est-duration": "Geschatte duur",
"explore": "Verkennen",
@@ -140,6 +143,7 @@
"import": "Import",
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Inclusief beschrijving",
"integrations": "",
"invalid-date": "Ongeldige datum",
"invalid-username": "Ongeldige gebruikersnaam",
"italian": "Italiaans",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Potwierdź usunięcie",
"confirm-publish": "Potwierdź publikację",
"confirm-share": "Potwierdź udostępnienie",
"connect": "",
"contribute": "Kontrybuuj",
"copy-link": "Kopiuj link",
"create-new-list": "Stwórz nową listę",
@@ -95,6 +96,7 @@
"english": "Angielski",
"entry": "Pozycja",
"error-creating-user": "Błąd tworzenia użytkownika",
"error-disabling-strava-integration": "",
"error-during-login": "Błąd podczas logowania",
"error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła",
"error-exporting-trail": "Błąd podczas eksportowania szlaku",
@@ -103,6 +105,7 @@
"error-reading-file": "Błąd wczytywania pliku",
"error-saving-list": "Błąd przy zapisywaniu listy",
"error-saving-trail": "Błąd podczas zapisywania szlaku",
"error-setting-up-strava-integration": "",
"error-updating-password": "Błąd podczas aktualizacji hasła",
"est-duration": "Szacowany czas",
"explore": "Eksploruj",
@@ -140,6 +143,7 @@
"import": "Importuj",
"import-hint": "Wybierz lub przeciągnij tutaj plik GPX, FIT, KML lub TCX...",
"include-description": "Dołącz opis",
"integrations": "",
"invalid-date": "Nieprawidłowa data",
"invalid-username": "Błędna nazwa użytkownika",
"italian": "Włoski",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirmar eliminação",
"confirm-publish": "Confirm publishing",
"confirm-share": "Confirmar partilha",
"connect": "",
"contribute": "Contribuir",
"copy-link": "Copiar ligação",
"create-new-list": "Criar nova lista",
@@ -95,6 +96,7 @@
"english": "Inglês",
"entry": "Entrada",
"error-creating-user": "Erro ao criar utilizador",
"error-disabling-strava-integration": "",
"error-during-login": "Erro durante o login",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Erro na exportação do percurso",
@@ -103,6 +105,7 @@
"error-reading-file": "Erro ao ler o arquivo",
"error-saving-list": "Erro ao gravar lista",
"error-saving-trail": "Erro ao gravar percurso",
"error-setting-up-strava-integration": "",
"error-updating-password": "Erro ao atualizar password",
"est-duration": "Duração prevista",
"explore": "Explorar",
@@ -140,6 +143,7 @@
"import": "Importar",
"import-hint": "Selecionar ou arrastar ficheiros GPX, FIT, KML ou TCX para aqui...",
"include-description": "Incluir descrição",
"integrations": "",
"invalid-date": "Data inválida",
"invalid-username": "Nome de usuário inválido",
"italian": "Italiano",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "确认删除",
"confirm-publish": "Confirm publishing",
"confirm-share": "确认分享",
"connect": "",
"contribute": "贡献",
"copy-link": "复制链接",
"create-new-list": "创建新列表",
@@ -95,6 +96,7 @@
"english": "英语",
"entry": "日程",
"error-creating-user": "创建用户错误",
"error-disabling-strava-integration": "",
"error-during-login": "登录错误",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "导出路线失败",
@@ -103,6 +105,7 @@
"error-reading-file": "读取文件错误",
"error-saving-list": "保存列表失败",
"error-saving-trail": "保存路线失败",
"error-setting-up-strava-integration": "",
"error-updating-password": "更新密码失败",
"est-duration": "预计时长",
"explore": "探索",
@@ -140,6 +143,7 @@
"import": "导入",
"import-hint": "在此选择或拖拽GPX、FIT、KML或TCX文件...",
"include-description": "包含描述",
"integrations": "",
"invalid-date": "无效日期",
"invalid-username": "无效用户名",
"italian": "意大利语",

View File

@@ -0,0 +1,25 @@
import { z, ZodType } from "zod";
import type { Integration } from "../integration";
const StravaSchema = z.object({
clientId: z.number({ coerce: true }).int().positive(),
clientSecret: z.string().length(40),
routes: z.boolean(),
activities: z.boolean(),
accessToken: z.string().length(40).optional(),
refreshToken: z.string().length(40).optional(),
expiresAt: z.number().int().positive().optional(),
active: z.boolean()
})
const IntegrationCreateSchema = z.object({
user: z.string().length(15),
strava: StravaSchema,
}) satisfies ZodType<Integration>
const IntegrationUpdateSchema = z.object({
strava: StravaSchema.optional(),
}) satisfies ZodType<Partial<Integration>>
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema }

View File

@@ -0,0 +1,25 @@
interface BaseIntegration {
active: boolean
}
interface StravaIntegration extends BaseIntegration {
clientId: string | number;
clientSecret: string;
routes: boolean;
activities: boolean;
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
}
export class Integration {
id?: string;
user: string;
strava?: StravaIntegration;
constructor(user: string, strava?: StravaIntegration) {
this.user = user;
this.strava = strava;
}
}

View File

@@ -0,0 +1,74 @@
import { Integration } from "$lib/models/integration";
import { pb } from "$lib/pocketbase";
import { APIError } from "$lib/util/api_util";
import { type ListResult } from "pocketbase";
import { writable, type Writable } from "svelte/store";
export const integrations: Writable<Integration[]> = writable([])
export async function integrations_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f('/api/v1/integration' + new URLSearchParams({
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const fetchedIntegrations: ListResult<Integration> = await r.json();
integrations.set(fetchedIntegrations.items);
return fetchedIntegrations.items;
}
export async function integrations_create(integration: Integration) {
if (!pb.authStore.model) {
throw new Error("Unauthenticated");
}
integration.user = pb.authStore.model!.id;
let r = await fetch('/api/v1/integration', {
method: 'PUT',
body: JSON.stringify(integration),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const model: Integration = await r.json();
return model;
}
export async function integrations_update(integration: Integration, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f('/api/v1/integration/' + integration.id, {
method: 'POST',
body: JSON.stringify(integration),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const model: Integration = await r.json();
return model;
}
export async function integrations_delete(integration: Integration) {
const r = await fetch('/api/v1/integration/' + integration.id, {
method: 'DELETE',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
}

View File

@@ -23,6 +23,7 @@ export enum Collection {
categories = "categories",
comments = "comments",
follows = "follows",
integrations = "integrations",
list_share = "list_share",
lists = "lists",
notifications = "notifications",

View File

@@ -0,0 +1,23 @@
import { IntegrationCreateSchema } from "$lib/models/api/integration_schema";
import type { Integration } from "$lib/models/integration";
import { Collection, create, handleError, list } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await list<Integration>(event, Collection.integrations);
return json(r)
} catch (e) {
throw handleError(e)
}
}
export async function PUT(event: RequestEvent) {
try {
const r = await create<Comment>(event, IntegrationCreateSchema, Collection.integrations)
return json(r);
} catch (e) {
throw handleError(e)
}
}

View File

@@ -0,0 +1,32 @@
import { IntegrationUpdateSchema } from "$lib/models/api/integration_schema";
import type { Integration } from "$lib/models/integration";
import { pb } from "$lib/pocketbase";
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await show<Integration>(event, Collection.integrations)
return json(r)
} catch (e: any) {
throw handleError(e)
}
}
export async function POST(event: RequestEvent) {
try {
const r = await update<Integration>(event, IntegrationUpdateSchema, Collection.integrations)
return json(r);
} catch (e: any) {
throw handleError(e)
}
}
export async function DELETE(event: RequestEvent) {
try {
const r = await remove(event, Collection.integrations)
return json(r);
} catch (e: any) {
throw handleError(e)
}
}

View File

@@ -21,6 +21,7 @@
},
{ text: $_("notifications"), value: "/settings/notifications" },
{ text: $_("map"), value: "/settings/map" },
{ text: $_("integrations"), value: "/settings/integrations" },
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
{
text: $_("help"),

View File

@@ -0,0 +1,201 @@
<script lang="ts">
import { page } from "$app/state";
import Button from "$lib/components/base/button.svelte";
import Modal from "$lib/components/base/modal.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import { StravaSchema } from "$lib/models/api/integration_schema.js";
import { Integration } from "$lib/models/integration.js";
import {
integrations_create,
integrations_update,
} from "$lib/stores/integration_store.js";
import { show_toast } from "$lib/stores/toast_store.js";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
let { data } = $props();
let integration = $state(data.integration);
const scope = "read_all,activity:read_all";
const redirectUri = page.url.href + "/callback/strava";
let stravaSettingsModal: Modal;
let stravaToggleValue: boolean = $derived(
integration?.strava?.active || false,
);
const { form, errors, data: d } = createForm({
initialValues: {
clientId: data.integration?.strava?.clientId ?? "",
clientSecret: data.integration?.strava?.clientSecret ?? "",
routes: data.integration?.strava?.routes ?? true,
activities: data.integration?.strava?.activities ?? true,
active: data.integration?.strava?.active ?? false,
},
extend: validator({
schema: StravaSchema,
}),
onSubmit: async (form) => {
stravaSettingsModal.closeModal();
try {
if (integration) {
integration.strava = {
clientId: form.clientId,
clientSecret: form.clientSecret,
routes: form.routes,
activities: form.activities,
active: integration.strava?.active ?? false,
};
integration = await integrations_update(integration);
} else {
const newIntegration = new Integration("", {
routes: form.routes,
activities: form.activities,
clientId: form.clientId,
clientSecret: form.clientSecret,
active: false,
});
integration = await integrations_create(newIntegration);
}
} catch (e) {
show_toast({
text: $_("error-setting-up-strava-integration"),
icon: "close",
type: "error",
});
}
},
});
async function onStravaToggle(value: boolean) {
if (!integration?.strava) {
return;
}
if (value) {
const authUrl = `https://www.strava.com/oauth/authorize?client_id=${integration.strava.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&approval_prompt=auto`;
window.location.href = authUrl;
} else {
const deauthUrl = `https://www.strava.com/oauth/deauthorize`;
const r = await fetch(deauthUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${integration.strava.accessToken}`,
},
});
if (!r.ok) {
show_toast({
text: $_("error-disabling-strava-integration"),
icon: "close",
type: "error",
});
return;
}
integration.strava = {
clientId: integration.strava.clientId,
clientSecret: integration.strava.clientSecret,
routes: integration.strava.routes,
activities: integration.strava.activities,
accessToken: undefined,
refreshToken: undefined,
expiresAt: undefined,
active: false,
};
try {
integration = await integrations_update(integration);
} catch (e) {
show_toast({
text: $_("error-disabling-strava-integration"),
icon: "close",
type: "error",
});
}
}
}
</script>
<svelte:head>
<title>{$_("settings")} | wanderer</title>
</svelte:head>
<h3 class="text-2xl font-semibold">{$_("integrations")}</h3>
<hr class="mt-4 mb-6 border-input-border" />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<div class="border border-input-border rounded-lg p-4 space-y-4">
<img
src="https://upload.wikimedia.org/wikipedia/commons/c/cb/Strava_Logo.svg"
alt="strava logo"
/>
<div>
<h5 class="text-xl font-semibold">strava</h5>
<p class="text-sm text-gray-500">
Syncs your strava routes with wanderer in regular intervals.
</p>
</div>
<div class="flex items-center justify-between">
<button
class="btn-secondary"
onclick={() => stravaSettingsModal.openModal()}
><i class="fa fa-cogs mr-2"></i>{$_("settings")}</button
>
<Toggle
value={stravaToggleValue}
onchange={onStravaToggle}
disabled={!integration?.strava}
></Toggle>
</div>
</div>
</div>
<Modal
id="strava-settings-modal"
size="max-w-lg"
title={"strava " + $_("settings")}
bind:this={stravaSettingsModal}
>
{#snippet content()}
<form id="strava-settings-form" class="space-y-2" use:form>
<TextField
label="Client-ID"
placeholder="000000"
name="clientId"
error={$errors.clientId}
></TextField>
<TextField
label="Client Secret"
placeholder="de8b3789bd7116d..."
name="clientSecret"
type="password"
error={$errors.clientSecret}
></TextField>
<div class="flex gap-x-4">
<Toggle name="routes" label={$_("route", { values: { n: 2 } })}
></Toggle>
<Toggle
name="activities"
label={$_("activity", { values: { n: 2 } })}
></Toggle>
</div>
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button
class="btn-secondary"
onclick={() => stravaSettingsModal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
form="strava-settings-form"
type="submit"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -0,0 +1,7 @@
import { integrations_index } from "$lib/stores/integration_store";
import { type Load } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch }) => {
const integrations = await integrations_index(fetch)
return { integration: integrations.at(0) }
};

View File

@@ -0,0 +1,55 @@
import { integrations_index, integrations_update } from "$lib/stores/integration_store";
import { error, redirect, type RequestEvent, type ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ url, fetch }) => {
const oauthError = url.searchParams.get('error');
if (oauthError) {
return error(400, {
message: oauthError
});
}
const code = url.searchParams.get('code');
if (!code) {
return error(400, {
message: "No code provided"
});
}
const integrations = await integrations_index(fetch);
if (!integrations.length || !integrations[0].strava) {
return error(400, {
message: "Missing integration record"
});
}
const integration = integrations[0]
const tokenResponse = await fetch('https://www.strava.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: integration.strava?.clientId,
client_secret: integration.strava?.clientSecret,
code,
grant_type: 'authorization_code'
})
});
if (!tokenResponse.ok) {
const r = await tokenResponse.json()
console.error(r)
return error(500, 'Failed to get access token');
}
const { access_token, refresh_token, expires_at } = await tokenResponse.json();
integration.strava!.accessToken = access_token
integration.strava!.refreshToken = refresh_token
integration.strava!.expiresAt = expires_at
integration.strava!.active = true
await integrations_update(integration, fetch);
return redirect(302, '/settings/integrations')
}