adds strava integration
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "意大利语",
|
||||
|
||||
25
web/src/lib/models/api/integration_schema.ts
Normal file
25
web/src/lib/models/api/integration_schema.ts
Normal 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 }
|
||||
25
web/src/lib/models/integration.ts
Normal file
25
web/src/lib/models/integration.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
74
web/src/lib/stores/integration_store.ts
Normal file
74
web/src/lib/stores/integration_store.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export enum Collection {
|
||||
categories = "categories",
|
||||
comments = "comments",
|
||||
follows = "follows",
|
||||
integrations = "integrations",
|
||||
list_share = "list_share",
|
||||
lists = "lists",
|
||||
notifications = "notifications",
|
||||
|
||||
23
web/src/routes/api/v1/integration/+server.ts
Normal file
23
web/src/routes/api/v1/integration/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
32
web/src/routes/api/v1/integration/[id]/+server.ts
Normal file
32
web/src/routes/api/v1/integration/[id]/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
201
web/src/routes/settings/integrations/+page.svelte
Normal file
201
web/src/routes/settings/integrations/+page.svelte
Normal 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
|
||||
>
|
||||
7
web/src/routes/settings/integrations/+page.ts
Normal file
7
web/src/routes/settings/integrations/+page.ts
Normal 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) }
|
||||
};
|
||||
@@ -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')
|
||||
}
|
||||
Reference in New Issue
Block a user