project settings page

This commit is contained in:
Nikita Galaiko 2023-02-16 12:15:45 +01:00
parent 374978cc1e
commit de0dd25c90
No known key found for this signature in database
GPG Key ID: EBAB54E845BA519D
15 changed files with 336 additions and 159 deletions

View File

@ -43,7 +43,7 @@ dependencies:
idb-keyval: 6.2.0
mm-jsr: 3.0.2
svelte-icons: 2.1.0
tauri-plugin-log-api: github.com/tauri-apps/tauri-plugin-log/33d9b712e9058ed82c110cb186345215f82b88e2
tauri-plugin-log-api: github.com/tauri-apps/tauri-plugin-log/921afb3366b14ac43e3d8041a7def4b85d4d7192
yjs: 13.5.45
devDependencies:
@ -2574,8 +2574,8 @@ packages:
engines: {node: '>=10'}
dev: true
github.com/tauri-apps/tauri-plugin-log/33d9b712e9058ed82c110cb186345215f82b88e2:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/33d9b712e9058ed82c110cb186345215f82b88e2}
github.com/tauri-apps/tauri-plugin-log/921afb3366b14ac43e3d8041a7def4b85d4d7192:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/921afb3366b14ac43e3d8041a7def4b85d4d7192}
name: tauri-plugin-log-api
version: 0.0.0
dependencies:

View File

@ -1,11 +1,24 @@
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ApiProject {
pub name: String,
pub description: Option<String>,
pub repository_id: String,
pub git_url: String,
pub created_at: String,
pub updated_at: String,
pub sync: bool,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Project {
pub id: String,
pub title: String,
pub path: String,
pub api: Option<ApiProject>,
}
impl AsRef<Project> for Project {
@ -40,6 +53,7 @@ impl Project {
id: uuid::Uuid::new_v4().to_string(),
title,
path: path.to_str().unwrap().to_string(),
api: None,
})
.ok_or_else(|| anyhow!("failed to get title from path"))
}

View File

@ -13,6 +13,7 @@ pub struct Storage {
pub struct UpdateRequest {
id: String,
title: Option<String>,
api: Option<project::ApiProject>,
}
impl Storage {
@ -38,9 +39,15 @@ impl Storage {
.iter_mut()
.find(|p| p.id == update_request.id)
.ok_or_else(|| anyhow::anyhow!("Project not found"))?;
if let Some(title) = &update_request.title {
project.title = title.clone();
}
if let Some(api) = &update_request.api {
project.api = Some(api.clone());
}
let projects = serde_json::to_string(&projects)?;
self.storage.write(PROJECTS_FILE, &projects)?;
Ok(self.get_project(&update_request.id)?.unwrap())

112
src/lib/api.ts Normal file
View File

@ -0,0 +1,112 @@
import { dev } from "$app/environment";
const apiUrl = dev
? new URL("https://test.app.gitbutler.com/api/")
: new URL("https://app.gitbutler.com/api/");
const getUrl = (path: string) => new URL(path, apiUrl).toString();
export type LoginToken = {
token: string;
expires: string;
url: string;
};
export type User = {
id: number;
name: string;
email: string;
picture: string;
locale: string;
created_at: string;
updated_at: string;
access_token: string;
};
export type Project = {
name: string;
description: string | null;
repository_id: string;
git_url: string;
created_at: string;
updated_at: string;
};
const parseResponseJSON = async (response: Response) => {
if (response.status === 204 || response.status === 205) {
return null;
} else if (response.status >= 400) {
throw new Error(
`HTTP Error ${response.statusText}: ${await response.text()}`
);
} else {
return await response.json();
}
};
export default (
{ fetch }: { fetch: typeof window.fetch } = { fetch: window.fetch }
) => ({
login: {
token: {
create: (params: {} = {}): Promise<LoginToken> =>
fetch(getUrl("login/token.json"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
})
.then(parseResponseJSON)
.then((token) => {
const url = new URL(token.url);
url.host = apiUrl.host;
return {
...token,
url: url.toString(),
};
}),
},
user: {
get: (token: string): Promise<User> =>
fetch(getUrl(`login/user/${token}.json`), {
method: "GET",
}).then(parseResponseJSON),
},
},
projects: {
create: (
token: string,
params: { name: string; uid?: string }
): Promise<Project> =>
fetch(getUrl("projects.json"), {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Auth-Token": token,
},
body: JSON.stringify(params),
}).then(parseResponseJSON),
list: (token: string): Promise<Project[]> =>
fetch(getUrl("projects.json"), {
method: "GET",
headers: {
"X-Auth-Token": token,
},
}).then(parseResponseJSON),
get: (token: string, repositoryId: string): Promise<Project> =>
fetch(getUrl(`projects/${repositoryId}.json`), {
method: "GET",
headers: {
"X-Auth-Token": token,
},
}).then(parseResponseJSON),
delete: (token: string, repositoryId: string): Promise<void> =>
fetch(getUrl(`projects/${repositoryId}.json`), {
method: "DELETE",
headers: {
"X-Auth-Token": token,
},
}).then(parseResponseJSON),
},
});

View File

@ -1,68 +0,0 @@
import { dev } from "$app/environment";
const apiUrl = dev
? new URL("https://test.app.gitbutler.com/api/")
: new URL("https://app.gitbutler.com/api/");
const getUrl = (path: string) => new URL(path, apiUrl).toString();
export type LoginToken = {
token: string;
expires: string;
url: string;
};
export type User = {
id: number;
name: string;
email: string;
picture: string;
locale: string;
created_at: string;
updated_at: string;
access_token: string;
};
const parseJSON = async (response: Response) => {
if (response.status === 204 || response.status === 205) {
return null;
}
if (response.status >= 400) {
throw new Error(
`HTTP Error ${response.statusText}: ${await response.text()}`
);
}
return await response.json();
};
export default (
{ fetch }: { fetch: typeof window.fetch } = { fetch: window.fetch }
) => ({
login: {
token: {
create: (params: {} = {}): Promise<LoginToken> =>
fetch(getUrl("login/token.json"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
})
.then(parseJSON)
.then((token) => {
const url = new URL(token.url);
url.host = apiUrl.host;
return {
...token,
url: url.toString(),
};
}),
},
user: {
get: (token: string): Promise<User> =>
fetch(getUrl(`login/user/${token}.json`), {
method: "GET",
}).then(parseJSON),
},
},
});

View File

@ -0,0 +1,46 @@
<script lang="ts">
import type Users from "$lib/users";
import type Api from "$lib/api";
import type { LoginToken } from "$lib/api";
import { derived, writable } from "svelte/store";
import { open } from "@tauri-apps/api/shell";
export let user: Awaited<ReturnType<typeof Users>>;
export let api: Awaited<ReturnType<typeof Api>>;
const pollForUser = async (token: string) => {
const apiUser = await api.login.user.get(token).catch(() => null);
if (apiUser) {
user.set(apiUser);
return apiUser;
}
return new Promise((resolve) => {
setTimeout(async () => {
resolve(await pollForUser(token));
}, 1000);
});
};
const token = writable<LoginToken | null>(null);
const authUrl = derived(token, ($token) => $token?.url as string);
</script>
<div>
{#if $user}
<button on:click={() => user.delete()}>Log out</button>
{:else if $token !== null}
{#await Promise.all([open($token.url), pollForUser($token.token)])}
<div>Log in in your system browser</div>
{/await}
<p>
<button class="underline" on:click={() => open($authUrl)}
>Click here</button
>
if you are not redirected automatically, you can
</p>
{:else}
<button on:click={() => api.login.token.create().then(token.set)}
>Log in</button
>
{/if}
</div>

View File

@ -1,3 +1,4 @@
export { default as Timeline } from "./Timeline.svelte";
export { default as CodeViewer } from "./CodeViewer.svelte";
export { default as BackForwardButtons } from "./BackForwardButtons.svelte";
export { default as Login } from "./Login.svelte";

View File

@ -1,16 +1,23 @@
import { invoke } from "@tauri-apps/api";
import { writable } from "svelte/store";
import { derived, writable } from "svelte/store";
import type { Project as ApiProject } from "$lib/api";
export type Project = {
id: string;
title: string;
path: string;
api: ApiProject & { sync: boolean };
};
const list = () => invoke<Project[]>("list_projects");
const update = (params: { project: { id: string; title?: string } }) =>
invoke<Project>("update_project", params);
const update = (params: {
project: {
id: string;
title?: string;
api?: ApiProject & { sync: boolean };
};
}) => invoke<Project>("update_project", params);
const add = (params: { path: string }) =>
invoke<Project>("add_project", params);
@ -21,19 +28,32 @@ export default async () => {
return {
subscribe: store.subscribe,
get: (id: string) => {
const project = derived(store, (store) =>
store.find((p) => p.id === id)
);
return {
subscribe: project.subscribe,
update: (params: { title?: string; api?: Project["api"] }) =>
update({
project: {
id,
...params,
},
}).then((project) => {
store.update((projects) =>
projects.map((p) =>
p.id === project.id ? project : p
)
);
return project;
}),
};
},
add: (params: { path: string }) =>
add(params).then((project) => {
store.update((projects) => [...projects, project]);
return project;
}),
update: (params: { project: { id: string; title?: string } }) =>
update(params).then((project) => {
console.log(project);
store.update((projects) =>
projects.map((p) => (p.id === project.id ? project : p))
);
return project;
}),
};
};

View File

@ -1,4 +1,4 @@
import type { User } from "$lib/authentication";
import type { User } from "$lib/api";
import { writable } from "svelte/store";
import { invoke } from "@tauri-apps/api";

View File

@ -2,33 +2,34 @@ import { readable } from "svelte/store";
import type { LayoutLoad } from "./$types";
import { building } from "$app/environment";
import type { Project } from "$lib/projects";
import Api from "$lib/api";
export const ssr = false;
export const prerender = true;
export const csr = true;
export const load: LayoutLoad = async () => {
export const load: LayoutLoad = async ({ fetch }) => {
const projects = building
? {
...readable<Project[]>([]),
add: () => {
throw new Error("not implemented");
},
update: () => {
throw new Error("not implemented");
},
}
...readable<Project[]>([]),
add: () => {
throw new Error("not implemented");
},
get: () => {
throw new Error("not implemented");
},
}
: await (await import("$lib/projects")).default();
const user = building
? {
...readable<undefined>(undefined),
set: () => {
throw new Error("not implemented");
},
delete: () => {
throw new Error("not implemented");
},
}
...readable<undefined>(undefined),
set: () => {
throw new Error("not implemented");
},
delete: () => {
throw new Error("not implemented");
},
}
: await (await import("$lib/users")).default();
return { projects, user };
return { projects, user, api: Api({ fetch }) };
};

View File

@ -1,5 +1,5 @@
<script lang="ts">
import type { LayoutData, LayoutLoad } from "./$types";
import type { LayoutData } from "./$types";
export let data: LayoutData;
$: project = data.project;
@ -7,16 +7,37 @@
$: lastSessionId = $sessions[$sessions.length - 1]?.id;
</script>
<div class="h-12 p-3 flex flex-row space-x-3 text-zinc-500 text-lg select-none border-b border-zinc-700">
<div>Week</div>
<a href="/projects/{$project?.id}" class="hover:text-zinc-300">Day</a>
{#if lastSessionId}
<a
href="/projects/{$project?.id}/sessions/{lastSessionId}"
class="hover:text-zinc-300"
title="go to current session">Session</a
>
{/if}
</div>
<nav
class="h-12 p-3 flex justify-between space-x-3 text-zinc-500 text-lg select-none border-b border-zinc-700"
>
<ul class="flex gap-2">
<li>
<div>Week</div>
</li>
<li>
<a href="/projects/{$project?.id}" class="hover:text-zinc-300"
>Day</a
>
</li>
{#if lastSessionId}
<li>
<a
href="/projects/{$project?.id}/sessions/{lastSessionId}"
class="hover:text-zinc-300"
title="go to current session">Session</a
>
</li>
{/if}
</ul>
<ul>
<li>
<a
href="/projects/{$project?.id}/settings"
class="hover:text-zinc-300">Settings</a
>
</li>
</ul>
</nav>
<slot />

View File

@ -1,4 +1,4 @@
import { derived, readable } from "svelte/store";
import { readable } from "svelte/store";
import type { LayoutLoad } from "./$types";
import { building } from "$app/environment";
import type { Session } from "$lib/sessions";
@ -13,9 +13,7 @@ export const load: LayoutLoad = async ({ parent, params }) => {
await import("$lib/sessions")
).default({ projectId: params.projectId });
return {
project: derived(projects, (projects) =>
projects.find((project) => project.id === params.projectId)
),
project: projects.get(params.projectId),
sessions,
};
};

View File

@ -0,0 +1,52 @@
<script lang="ts">
import { derived } from "svelte/store";
import { Login } from "$lib/components";
import type { PageData } from "./$types";
export let data: PageData;
const { project, user, api } = data;
const isSyncing = derived(project, (project) => project?.api?.sync);
const onSyncChange = async (event: Event) => {
if ($project === undefined) return;
if ($user === undefined) return;
const target = event.target as HTMLInputElement;
const sync = target.checked;
if (!$project.api) {
const apiProject = await api.projects.create($user.access_token, {
name: $project.title,
uid: $project.id,
});
await project.update({ api: { ...apiProject, sync } });
} else {
await project.update({ api: { ...$project.api, sync } });
}
};
</script>
<article class="">
<header>
<h2>{$project?.title}</h2>
</header>
{#if $user}
<form disabled={$user === undefined}>
<label for="sync">Sync</label>
<input
disabled={$user === undefined}
type="checkbox"
checked={$isSyncing}
on:change={onSyncChange}
/>
</form>
{:else}
<div><Login {user} {api} /> to sync</div>
{/if}
<code class="whitespace-pre">
{JSON.stringify($project, null, 2)}
</code>
</article>

View File

@ -1,41 +1,14 @@
<script lang="ts">
import { open } from "@tauri-apps/api/shell";
import Authentication from "$lib/authentication";
import { Login } from "$lib/components";
import type { PageData } from "./$types";
export let data: PageData;
const { user } = data;
const authApi = Authentication();
const pollForUser = async (token: string) => {
const apiUser = await authApi.login.user.get(token).catch(() => null);
if (apiUser) {
user.set(apiUser);
return apiUser;
}
return new Promise((resolve) => {
setTimeout(async () => {
resolve(await pollForUser(token));
}, 1000);
});
};
const { user, api } = data;
</script>
{#if $user}
<div>
Welcome, {$user.name}!
</div>
{:else}
{#await authApi.login.token.create()}
<div>loading...</div>
{:then { url, token }}
{#await Promise.all([open(url), pollForUser(token)])}
<div>Log in in your system browser</div>
<p>
If you are not redirected automatically, you can
<button on:click={() => open(url)}>Try again</button>
</p>
{/await}
{/await}
{/if}
<div>
{#if $user}
<div>Welcome, {$user.name}!</div>
{/if}
<Login {user} {api} />
</div>

View File

@ -1,12 +1,12 @@
const config = {
content: ["./src/**/*.{html,js,svelte,ts}"],
darkMode: 'class',
content: ["./src/**/*.{html,js,svelte,ts}"],
darkMode: "class",
theme: {
extend: {},
},
theme: {
extend: {},
},
plugins: [],
plugins: [],
};
module.exports = config;