From 93ff2ed9b4598f8664f279c76d33782d91a4be8e Mon Sep 17 00:00:00 2001 From: Nils Kolvenbach Date: Mon, 17 Nov 2025 11:54:03 +0100 Subject: [PATCH 01/36] Added tanstack query devtools --- package.json | 1 + pnpm-lock.yaml | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/package.json b/package.json index 6ecebb5..aede0c1 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@hookform/devtools": "4.4.0", "@redux-devtools/extension": "3.3.0", "@tailwindcss/vite": "4.1.17", + "@tanstack/react-query-devtools": "5.90.2", "@tanstack/react-router-devtools": "1.131.2", "@tanstack/router-plugin": "1.131.2", "@trivago/prettier-plugin-sort-imports": "5.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc870de..0f85a18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: '@tailwindcss/vite': specifier: 4.1.17 version: 4.1.17(vite@7.2.1(@types/node@22.18.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@tanstack/react-query-devtools': + specifier: ^5.90.2 + version: 5.90.2(@tanstack/react-query@5.90.7(react@19.2.0))(react@19.2.0) '@tanstack/react-router-devtools': specifier: 1.131.2 version: 1.131.2(@tanstack/react-router@1.131.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@tanstack/router-core@1.132.33)(csstype@3.1.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.9)(tiny-invariant@1.3.3) @@ -2204,6 +2207,15 @@ packages: '@tanstack/query-core@5.90.7': resolution: {integrity: sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==} + '@tanstack/query-devtools@5.90.1': + resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==} + + '@tanstack/react-query-devtools@5.90.2': + resolution: {integrity: sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.2 + react: ^18 || ^19 + '@tanstack/react-query@5.90.7': resolution: {integrity: sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==} peerDependencies: @@ -7624,6 +7636,14 @@ snapshots: '@tanstack/query-core@5.90.7': {} + '@tanstack/query-devtools@5.90.1': {} + + '@tanstack/react-query-devtools@5.90.2(@tanstack/react-query@5.90.7(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/query-devtools': 5.90.1 + '@tanstack/react-query': 5.90.7(react@19.2.0) + react: 19.2.0 + '@tanstack/react-query@5.90.7(react@19.2.0)': dependencies: '@tanstack/query-core': 5.90.7 From a8f4b369446504a37e47499511806576f4eaa9d5 Mon Sep 17 00:00:00 2001 From: Nils Kolvenbach Date: Mon, 17 Nov 2025 12:24:56 +0100 Subject: [PATCH 02/36] Removed IPC from route context --- src/renderer/app.tsx | 9 +++------ src/renderer/ipc.ts | 1 - src/renderer/routes/__root.tsx | 18 ++++++++---------- src/renderer/sentry.ts | 5 ++--- 4 files changed, 13 insertions(+), 20 deletions(-) delete mode 100644 src/renderer/ipc.ts diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index c39e6a3..0c2dec6 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -1,27 +1,24 @@ // Sentry initialization should be imported first! import '@fontsource-variable/montserrat'; import '@fontsource/roboto'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; import { ThemeProvider } from '@renderer/components/theme-provider'; import '@renderer/index.css'; -import { ipc } from '@renderer/ipc'; +import { queryClient } from '@renderer/queries'; import '@renderer/sentry'; import { reactErrorHandler, router } from '@renderer/sentry'; -// Initialize TanStack Query -export const queryClient = new QueryClient(); - // Render the app const rootElement = document.getElementById('app')!; if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement, { // Callback called when an error is thrown and not caught by an ErrorBoundary. onUncaughtError: reactErrorHandler((error, errorInfo) => { - ipc.core.logger.error({ + window.ipc.core.logger.error({ source: 'desktop', message: `Uncaught React error`, meta: { error, errorInfo }, diff --git a/src/renderer/ipc.ts b/src/renderer/ipc.ts deleted file mode 100644 index 730d744..0000000 --- a/src/renderer/ipc.ts +++ /dev/null @@ -1 +0,0 @@ -export const ipc = window.ipc; diff --git a/src/renderer/routes/__root.tsx b/src/renderer/routes/__root.tsx index 7998791..c853c76 100644 --- a/src/renderer/routes/__root.tsx +++ b/src/renderer/routes/__root.tsx @@ -1,3 +1,4 @@ +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { type ErrorComponentProps, Outlet, @@ -14,7 +15,7 @@ import { Button } from '@renderer/components/ui/button'; import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'; import { Toaster } from '@renderer/components/ui/sonner'; -export interface RouterContext extends ContextBridgeApi {} +export interface RouterContext {} // Use the routerContext to create your root route export const Route = createRootRouteWithContext()({ @@ -25,9 +26,8 @@ export const Route = createRootRouteWithContext()({ function ErrorComponent({ error }: ErrorComponentProps): ReactElement { const router = useRouter(); - const { electron, core } = Route.useRouteContext(); - core.logger.error({ + window.ipc.core.logger.error({ source: 'desktop', message: `Uncaught route error: ${error.message}`, meta: { error: { message: error.message, stack: error.stack } }, @@ -64,7 +64,7 @@ function ErrorComponent({ error }: ErrorComponentProps): ReactElement { return ( <> - + } actions={}>

{error.message}

@@ -82,7 +82,6 @@ function ErrorComponent({ error }: ErrorComponentProps): ReactElement { function NotFoundComponent(): ReactElement { const router = useRouter(); - const { electron } = Route.useRouteContext(); function Description(): ReactElement { return <>You've tried accessing a route that could not be found.; @@ -108,7 +107,7 @@ function NotFoundComponent(): ReactElement { return ( <> - + } @@ -125,14 +124,13 @@ function NotFoundComponent(): ReactElement { } function RootComponent(): ReactElement { - const { electron } = Route.useRouteContext(); - return ( <> - + - + + ); } diff --git a/src/renderer/sentry.ts b/src/renderer/sentry.ts index f2f1caa..0926e60 100644 --- a/src/renderer/sentry.ts +++ b/src/renderer/sentry.ts @@ -16,7 +16,6 @@ import { } from '@sentry/react'; import { createHashHistory, createRouter } from '@tanstack/react-router'; -import { ipc } from '@renderer/ipc'; import { routeTree } from '@renderer/routeTree.gen'; // Create a new router instance @@ -24,10 +23,10 @@ const hashHistory = createHashHistory(); // Use hash based routing since in prod const router = createRouter({ routeTree, history: hashHistory, - context: { electron: ipc.electron, core: ipc.core }, + context: {}, }); router.subscribe('onBeforeLoad', (event) => { - ipc.core.logger.info({ + window.ipc.core.logger.info({ source: 'desktop', message: `Client navigating from "${event?.fromLocation?.href}" to "${event.toLocation.href}"`, }); From e8d010648bd5cea1651209277d156a13ad010df8 Mon Sep 17 00:00:00 2001 From: Nils Kolvenbach Date: Mon, 17 Nov 2025 12:26:07 +0100 Subject: [PATCH 03/36] Added most queryOptions and mutationOptions. Using queryClient with infinite stale time and automatic toast messages. --- src/renderer/queries/client.ts | 90 ++++++ src/renderer/queries/index.ts | 2 + src/renderer/queries/options.ts | 483 ++++++++++++++++++++++++++++++++ 3 files changed, 575 insertions(+) create mode 100644 src/renderer/queries/client.ts create mode 100644 src/renderer/queries/index.ts create mode 100644 src/renderer/queries/options.ts diff --git a/src/renderer/queries/client.ts b/src/renderer/queries/client.ts new file mode 100644 index 0000000..04e3241 --- /dev/null +++ b/src/renderer/queries/client.ts @@ -0,0 +1,90 @@ +import { QueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import z from 'zod'; + +import { objectTypeSchema } from '@elek-io/core'; + +const logableMutationMetaSchema = z.object({ + method: z.enum(['create', 'update', 'delete', 'clone']), + objectType: objectTypeSchema, +}); + +/** + * Tanstack Query instance with agressive caching and + * automatic display of a toast notification whenever a mutation succeeds or fails + * + * The display of a notification depends on the availability of the meta object with + * method and objectType keys, which needs to be set on all mutationOptions inside + * the `./options.ts` file. + * + * Additionally it logs all mutations with Core to enable full E2E debugging. + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + }, + mutations: { + onSuccess: (data, variables, result, context) => { + const logableMutationMeta = logableMutationMetaSchema.safeParse( + context.meta + ); + + if (logableMutationMeta.success) { + toast.success( + `${logableMutationMeta.data.method} ${logableMutationMeta.data.objectType}` + ); + + window.ipc.core.logger.info({ + source: 'desktop', + message: `Successfully ${logableMutationMeta.data.method}ed ${logableMutationMeta.data.objectType}`, + meta: { + ...logableMutationMeta.data, + }, + }); + } else { + window.ipc.core.logger.error({ + source: 'desktop', + message: 'Detected mutation without meta', + meta: { + data, + variables, + result, + context, + }, + }); + } + }, + onError: (error, variables, result, context) => { + const logableMutationMeta = logableMutationMetaSchema.safeParse( + context.meta + ); + + if (logableMutationMeta.success) { + toast.error( + `${logableMutationMeta.data.method} ${logableMutationMeta.data.objectType}` + ); + + window.ipc.core.logger.error({ + source: 'desktop', + message: `Failed to ${logableMutationMeta.data.method} ${logableMutationMeta.data.objectType}`, + meta: { + ...logableMutationMeta.data, + }, + }); + } else { + window.ipc.core.logger.error({ + source: 'desktop', + message: 'Detected mutation without meta', + meta: { + error, + variables, + result, + context, + }, + }); + } + }, + }, + }, +}); diff --git a/src/renderer/queries/index.ts b/src/renderer/queries/index.ts new file mode 100644 index 0000000..f75d00e --- /dev/null +++ b/src/renderer/queries/index.ts @@ -0,0 +1,2 @@ +export { default as queryOptions } from './options'; +export * from './client'; diff --git a/src/renderer/queries/options.ts b/src/renderer/queries/options.ts new file mode 100644 index 0000000..e74a327 --- /dev/null +++ b/src/renderer/queries/options.ts @@ -0,0 +1,483 @@ +import { mutationOptions, queryOptions } from '@tanstack/react-query'; + +import { + type Asset, + type Collection, + type CreateAssetProps, + type CreateCollectionProps, + type CreateProjectProps, + type DeleteAssetProps, + type DeleteCollectionProps, + type DeleteProjectProps, + type ListAssetsProps, + type ListCollectionsProps, + type ListProjectsProps, + type PaginatedList, + type Project, + type ReadAssetProps, + type ReadCollectionProps, + type ReadProjectProps, + type SetUserProps, + type UpdateAssetProps, + type UpdateCollectionProps, + type UpdateProjectProps, +} from '@elek-io/core'; + +import { queryClient } from './client'; + +/** + * Query and Mutation options for Tanstack Query that wrap IPC calls to the main process. + */ +export default { + projects: { + create: (props: CreateProjectProps) => + mutationOptions({ + mutationFn: async () => { + return await window.ipc.core.projects.create(props); + }, + meta: { + method: 'create', + objectType: 'project', + }, + onSuccess: (createdProject, _variables, _onMutateResult, context) => { + // Add Project to cache individually + context.client.setQueryData( + ['projects', createdProject.id], + createdProject + ); + + // And update the Projects list cache too + context.client.setQueryData( + ['projects'], + (old: PaginatedList) => { + return { + total: old.total + 1, + limit: old.limit, + offset: old.offset, + list: [...old.list, createdProject], + }; + } + ); + }, + }), + read: (props: ReadProjectProps) => + queryOptions({ + queryKey: ['projects', props.id], + queryFn: async () => { + return await window.ipc.core.projects.read(props); + }, + }), + update: (props: UpdateProjectProps) => + mutationOptions({ + mutationFn: async () => { + return await window.ipc.core.projects.update(props); + }, + meta: { + method: 'update', + objectType: 'project', + }, + onSuccess: (updatedProject, _variables, _onMutateResult, context) => { + // Update Project in cache individually + context.client.setQueryData( + ['projects', updatedProject.id], + updatedProject + ); + + // And update the Projects list cache too + context.client.setQueryData( + ['projects'], + (old: PaginatedList) => { + return { + total: old.total, + limit: old.limit, + offset: old.offset, + list: old.list.map((oldProject) => + oldProject.id === updatedProject.id + ? updatedProject + : oldProject + ), + }; + } + ); + }, + }), + delete: (props: DeleteProjectProps) => + mutationOptions({ + mutationFn: async () => { + return await window.ipc.core.projects.delete(props); + }, + meta: { + method: 'delete', + objectType: 'project', + }, + onSuccess: (_deletedProject, _variables, _onMutateResult, context) => { + // Remove Project from cache individually + context.client.setQueryData(['projects', props.id], undefined); + + // And update the Projects list cache too + context.client.setQueryData( + ['projects'], + (old: PaginatedList) => { + return { + total: old.total - 1, + limit: old.limit, + offset: old.offset, + list: old.list.filter( + (oldProject) => oldProject.id !== props.id + ), + }; + } + ); + }, + }), + list: (props: ListProjectsProps) => + queryOptions({ + queryKey: ['projects'], + queryFn: async () => { + const projects = await window.ipc.core.projects.list(props); + + // Cache each project individually too + // so that we can access them directly without refetching later + projects.list.forEach((project) => { + queryClient.setQueryData(['projects', project.id], project); + }); + + return projects; + }, + }), + clone: mutationOptions({ + mutationFn: window.ipc.core.projects.clone, + meta: { + method: 'clone', + objectType: 'project', + }, + onSuccess: (clonedProject, _variables, _onMutateResult, context) => { + // Add Project to cache individually + context.client.setQueryData( + ['projects', clonedProject.id], + clonedProject + ); + + // And update the Projects list cache too + context.client.setQueryData( + ['projects'], + (old: PaginatedList) => { + return { + total: old.total + 1, + limit: old.limit, + offset: old.offset, + list: [...old.list, clonedProject], + }; + } + ); + }, + }), + getChanges: (project: Project) => + queryOptions({ + enabled: project.remoteOriginUrl !== null, + queryKey: ['projects', project.id, 'changes'], + queryFn: async () => { + return await window.ipc.core.projects.getChanges({ + id: project.id, + }); + }, + // Refetch the data every 3 minutes + refetchInterval: 180000, + }), + synchronize: (project: Project) => + mutationOptions({ + mutationFn: window.ipc.core.projects.synchronize, + meta: { + method: 'synchronize', + objectType: 'project', + }, + onSuccess: (_data, _variables, _onMutateResult, context) => { + // On synchronization anything inside the Project may have changed + // so we invalidate it entirely + context.client.invalidateQueries({ + queryKey: ['projects', project.id], + refetchType: 'all', + }); + }, + }), + }, + collections: { + create: (props: CreateCollectionProps) => + mutationOptions({ + mutationFn: async () => { + return await window.ipc.core.collections.create(props); + }, + meta: { + method: 'create', + objectType: 'collection', + }, + onSuccess: ( + createdCollection, + _variables, + _onMutateResult, + context + ) => { + // Add Collection to cache individually + context.client.setQueryData( + ['projects', props.projectId, 'collections', createdCollection.id], + createdCollection + ); + + // And update the Collections list cache too + context.client.setQueryData( + ['projects', props.projectId, 'collections'], + (old: PaginatedList) => { + return { + total: old.total + 1, + limit: old.limit, + offset: old.offset, + list: [...old.list, createdCollection], + }; + } + ); + }, + }), + read: (props: ReadCollectionProps) => + queryOptions({ + queryKey: ['projects', props.projectId, 'collections', props.id], + queryFn: async () => { + return await window.ipc.core.collections.read(props); + }, + }), + update: (props: UpdateCollectionProps) => + mutationOptions({ + mutationFn: async () => { + return await window.ipc.core.collections.update(props); + }, + meta: { + method: 'update', + objectType: 'collection', + }, + onSuccess: ( + updatedCollection, + _variables, + _onMutateResult, + context + ) => { + // Update Collection in cache individually + context.client.setQueryData( + ['projects', props.projectId, 'collections', updatedCollection.id], + updatedCollection + ); + + // And update the Collections list cache too + context.client.setQueryData( + ['projects', props.projectId, 'collections'], + (old: PaginatedList) => { + return { + total: old.total, + limit: old.limit, + offset: old.offset, + list: old.list.map((oldCollection) => + oldCollection.id === updatedCollection.id + ? updatedCollection + : oldCollection + ), + }; + } + ); + }, + }), + delete: (props: DeleteCollectionProps) => + mutationOptions({ + mutationFn: async () => { + return await window.ipc.core.collections.delete(props); + }, + meta: { + method: 'delete', + objectType: 'collection', + }, + onSuccess: ( + _deletedCollection, + _variables, + _onMutateResult, + context + ) => { + // Remove Collection from cache individually + context.client.setQueryData( + ['projects', props.projectId, 'collections', props.id], + undefined + ); + + // And update the Collections list cache too + context.client.setQueryData( + ['projects', props.projectId, 'collections'], + (old: PaginatedList) => { + return { + total: old.total - 1, + limit: old.limit, + offset: old.offset, + list: old.list.filter( + (oldCollection) => oldCollection.id !== props.id + ), + }; + } + ); + }, + }), + list: (props: ListCollectionsProps) => + queryOptions({ + queryKey: ['projects', props.projectId, 'collections'], + queryFn: async () => { + const collections = await window.ipc.core.collections.list(props); + + // Cache each collection individually too + // so that we can access them directly without refetching later + collections.list.forEach((collection) => { + queryClient.setQueryData( + ['projects', props.projectId, 'collections', collection.id], + collection + ); + }); + + return collections; + }, + }), + }, + assets: { + create: (props: CreateAssetProps) => + mutationOptions({ + mutationFn: async () => { + return await window.ipc.core.assets.create(props); + }, + meta: { + method: 'create', + objectType: 'asset', + }, + onSuccess: (createdAsset, _variables, _onMutateResult, context) => { + // Add Asset to cache individually + context.client.setQueryData( + ['projects', props.projectId, 'assets', createdAsset.id], + createdAsset + ); + + // And update the Assets list cache too + context.client.setQueryData( + ['projects', props.projectId, 'assets'], + (old: PaginatedList) => { + return { + total: old.total + 1, + limit: old.limit, + offset: old.offset, + list: [...old.list, createdAsset], + }; + } + ); + }, + }), + read: (props: ReadAssetProps) => + queryOptions({ + queryKey: ['projects', props.projectId, 'assets', props.id], + queryFn: async () => { + return await window.ipc.core.assets.read(props); + }, + }), + update: (props: UpdateAssetProps) => + mutationOptions({ + mutationFn: async () => { + return await window.ipc.core.assets.update(props); + }, + meta: { + method: 'update', + objectType: 'asset', + }, + onSuccess: (updatedAsset, _variables, _onMutateResult, context) => { + // Update Asset in cache individually + context.client.setQueryData( + ['projects', props.projectId, 'assets', updatedAsset.id], + updatedAsset + ); + + // And update the Assets list cache too + context.client.setQueryData( + ['projects', props.projectId, 'assets'], + (old: PaginatedList) => { + return { + total: old.total, + limit: old.limit, + offset: old.offset, + list: old.list.map((oldAsset) => + oldAsset.id === updatedAsset.id ? updatedAsset : oldAsset + ), + }; + } + ); + }, + }), + delete: (props: DeleteAssetProps) => + mutationOptions({ + mutationFn: async () => { + return await window.ipc.core.assets.delete(props); + }, + meta: { + method: 'delete', + objectType: 'asset', + }, + onSuccess: (_deletedAsset, _variables, _onMutateResult, context) => { + // Remove Asset from cache individually + context.client.setQueryData( + ['projects', props.projectId, 'assets', props.id], + undefined + ); + + // And update the Assets list cache too + context.client.setQueryData( + ['projects', props.projectId, 'assets'], + (old: PaginatedList) => { + return { + total: old.total - 1, + limit: old.limit, + offset: old.offset, + list: old.list.filter((oldAsset) => oldAsset.id !== props.id), + }; + } + ); + }, + }), + list: (props: ListAssetsProps) => + queryOptions({ + queryKey: ['projects', props.projectId, 'assets'], + queryFn: async () => { + const assets = await window.ipc.core.assets.list(props); + + // Cache each asset individually too + // so that we can access them directly without refetching later + assets.list.forEach((asset) => { + queryClient.setQueryData( + ['projects', props.projectId, 'assets', asset.id], + asset + ); + }); + + return assets; + }, + }), + }, + user: { + get: () => + queryOptions({ + queryKey: ['user'], + queryFn: async () => { + return await window.ipc.core.user.get(); + }, + }), + set: (props: SetUserProps) => + mutationOptions({ + mutationFn: async () => { + return await window.ipc.core.user.set(props); + }, + meta: { + method: 'set', + objectType: 'user', + }, + onSuccess: (updatedUser, _variables, _onMutateResult, context) => { + context.client.setQueryData(['user'], updatedUser); + }, + }), + }, +}; From be622d881d09236c1c96cf7c3fe09bcb75239475 Mon Sep 17 00:00:00 2001 From: Nils Kolvenbach Date: Mon, 17 Nov 2025 12:29:09 +0100 Subject: [PATCH 04/36] Using queryClient instead of direct IPC calls for Projects --- src/renderer/components/asset-info.tsx | 25 +- src/renderer/components/project-card.tsx | 86 ++++++ src/renderer/routes/projects.tsx | 6 +- src/renderer/routes/projects/$projectId.tsx | 60 ++--- src/renderer/routes/projects/index.tsx | 275 ++++++++------------ 5 files changed, 239 insertions(+), 213 deletions(-) create mode 100644 src/renderer/components/project-card.tsx diff --git a/src/renderer/components/asset-info.tsx b/src/renderer/components/asset-info.tsx index c0fd7dc..bbd1d98 100644 --- a/src/renderer/components/asset-info.tsx +++ b/src/renderer/components/asset-info.tsx @@ -1,3 +1,4 @@ +import { useMutation } from '@tanstack/react-query'; import { Download, Edit2, Trash } from 'lucide-react'; import type { ReactElement } from 'react'; @@ -20,12 +21,13 @@ import { TooltipProvider, TooltipTrigger, } from '@renderer/components/ui/tooltip'; -import { ipc } from '@renderer/ipc'; import { formatBytes, formatDatetime } from '@renderer/lib/utils'; import { NotificationIntent, useStore } from '@renderer/store'; import { type Asset, type SupportedLanguage } from '@elek-io/core'; +import queryOptions from '../queries/options'; + export interface AssetInfoProps { projectId: string; asset: Asset; @@ -72,7 +74,7 @@ export function AssetInfo({ async function onAssetUpdate(): Promise { try { - const result = await ipc.electron.dialog.showOpenDialog({ + const result = await window.ipc.electron.dialog.showOpenDialog({ title: 'Select Asset to update with', buttonLabel: 'Update Asset', properties: ['openFile'], @@ -82,13 +84,16 @@ export function AssetInfo({ return; } - await ipc.core.assets.update({ - projectId: projectId, - id: asset.id, - newFilePath: result.filePaths[0], - name: 'Updated Asset', - description: 'Updated Asset', - }); + const updateAssetMutation = useMutation( + queryOptions.assets.update({ + projectId: projectId, + id: asset.id, + newFilePath: result.filePaths[0], + name: 'Updated Asset', + description: 'Updated Asset', + }) + ); + await updateAssetMutation.mutateAsync(); addNotification({ intent: NotificationIntent.SUCCESS, title: 'Successfully updated Asset', @@ -118,7 +123,7 @@ export function AssetInfo({ } try { - const result = await ipc.electron.dialog.showSaveDialog({ + const result = await window.ipc.electron.dialog.showSaveDialog({ // title: `Save Asset ${asset.name}.${asset.extension} to disk`, // buttonLabel: 'Save Asset', // message: 'Hello World!', diff --git a/src/renderer/components/project-card.tsx b/src/renderer/components/project-card.tsx new file mode 100644 index 0000000..1de1c75 --- /dev/null +++ b/src/renderer/components/project-card.tsx @@ -0,0 +1,86 @@ +import { Link } from '@tanstack/react-router'; +import { EllipsisIcon } from 'lucide-react'; + +import { Badge, RemoteOriginBadge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@renderer/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@renderer/components/ui/dropdown-menu'; + +import type { Project } from '@elek-io/core'; + +import { Skeleton } from './ui/skeleton'; + +function ProjectCard({ project }: { project: Project }): React.JSX.Element { + return ( + + + + {project.name} + {project.description} + + + + + + + + My Account + + Profile + Billing + Team + Subscription + + + + + + + Core version: {project.coreVersion} + + + ); +} + +function ProjectCardSkeleton(): React.JSX.Element { + return ( + + + + + + + + + + + + + + ); +} + +export { ProjectCard, ProjectCardSkeleton }; diff --git a/src/renderer/routes/projects.tsx b/src/renderer/routes/projects.tsx index 8eb1223..239669b 100644 --- a/src/renderer/routes/projects.tsx +++ b/src/renderer/routes/projects.tsx @@ -3,9 +3,11 @@ import type { ReactElement } from 'react'; import { UserHeader } from '@renderer/components/user-header'; +import { queryClient, queryOptions } from '../queries'; + export const Route = createFileRoute('/projects')({ - beforeLoad: async ({ context }) => { - const user = await context.core.user.get(); + beforeLoad: async () => { + const user = await queryClient.ensureQueryData(queryOptions.user.get()); if (!user) { throw redirect({ to: '/user/profile', diff --git a/src/renderer/routes/projects/$projectId.tsx b/src/renderer/routes/projects/$projectId.tsx index 7f28251..3e464da 100644 --- a/src/renderer/routes/projects/$projectId.tsx +++ b/src/renderer/routes/projects/$projectId.tsx @@ -3,12 +3,17 @@ import { Outlet, createFileRoute } from '@tanstack/react-router'; import { type ReactElement, useEffect, useState } from 'react'; import { ProjectSidebar } from '@renderer/components/project-sidebar'; +import { queryClient, queryOptions } from '@renderer/queries'; import { NotificationIntent, useStore } from '@renderer/store'; import { type TranslatableString } from '@elek-io/core'; export const Route = createFileRoute('/projects/$projectId')({ beforeLoad: async ({ context, params }) => { + const project = await queryClient.ensureQueryData( + queryOptions.projects.read({ id: params.projectId }) + ); + /** * Returns given TranslatableString in the language of the current user * @@ -39,18 +44,8 @@ export const Route = createFileRoute('/projects/$projectId')({ return `(Missing translation for key "${key}")`; } - const project = await context.core.projects.read({ - id: params.projectId, - }); - - const collections = await context.core.collections.list({ - projectId: params.projectId, - limit: 0, - }); - return { project, - collections, translateContent, }; }, @@ -61,6 +56,12 @@ function ProjectLayout(): ReactElement { const context = Route.useRouteContext(); const addNotification = useStore((state) => state.addNotification); const [isSynchronizing, setIsSynchronizing] = useState(false); + const projectChangesQuery = useQuery( + queryOptions.projects.getChanges(context.project) + ); + // const = useQuery( + // queryOptions.projects.(context.project) + // ); useEffect(() => { useStore.setState((prev) => ({ @@ -71,32 +72,19 @@ function ProjectLayout(): ReactElement { })); }, [context.project]); - useEffect(() => { - context.collections.list.map((collection) => { - useStore.setState((prev) => ({ - breadcrumbLookupMap: new Map(prev.breadcrumbLookupMap).set( - collection.id, - context.translateContent( - 'collection.name.plural', - collection.name.plural - ) - ), - })); - }); - }, [context.collections]); - - const projectChangesQuery = useQuery({ - enabled: context.project.remoteOriginUrl !== null, - queryKey: ['projectChanges', context.project.id], - queryFn: async () => { - const changes = await context.core.projects.getChanges({ - id: context.project.id, - }); - return changes; - }, - // Refetch the data every 3 minutes - refetchInterval: 180000, - }); + // useEffect(() => { + // context.collections.list.map((collection) => { + // useStore.setState((prev) => ({ + // breadcrumbLookupMap: new Map(prev.breadcrumbLookupMap).set( + // collection.id, + // context.translateContent( + // 'collection.name.plural', + // collection.name.plural + // ) + // ), + // })); + // }); + // }, [context.collections]); async function onSynchronize(): Promise { setIsSynchronizing(true); diff --git a/src/renderer/routes/projects/index.tsx b/src/renderer/routes/projects/index.tsx index a0be279..eae754d 100644 --- a/src/renderer/routes/projects/index.tsx +++ b/src/renderer/routes/projects/index.tsx @@ -1,20 +1,17 @@ -import { Link, createFileRoute, useRouter } from '@tanstack/react-router'; -import { DownloadCloud, EllipsisIcon, Plus } from 'lucide-react'; +import queryOptions from '@root/src/renderer/queries/options'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; +import { DownloadCloud, Plus } from 'lucide-react'; import { type ReactElement, useState } from 'react'; import { type SubmitHandler, useForm } from 'react-hook-form'; import { FormInput } from '@renderer/components/form-input'; import { Page } from '@renderer/components/page'; -import { Badge, RemoteOriginBadge } from '@renderer/components/ui/badge'; -import { Button } from '@renderer/components/ui/button'; import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@renderer/components/ui/card'; + ProjectCard, + ProjectCardSkeleton, +} from '@renderer/components/project-card'; +import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogBody, @@ -23,7 +20,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from '@renderer/components/ui/dialog'; import { Empty, @@ -41,61 +37,37 @@ import { FormLabel, FormMessage, } from '@renderer/components/ui/form'; -import { NotificationIntent, useStore } from '@renderer/store'; import { type CloneProjectProps } from '@elek-io/core'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '../../components/ui/dropdown-menu'; - export const Route = createFileRoute('/projects/')({ - beforeLoad: async ({ context }) => { - const projects = await context.core.projects.list({ limit: 0 }); - - return { projects }; - }, component: ListProjectsPage, }); function ListProjectsPage(): ReactElement { const router = useRouter(); - const context = Route.useRouteContext(); - const addNotification = useStore((state) => state.addNotification); + const { + data: projects, + isPending: isProjectsPending, + isError: isProjectsError, + error: projectsError, + } = useQuery( + queryOptions.projects.list({ + limit: 0, + }) + ); const cloneProjectForm = useForm({ defaultValues: { url: '', }, }); + const { mutateAsync: cloneProject, isPending: isCloningProject } = + useMutation(queryOptions.projects.clone); const [isCloningDialogOpen, setIsCloningDialogOpen] = useState(false); - const [isCloning, setIsCloning] = useState(false); const onCloneProject: SubmitHandler = async (props) => { - setIsCloning(true); - try { - await context.core.projects.clone(props); - setIsCloning(false); - setIsCloningDialogOpen(false); - await router.invalidate(); - addNotification({ - intent: NotificationIntent.SUCCESS, - title: 'Successfully cloned Project', - description: 'The Project was successfully cloned.', - }); - } catch (error) { - setIsCloning(false); - console.error(error); - addNotification({ - intent: NotificationIntent.DANGER, - title: 'Failed to clone Project', - description: 'There was an error cloning the Project.', - }); - } + await cloneProject(props); + setIsCloningDialogOpen(false); }; function Description(): ReactElement { @@ -117,126 +89,99 @@ function ListProjectsPage(): ReactElement { > Create Project - setIsCloningDialogOpen(true)} > - - - - - - Clone a Project by URL - - You can clone an existing Project by providing the URL. Make - sure you have the necessary permissions to access the Project. - - - - -
- - ( - - URL - - - - - - - )} - /> - - -
- - - - -
-
+ Clone Project + ); } + if (isProjectsError) { + throw projectsError; + } + return ( - } - actions={} - layout="bare" - > - {context.projects.total === 0 ? ( - - - - - - No Projects yet - - You haven't created any Projects yet. Get started by creating - a new or cloning an existing Project. - - - - ) : ( -
- {context.projects.list.map((project) => { - return ( - - - - {project.name} - {project.description} - - - - - - - - My Account - - Profile - Billing - Team - Subscription - - - - - - - - Core version: {project.coreVersion} - - - - ); - })} -
- )} -
+ <> + } + actions={} + layout="bare" + > + {isProjectsPending ? ( +
+ {[1, 2, 3, 4, 5].map((i) => { + return ; + })} +
+ ) : projects.total === 0 ? ( + + + + + + No Projects yet + + You haven't created any Projects yet. Get started by + creating a new or cloning an existing Project. + + + + ) : ( +
+ {projects.list.map((project) => { + return ; + })} +
+ )} +
+ + + + + Clone a Project by URL + + You can clone an existing Project by providing the URL. Make sure + you have the necessary permissions to access the Project. + + + + +
+ + ( + + URL + + + + + + + )} + /> + + +
+ + + + +
+
+ ); } From 968fd89d67609013b1dab2e225ed986d8a9b13fd Mon Sep 17 00:00:00 2001 From: Nils Kolvenbach Date: Mon, 17 Nov 2025 14:33:30 +0100 Subject: [PATCH 05/36] Fixed linting errors --- src/renderer/components/project-card.tsx | 4 ++-- src/renderer/queries/client.ts | 12 ++++++------ src/renderer/queries/options.ts | 4 ++-- src/renderer/routes/__root.tsx | 2 +- src/renderer/routes/projects/index.tsx | 10 +++++----- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/renderer/components/project-card.tsx b/src/renderer/components/project-card.tsx index 1de1c75..d8a080e 100644 --- a/src/renderer/components/project-card.tsx +++ b/src/renderer/components/project-card.tsx @@ -56,10 +56,10 @@ function ProjectCard({ project }: { project: Project }): React.JSX.Element { - Core version: {project.coreVersion} + Core version: {project.coreVersion} ); diff --git a/src/renderer/queries/client.ts b/src/renderer/queries/client.ts index 04e3241..15fdc2f 100644 --- a/src/renderer/queries/client.ts +++ b/src/renderer/queries/client.ts @@ -25,7 +25,7 @@ export const queryClient = new QueryClient({ staleTime: Infinity, }, mutations: { - onSuccess: (data, variables, result, context) => { + onSuccess: async (data, variables, result, context) => { const logableMutationMeta = logableMutationMetaSchema.safeParse( context.meta ); @@ -35,7 +35,7 @@ export const queryClient = new QueryClient({ `${logableMutationMeta.data.method} ${logableMutationMeta.data.objectType}` ); - window.ipc.core.logger.info({ + await window.ipc.core.logger.info({ source: 'desktop', message: `Successfully ${logableMutationMeta.data.method}ed ${logableMutationMeta.data.objectType}`, meta: { @@ -43,7 +43,7 @@ export const queryClient = new QueryClient({ }, }); } else { - window.ipc.core.logger.error({ + await window.ipc.core.logger.error({ source: 'desktop', message: 'Detected mutation without meta', meta: { @@ -55,7 +55,7 @@ export const queryClient = new QueryClient({ }); } }, - onError: (error, variables, result, context) => { + onError: async (error, variables, result, context) => { const logableMutationMeta = logableMutationMetaSchema.safeParse( context.meta ); @@ -65,7 +65,7 @@ export const queryClient = new QueryClient({ `${logableMutationMeta.data.method} ${logableMutationMeta.data.objectType}` ); - window.ipc.core.logger.error({ + await window.ipc.core.logger.error({ source: 'desktop', message: `Failed to ${logableMutationMeta.data.method} ${logableMutationMeta.data.objectType}`, meta: { @@ -73,7 +73,7 @@ export const queryClient = new QueryClient({ }, }); } else { - window.ipc.core.logger.error({ + await window.ipc.core.logger.error({ source: 'desktop', message: 'Detected mutation without meta', meta: { diff --git a/src/renderer/queries/options.ts b/src/renderer/queries/options.ts index e74a327..9480421 100644 --- a/src/renderer/queries/options.ts +++ b/src/renderer/queries/options.ts @@ -191,10 +191,10 @@ export default { method: 'synchronize', objectType: 'project', }, - onSuccess: (_data, _variables, _onMutateResult, context) => { + onSuccess: async (_data, _variables, _onMutateResult, context) => { // On synchronization anything inside the Project may have changed // so we invalidate it entirely - context.client.invalidateQueries({ + await context.client.invalidateQueries({ queryKey: ['projects', project.id], refetchType: 'all', }); diff --git a/src/renderer/routes/__root.tsx b/src/renderer/routes/__root.tsx index 588b6fa..ce62e9f 100644 --- a/src/renderer/routes/__root.tsx +++ b/src/renderer/routes/__root.tsx @@ -127,7 +127,7 @@ function RootComponent(): ReactElement { return ( <> - + diff --git a/src/renderer/routes/projects/index.tsx b/src/renderer/routes/projects/index.tsx index d42f145..9e912c7 100644 --- a/src/renderer/routes/projects/index.tsx +++ b/src/renderer/routes/projects/index.tsx @@ -91,7 +91,7 @@ function ListProjectsPage(): ReactElement { + + + +
+ +
+ {user.name} + {user.email} +
+
+
+ router.navigate({ to: '/user/profile' })} + > + Profile + ⇧⌘P + + + + + + +
+
Local API
+
+ + checked ? startApi(user.localApi.port) : stopApi() + } + /> +
+
+
+ window.open(localApiUrl, '_blank')} + disabled={ + isLocalApiRunning === false || isStartingApi || isStoppingApi + } + > + Open + + + + +
+ + + + + + Theme + + + setTheme(theme as Theme)} + > + + System + + + + Light + + + + Dark + + + + + + +
+ + ); +} + +export function UserDropdownSkeleton(): React.JSX.Element { + return ( +
+ +
+ + + + + + +
+ +
+ ); +} diff --git a/src/renderer/components/user-header.tsx b/src/renderer/components/user-header.tsx index 1d32669..3cebed2 100644 --- a/src/renderer/components/user-header.tsx +++ b/src/renderer/components/user-header.tsx @@ -1,8 +1,8 @@ +import { useQuery } from '@tanstack/react-query'; import { Link, useRouter, useRouterState } from '@tanstack/react-router'; -import { ArrowLeft, ArrowRight, ChevronDown, Moon, Sun } from 'lucide-react'; -import { forwardRef, Fragment, type HTMLAttributes } from 'react'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; +import { Fragment } from 'react/jsx-runtime'; -import { Avatar } from '@renderer/components/ui/avatar'; import { Breadcrumb, BreadcrumbItem, @@ -11,103 +11,91 @@ import { BreadcrumbSeparator, } from '@renderer/components/ui/breadcrumb'; import { Button } from '@renderer/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuPortal, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from '@renderer/components/ui/dropdown-menu'; -import { useTheme, type Theme } from '@renderer/hooks/useTheme'; +import { queryOptions } from '@renderer/queries'; import { useStore } from '@renderer/store'; -import { type User } from '@elek-io/core'; +import { UserDropdown, UserDropdownSkeleton } from './user-dropdown'; -export interface UserHeaderProps extends HTMLAttributes { - user: User; -} +export function UserHeader(): React.JSX.Element { + const router = useRouter(); + const routerState = useRouterState(); + const { + data: user, + isPending: isUserPending, + isError: isUserError, + error: userError, + } = useQuery(queryOptions.user.get()); + // @todo causes "Maximum update depth exceeded" error + // const [isProjectSidebarNarrow, setIsProjectSidebarNarrow] = useStore( + // (storeState) => [ + // storeState.isProjectSidebarNarrow, + // storeState.setIsProjectSidebarNarrow, + // ] + // ); + const breadcrumbLookupMap = useStore( + (storeState) => storeState.breadcrumbLookupMap + ); + const breadcrumbs = routerState.location.pathname + .split('/') + .filter((value) => value) // Filter out empty values for beginning or ending slashes + .map((part, index, array) => { + const path = array.slice(0, index + 1).join('/'); -const UserHeader = forwardRef( - ({ user }, ref) => { - const router = useRouter(); - const routerState = useRouterState(); - const { theme, setTheme } = useTheme(); - // @todo causes "Maximum update depth exceeded" error - // const [isProjectSidebarNarrow, setIsProjectSidebarNarrow] = useStore( - // (storeState) => [ - // storeState.isProjectSidebarNarrow, - // storeState.setIsProjectSidebarNarrow, - // ] - // ); - const breadcrumbLookupMap = useStore( - (storeState) => storeState.breadcrumbLookupMap - ); - const breadcrumbs = routerState.location.pathname - .split('/') - .filter((value) => value) // Filter out empty values for beginning or ending slashes - .map((part, index, array) => { - const path = array.slice(0, index + 1).join('/'); + // @todo add translation for static breadcrumb parts + switch (part) { + case 'projects': + part = 'Projects'; + break; + case 'dashboard': + part = 'Dashboard'; + break; + case 'assets': + part = 'Assets'; + break; + case 'collections': + part = 'Collections'; + break; + case 'settings': + part = 'Settings'; + break; + case 'create': + part = 'Create'; + break; + case 'update': + part = 'Update'; + break; + case 'general': + part = 'General'; + break; + case 'en': + part = 'English'; // @todo mapping between locale ID and localized name + break; + default: + break; + } - // @todo add translation for static breadcrumb parts - switch (part) { - case 'projects': - part = 'Projects'; - break; - case 'dashboard': - part = 'Dashboard'; - break; - case 'assets': - part = 'Assets'; - break; - case 'collections': - part = 'Collections'; - break; - case 'settings': - part = 'Settings'; - break; - case 'create': - part = 'Create'; - break; - case 'update': - part = 'Update'; - break; - case 'general': - part = 'General'; - break; - case 'en': - part = 'English'; // @todo mapping between locale ID and localized name - break; - default: - break; - } + // Use names instead of IDs to display + const match = breadcrumbLookupMap.get(part); + if (match !== undefined) { + part = match; + } - // Use names instead of IDs to display - const match = breadcrumbLookupMap.get(part); - if (match !== undefined) { - part = match; - } + return { + part, + path: `/${path}`, + full: routerState.location.pathname, + }; + }); - return { - part, - path: `/${path}`, - full: routerState.location.pathname, - }; - }); + if (isUserError) { + throw userError; + } - return ( -
- - ); - } -); -UserHeader.displayName = 'ProjectHeader'; - -export { UserHeader }; +
+ ); +} diff --git a/src/renderer/queries/options.ts b/src/renderer/queries/options.ts index 9480421..fa988d0 100644 --- a/src/renderer/queries/options.ts +++ b/src/renderer/queries/options.ts @@ -17,7 +17,6 @@ import { type ReadAssetProps, type ReadCollectionProps, type ReadProjectProps, - type SetUserProps, type UpdateAssetProps, type UpdateCollectionProps, type UpdateProjectProps, @@ -466,18 +465,44 @@ export default { return await window.ipc.core.user.get(); }, }), - set: (props: SetUserProps) => - mutationOptions({ - mutationFn: async () => { - return await window.ipc.core.user.set(props); - }, - meta: { - method: 'set', - objectType: 'user', - }, - onSuccess: (updatedUser, _variables, _onMutateResult, context) => { - context.client.setQueryData(['user'], updatedUser); + set: mutationOptions({ + mutationFn: window.ipc.core.user.set, + meta: { + method: 'set', + objectType: 'user', + }, + onSuccess: (updatedUser, _variables, _onMutateResult, context) => { + context.client.setQueryData(['user'], updatedUser); + }, + }), + }, + api: { + isRunning: () => + queryOptions({ + queryKey: ['user', 'localApi', 'isRunning'], + queryFn: async () => { + return await window.ipc.core.api.isRunning(); }, }), + start: mutationOptions({ + mutationFn: window.ipc.core.api.start, + meta: { + method: 'start', + objectType: 'api', + }, + onSuccess: (_data, _variables, _onMutateResult, context) => { + context.client.setQueryData(['user', 'localApi', 'isRunning'], true); + }, + }), + stop: mutationOptions({ + mutationFn: window.ipc.core.api.stop, + meta: { + method: 'stop', + objectType: 'api', + }, + onSuccess: (_data, _variables, _onMutateResult, context) => { + context.client.setQueryData(['user', 'localApi', 'isRunning'], false); + }, + }), }, }; diff --git a/src/renderer/routes/__root.tsx b/src/renderer/routes/__root.tsx index ce62e9f..0f784e9 100644 --- a/src/renderer/routes/__root.tsx +++ b/src/renderer/routes/__root.tsx @@ -14,6 +14,7 @@ import { Page } from '@renderer/components/page'; import { Button } from '@renderer/components/ui/button'; import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area'; import { Toaster } from '@renderer/components/ui/sonner'; +import { UserHeader } from '@renderer/components/user-header'; export interface RouterContext {} @@ -127,6 +128,7 @@ function RootComponent(): ReactElement { return ( <> + diff --git a/src/renderer/routes/projects.tsx b/src/renderer/routes/projects.tsx index 725be75..2bac3a7 100644 --- a/src/renderer/routes/projects.tsx +++ b/src/renderer/routes/projects.tsx @@ -1,31 +1,10 @@ -import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; +import { createFileRoute, Outlet } from '@tanstack/react-router'; import type { ReactElement } from 'react'; -import { UserHeader } from '@renderer/components/user-header'; - -import { queryClient, queryOptions } from '../queries'; - export const Route = createFileRoute('/projects')({ - beforeLoad: async () => { - const user = await queryClient.ensureQueryData(queryOptions.user.get()); - if (!user) { - throw redirect({ - to: '/user/profile', - }); - } - - return { user }; - }, component: ProjectsLayout, }); function ProjectsLayout(): ReactElement { - const { user } = Route.useRouteContext(); - - return ( - <> - - - - ); + return ; } diff --git a/src/renderer/routes/user/profile.tsx b/src/renderer/routes/user/profile.tsx index f469126..1d9b829 100644 --- a/src/renderer/routes/user/profile.tsx +++ b/src/renderer/routes/user/profile.tsx @@ -1,7 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { createFileRoute, useRouter } from '@tanstack/react-router'; import { Check } from 'lucide-react'; -import { type ReactElement, useState } from 'react'; +import { type ReactElement, useEffect } from 'react'; import { type SubmitHandler, useForm } from 'react-hook-form'; import { CommitHistory } from '@renderer/components/commit-history'; @@ -27,8 +28,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { Switch } from '@renderer/components/ui/switch'; -import { UserHeader } from '@renderer/components/user-header'; -import { useStore } from '@renderer/store'; +import { queryOptions } from '@renderer/queries'; import { type GitCommit, @@ -38,41 +38,52 @@ import { } from '@elek-io/core'; export const Route = createFileRoute('/user/profile')({ - beforeLoad: async ({ context }) => { - const user = await context.core.user.get(); - - return { user }; - }, component: UserProfilePage, }); function UserProfilePage(): ReactElement { const router = useRouter(); - const context = Route.useRouteContext(); - const addNotification = useStore((state) => state.addNotification); - const [isSettingUser, setIsSettingUser] = useState(false); + const { data: user, isPending: isUserPending } = useQuery( + queryOptions.user.get() + ); + const { data: isLocalApiRunning } = useQuery(queryOptions.api.isRunning()); + const { mutateAsync: startApi } = useMutation(queryOptions.api.start); + const { mutateAsync: stopApi } = useMutation(queryOptions.api.stop); + const { mutateAsync: setUser, isPending: isSettingUser } = useMutation( + queryOptions.user.set + ); const setUserForm = useForm({ resolver: async (data, context, options) => { return zodResolver(setUserSchema)(data, context, options); }, defaultValues: { userType: 'local', - name: context.user?.name !== undefined ? context.user.name : '', - email: context.user?.email !== undefined ? context.user.email : '', - language: - context.user?.language !== undefined ? context.user.language : 'en', + name: '', + email: '', + language: 'en', localApi: { - port: - context.user?.localApi.port !== undefined - ? context.user.localApi.port - : 31310, - isEnabled: - context.user?.localApi.isEnabled !== undefined - ? context.user.localApi.isEnabled - : false, + port: 31310, + isEnabled: false, }, }, }); + + // Reset form with user data when it loads + useEffect(() => { + if (user) { + setUserForm.reset({ + userType: 'local', + name: user.name, + email: user.email, + language: user.language, + localApi: { + port: user.localApi.port, + isEnabled: user.localApi.isEnabled, + }, + }); + } + }, [user, setUserForm]); + const exampleCommit: GitCommit = { hash: '1234567890', author: { @@ -89,10 +100,10 @@ function UserProfilePage(): ReactElement { }, }, }; - const localApiUrl = `http://localhost:${setUserForm.watch('localApi.port')}/v1/ui`; + const localApiUrl = `http://localhost:${setUserForm.watch('localApi.port')}`; function Description(): ReactElement { - if (context.user === null) { + if (user === null) { return ( <> Before we start you need to set up a local User first. Don't @@ -140,56 +151,31 @@ function UserProfilePage(): ReactElement { } const onSetUser: SubmitHandler = async (props) => { - setIsSettingUser(true); - try { - const user = await context.core.user.set(props); - const isLocalApiRunning = await context.core.api.isRunning(); + const user = await setUser(props); - if (user.localApi.isEnabled === true && isLocalApiRunning === false) { - await context.core.api.start(user.localApi.port); - } - - if (user.localApi.isEnabled === false && isLocalApiRunning === true) { - await context.core.api.stop(); - } + if (user.localApi.isEnabled === true && isLocalApiRunning === false) { + await startApi(user.localApi.port); + } - setIsSettingUser(false); - await router.navigate({ to: '/projects' }); - addNotification({ - intent: 'success', - title: 'Successfully setup User', - description: 'The User was successfully setup.', - }); - } catch (error) { - setIsSettingUser(false); - await context.core.logger.error({ - source: 'desktop', - message: 'Failed to setup User', - meta: { error }, - }); - addNotification({ - intent: 'danger', - title: 'Failed to setup User', - description: 'There was an error setting up the User.', - }); + if (user.localApi.isEnabled === false && isLocalApiRunning === true) { + await stopApi(); } + + await router.navigate({ to: '/projects' }); }; return ( - <> - {context.user !== null ? : null} - } - actions={} - layout="bare" - > -
- -
- + } + actions={} + layout="bare" + > +
+ + + +
Enabled - Enabling the local API allows you to read local - Project data. + By enabling the local API it will start whenever + elek.io Client is opened. This allows you to read + local Project data from other applications.
@@ -323,25 +310,25 @@ function UserProfilePage(): ReactElement { />
- - - - -
-
- + + + + + + + ); } From 9b472774a187a3fe5ad360a8f921d6b7cf97b1b5 Mon Sep 17 00:00:00 2001 From: Nils Kolvenbach Date: Tue, 18 Nov 2025 00:28:14 +0100 Subject: [PATCH 07/36] Disabled select component should not be clickable --- src/renderer/components/ui/input.tsx | 2 +- src/renderer/components/ui/select.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/ui/input.tsx b/src/renderer/components/ui/input.tsx index 8f2403f..ac95863 100644 --- a/src/renderer/components/ui/input.tsx +++ b/src/renderer/components/ui/input.tsx @@ -12,7 +12,7 @@ function Input({ type={type} data-slot="input" className={cn( - 'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30', + 'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30', 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50', 'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40', className diff --git a/src/renderer/components/ui/select.tsx b/src/renderer/components/ui/select.tsx index 4f48410..d18faed 100644 --- a/src/renderer/components/ui/select.tsx +++ b/src/renderer/components/ui/select.tsx @@ -35,7 +35,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "flex w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", + "flex w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className )} {...props} From 7c8d05f4451790dc8dc0980e44a7c239a1f3d037 Mon Sep 17 00:00:00 2001 From: Nils Kolvenbach Date: Tue, 18 Nov 2025 14:44:16 +0100 Subject: [PATCH 08/36] Renamed sentry.ts to index.ts and using env.d.ts for types --- src/renderer/app.tsx | 7 +++---- src/renderer/env.d.ts | 5 +++++ src/renderer/index.d.ts | 4 ---- src/renderer/{sentry.ts => index.ts} | 0 4 files changed, 8 insertions(+), 8 deletions(-) delete mode 100644 src/renderer/index.d.ts rename src/renderer/{sentry.ts => index.ts} (100%) diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 8ff268a..b50efe5 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -1,4 +1,3 @@ -// Sentry initialization should be imported first! import '@fontsource-variable/montserrat'; import '@fontsource/roboto'; import { QueryClientProvider } from '@tanstack/react-query'; @@ -7,11 +6,11 @@ import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; import { ThemeProvider } from '@renderer/components/theme-provider'; +import '@renderer/index'; +// eslint-disable-next-line no-duplicate-imports +import { reactErrorHandler, router } from '@renderer/index'; import '@renderer/index.css'; import { queryClient } from '@renderer/queries'; -import '@renderer/sentry'; -// eslint-disable-next-line no-duplicate-imports -import { reactErrorHandler, router } from '@renderer/sentry'; // Render the app const rootElement = document.getElementById('app')!; diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 11f02fe..85d7852 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -1 +1,6 @@ /// + +// @see https://fontsource.org/docs/getting-started/faq#typescript-errors +declare module '*.css'; +declare module '@fontsource/*' {} +declare module '@fontsource-variable/*' {} diff --git a/src/renderer/index.d.ts b/src/renderer/index.d.ts deleted file mode 100644 index 312c374..0000000 --- a/src/renderer/index.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// @see https://fontsource.org/docs/getting-started/faq#typescript-errors -declare module '*.css'; -declare module '@fontsource/*' {} -declare module '@fontsource-variable/*' {} diff --git a/src/renderer/sentry.ts b/src/renderer/index.ts similarity index 100% rename from src/renderer/sentry.ts rename to src/renderer/index.ts From b0b498c629d1ee164afcbf6a6bfc06e0c223f8b7 Mon Sep 17 00:00:00 2001 From: Nils Kolvenbach Date: Tue, 18 Nov 2025 15:04:36 +0100 Subject: [PATCH 09/36] Added README as entry for dev documentation and updated all documentation pages. --- documentation/README.md | 43 ++++ documentation/consuming-content-locally.md | 178 ++++++++++++--- documentation/overview.md | 48 +++- .../renderer/dynamic-form-field-generation.md | 213 +++++++++++++++--- .../renderer/loading-and-updating-data.md | 130 ++++++++++- 5 files changed, 528 insertions(+), 84 deletions(-) create mode 100644 documentation/README.md diff --git a/documentation/README.md b/documentation/README.md new file mode 100644 index 0000000..9f457f5 --- /dev/null +++ b/documentation/README.md @@ -0,0 +1,43 @@ +# Developer Documentation + +Welcome to the elek.io Client developer documentation. This guide will help you understand the codebase architecture and development patterns. + +> [!NOTE] +> If your main interest is using Projects content inside your own applications, you can skip to the [Consuming Content Locally](./consuming-content-locally.md) section. + +## Prerequisites + +- Familiarity with TypeScript and React +- Basic understanding of Electron architecture +- Knowledge of React Hook Form and TanStack Query (helpful but not required) +- Understanding of Git and version control concepts + +## Getting Started + +**Start with [Overview](./overview.md)** to understand the application architecture, security model, and how the different processes communicate. + +Then proceed to the specific topics based on your interests or contribution goals: + +- **[Loading and Updating Data](./renderer/loading-and-updating-data.md)** - TanStack Query patterns for data fetching and mutations +- **[Dynamic Form Generation](./renderer/dynamic-form-field-generation.md)** - How user-defined forms work with field definitions + +## Contributing + +When updating these docs: + +- Keep code examples up-to-date with actual implementation +- Include file references with line numbers where relevant +- Add practical examples for complex concepts +- Update the "Last Updated" date at the bottom of each document + +## Additional Resources + +- [Electron Documentation](https://www.electronjs.org/docs/latest) +- [TanStack Query Documentation](https://tanstack.com/query/latest) +- [React Hook Form Documentation](https://react-hook-form.com/get-started) +- [shadcn/ui Documentation](https://ui.shadcn.com/docs) +- [@elek-io/core Repository](https://github.com/elek-io/core) + +--- + +**Last Updated:** 2025-11-18 diff --git a/documentation/consuming-content-locally.md b/documentation/consuming-content-locally.md index 7030b8c..f39c6ad 100644 --- a/documentation/consuming-content-locally.md +++ b/documentation/consuming-content-locally.md @@ -1,90 +1,164 @@ -# Consuming content locally +# Consuming Content Locally -Consuming created content is essential to actually benefit from a CMS. elek.io Client and Core therefore provide multiple ways of doing so. +## Overview -**Use Cases**: +Once you've created content in elek.io, you probably want to consume it in your applications. elek.io Client and Core provide multiple ways to access your Project data from external applications. -- Static site generators (Next.js, Gatsby, Hugo) -- Custom build tools -- Really anything you can think of +**Common Use Cases**: -## Export Project data to JSON file +- **Static Site Generators**: Fetch content for Next.js, Gatsby, Astro, Hugo, etc. +- **Custom Build Tools**: Integrate content into your CI/CD pipeline +- **Mobile Apps**: Access content via the local API from React Native, Flutter, etc. +- **Desktop Applications**: Read content from other Electron or native apps +- **Documentation Sites**: Generate docs from your elek.io content +- **Data Analysis**: Export content for analytics or reporting tools -elek.io Core can be installed globally via your package manager of choice: +## Export Project Data to JSON Files + +### Installation + +First, install elek.io Core globally via your package manager: ```bash npm install -g @elek-io/core ``` -With the included CLI you can then run one of the following commands inside e.g. your websites source code directory: +### Export Commands + +The built-in CLI allows you to export Project data to JSON files. Run these commands e.g. inside your website's source code directory (or any directory where you want the exported data). Alternatively integrate them into your build scripts or CI/CD pipelines to ensure your application always has the latest content: ```bash # Export all Projects content to .elek.io/projects.json elek export -# Export one Project to .elek.io/projects.json -elek export ./.elek.io [projectId] +# Export one Project by ID to .elek.io/projects.json +elek export ./.elek.io 467e57ea-e04a-44a7-b34b-684ed3ba6f49 # Export one Project to .elek.io/project-${id}.json -elek export ./.elek.io [projectId] +elek export ./.elek.io 467e57ea-e04a-44a7-b34b-684ed3ba6f49 # Export multiple Projects into separate .elek.io/project-${id}.json files -elek export ./.elek.io [projectId1,projectId2] --separate +elek export ./.elek.io 467e57ea-e04a-44a7-b34b-684ed3ba6f49,bcffed17-4946-4336-b420-3974d9c94a43 --separate ``` -Add the `-w` or `--watch` option to any of these commands to automatically re-export whenever a Project changes. +> [!NOTE] +> Replace `467e57ea-e04a-44a7-b34b-684ed3ba6f49` with your actual Project ID. You can find Project IDs in the elek.io Client UI or in your Project's `.elek.io` directory. + +### Watch Mode -Then use the content inside these JSON file(s) to populate your website or application: +Add the `-w` or `--watch` flag to automatically re-export whenever a Project changes: + +```bash +elek export ./.elek.io 467e57ea-e04a-44a7-b34b-684ed3ba6f49 --watch +``` + +This is particularly useful during development when you're actively updating content. + +### Using Exported JSON + +Once exported, you can import and use the JSON data in your application: ```typescript import * as projects from './.elek.io/projects.json' with { type: 'json' }; -const entries = projects['...'].collections['...'].entries; +// Access a specific Project's Collections +const myProject = projects['467e57ea-e04a-44a7-b34b-684ed3ba6f49']; +const blogPosts = myProject.collections['blog-posts'].entries; -// Use the Entries +// Use the Entries in your app +blogPosts.forEach((post) => { + console.log(post.title, post.content); +}); ``` ## Local API -Enable the local API in your User Profile of elek.io Client → "Enable Local API Server". Then you can visit `http://localhost:31310/` (or your specified port) to view a rendered OpenAPI documentation and execute queries. +elek.io Core also provides a local REST API server that runs on your machine, allowing applications to query your elek.io Projects for content. + +### Enabling the Local API + +**Via elek.io Client** + +1. Open elek.io Client +2. Navigate to **User Profile** (top-right menu) +3. Scroll to the **Local API** section +4. Toggle **"Enable Local API Server"** +5. Optionally change the port (default: `31310`) +6. Click **Save** + +**Via elek.io Core** + +You can also start the local API server directly using elek.io Core CLI: + +```bash +elek api:start 31310 +``` + +Or programmatically in your Node.js application: + +```typescript +import ElekIoCore from '@elek-io/core'; + +const core = new ElekIoCore(); +core.api.start(31310); +``` + +Once enabled, you can visit `http://localhost:31310/` (or your specified port) to view the OpenAPI documentation and execute test queries. + +> [!WARNING] +> Make sure the port you choose is not already in use by another application. If the API fails to start, try a different port. -### Generate TS/JS API Client +### Generate TypeScript/JavaScript API Client -Of course you could write an API Client yourself but for TypeScript / JavaScript applications we provide a generated API Client with validation. +While you can write an API client manually, elek.io provides a code generator that creates a type-safe client with built-in validation for TypeScript and JavaScript applications. -Install elek.io Core globally via your package manager of choice: +#### Generate the Client + +If you haven't already, install elek.io Core globally: ```bash npm install -g @elek-io/core ``` -Then use the CLI to generate a TS or JS API Client with one of the following commands: +Then generate the API client: ```bash -# Generate TS API Client with default options in ./.elek.io/client.ts +# Generate TypeScript API Client with default options in ./.elek.io/client.ts elek generate:client -# Generate JS API Client with ESM and target ES2020 in ./.elek.io/client.ts +# Generate JavaScript API Client with ESM and target ES2020 elek generate:client ./.elek.io js esm es2020 ``` -Then import and use the generated API Client like: +#### Using the Generated Client + +Import and use the generated API client in your application: ```typescript import { apiClient } from './.elek.io/client.js'; const client = apiClient({ baseUrl: 'http://localhost:31310', - apiKey: 'abc123', // Not used for now + apiKey: 'abc123', // Not currently used, reserved for future authentication }); -const entries = - await client.content.v1.projects['...'].collections['...'].entries.list(); +// Fetch all Entries from a specific Collection +const blogPosts = + await client.content.v1.projects[ + '467e57ea-e04a-44a7-b34b-684ed3ba6f49' + ].collections['blog-posts'].entries.list(); + +// Access individual Entries +blogPosts.forEach((post) => { + console.log(post.title, post.publishedAt); +}); ``` -### Generate API Client for other languages +### Generate API Client for Other Languages -For languages other than TS/JS you can either write an API Client yourself, or use an OpenAPI generator like: +For languages other than TypeScript/JavaScript, you can use any OpenAPI-compatible code generator. The local API exposes its full schema at `/openapi.json`. + +#### Example: Generate a Java Client ```bash npx openapi-generator-cli generate \ @@ -92,3 +166,47 @@ npx openapi-generator-cli generate \ -g java \ -o ./.elek.io/client ``` + +#### Example: Generate a Python Client + +```bash +npx openapi-generator-cli generate \ + -i http://localhost:31310/openapi.json \ + -g python \ + -o ./.elek.io/client +``` + +> [!NOTE] +> Make sure the local API is running before generating the client, as the generator needs to fetch the OpenAPI schema. + +For more generators and options, see the [OpenAPI Generator documentation](https://openapi-generator.tech/docs/generators). + +## Troubleshooting + +### Port Already in Use + +If you see an error that the port is already in use: + +1. Check which application is using the port: `lsof -i :31310` (macOS/Linux) or `netstat -ano | findstr :31310` (Windows) +2. Either stop that application or choose a different port + +### API Not Starting + +If the local API fails to start: + +1. Check the elek.io Client logs for error messages +2. Ensure you have the latest version of elek.io Client and Core +3. Try restarting elek.io Client +4. Verify no firewall is blocking the port + +### Generated Client Not Working + +If the generated API client has type errors or runtime issues: + +1. Regenerate the client with the latest schema +2. Ensure the local API is running and accessible +3. Check that your Project IDs and Collection IDs are correct + +--- + +**Last Updated:** 2025-11-18 diff --git a/documentation/overview.md b/documentation/overview.md index 91a383c..c56ab23 100644 --- a/documentation/overview.md +++ b/documentation/overview.md @@ -1,6 +1,15 @@ # Overview -elek.io Client is built on [Electron](https://www.electronjs.org/), to create a cross-platform desktop application with web technologies (TypeScript, React). The architecture consists of three main layers: the Main Process, the Preload Script, and the Renderer Process. +## Introduction + +elek.io Client is built on [Electron](https://www.electronjs.org/) to create a cross-platform desktop application with web technologies (TypeScript, React). + +> [!NOTE] +> By choosing Electron, we keep the languages (everything is mainly TypeScript) used throughout our repositories to a minimum and the knowledge barrier for potential contributors low. Although applications build e.g. with [Tauri](https://tauri.app/) do have a smaller bundle size and memory footprint, adding another language (Rust) would increase complexity for contributors by alot. + +## Architecture + +Electron applications consist of three main layers: the Main Process, the Preload Script, and the Renderer Process. ``` ┌─────────────────────────────────────────────────────────┐ @@ -24,7 +33,7 @@ elek.io Client is built on [Electron](https://www.electronjs.org/), to create a While elek.io Client provides the user interface in a desktop application, all core functionalities related to file I/O, content handling, Git operations, local read-only API hosting and CLI usage are encapsulated in the separate [@elek-io/core](https://github.com/elek-io/core) library. -It is used by the Main Process, since only it has access to the filesystem and Node.js APIs - the Renderer Process is sandboxed for security reasons. +@elek-io/core is used by the Main Process, since only it has access to the filesystem and Node.js APIs - the Renderer Process is sandboxed for security reasons. Therefore the Renderer Process communicates with the Main Process via IPC (Inter-Process Communication) to request operations via @elek-io/core. @@ -61,11 +70,11 @@ contextBridge.exposeInMainWorld('ipc', { // Renderer process uses the IPC API (src/renderer/) const project = await window.ipc.core.projects.create({ name: 'My Project', - description: 'A new project', + description: 'A new Project', }); ``` -50+ IPC Channels are available and organized by namespace. +35+ IPC Channels are available and organized by namespace. ### Project Structure @@ -83,6 +92,7 @@ client/ │ │ └── ui/ # UI components │ ├── hooks/ # React hooks │ ├── lib/ # Utilities +│ ├── queries/ # Data fetching, mutations and caching │ ├── routes/ # File-based routing │ │ ├── projects/ │ │ │ ├── $projectId/ @@ -98,9 +108,7 @@ client/ │ │ │ └── profile.tsx │ │ └── __root.tsx │ ├── app.tsx # App entry point -│ ├── ipc.ts # IPC communication -│ ├── sentry.ts # Error monitoring -│ └── store.ts # State management +│ └── index.ts # Error monitoring and router setup ├── build/ # Build resources (icons, etc.) ├── documentation/ # Developer docs ├── electron-builder.yml # Build configuration @@ -111,7 +119,9 @@ client/ ### Security -As mentioned, elek.io Client only allows the Renderer Process to communicate with the Main Process via a controlled IPC API exposed through the Preload Script. This follows the [security best practices of Electron](https://www.electronjs.org/docs/latest/tutorial/security) and ensures that untrusted code running in the Renderer Process (e.g. third-party libraries, users content) cannot directly access Node.js APIs or the filesystem. +Handling user content that is distributed and could potentially be malicious within an app that has access to the file system, strict security is necessary. elek.io Client follows [Electron's security best practices](https://www.electronjs.org/docs/latest/tutorial/security) to create strong isolation boundaries. + +The Renderer Process can only communicate with the Main Process via a controlled IPC API exposed through the Preload Script. This ensures that untrusted code running in the Renderer Process (e.g., third-party libraries or user content) cannot directly access Node.js APIs or the filesystem. #### Renderer Process Isolation @@ -131,14 +141,28 @@ A Content Security Policy is enforced via a `` tag in the `src/renderer/in #### External Content Restrictions -Some links to elek.io domains and loading of content are allowed in elek.io Client. To prevent abuse, the following restrictions are in place: +Some links to elek.io domains and loading of content are allowed in elek.io Client. To prevent abuse and potential security risks, the following restrictions are in place: **URL Whitelisting**: -All external requests (e.g. when a user clicks a link inside the renderer or an Asset is displayed) are checked against a whitelist. See `allowedHostnamesToLoadExternal` in [`src/main/index.ts`](/src/main/index.ts). +All external requests (e.g., when a user clicks a link inside the renderer or an Asset is displayed) are checked against a whitelist of allowed hostnames: -Links to these hostnames are opened inside the default browser, not an elek.io Client renderer. +- `elek-io-local-file://` (custom file protocol) +- `localhost` +- `elek.io` +- `api.elek.io` +- `github.com` + +See `allowedHostnamesToLoadExternal` in [`src/main/index.ts:41-46`](/src/main/index.ts) for the implementation. + +Links to whitelisted external hostnames are opened in the default system browser, not within an elek.io Client renderer window. **Custom File Protocol**: -Loading of Assets in the UI is handled via a custom file protocol `elek-io-local-file://` since the standard `file://` protocol in electron has more privileges than in a browser. This ensures path validation (must be within project or tmp folders) and prevents directory traversal. +Loading of Assets in the UI is handled via a custom file protocol `elek-io-local-file://` since the standard `file://` protocol in Electron has more privileges than in a browser. This custom protocol implementation ensures path validation (files must be within Project or tmp folders) and prevents directory traversal attacks. + +See [`src/main/index.ts:267-305`](/src/main/index.ts) for the custom protocol implementation. + +--- + +**Last Updated:** 2025-11-18 diff --git a/documentation/renderer/dynamic-form-field-generation.md b/documentation/renderer/dynamic-form-field-generation.md index a16627e..3d3279e 100644 --- a/documentation/renderer/dynamic-form-field-generation.md +++ b/documentation/renderer/dynamic-form-field-generation.md @@ -1,62 +1,209 @@ -# Dynamic form field generation +# Dynamic Form Field Generation -Handling static forms that stay the same is straight forward. We simply use the correct input component and attach it to the forms field. But when it comes to user defined forms it gets tricky. +## Overview -We want to be able to define a form field in a way that it can be rendered dynamically. This means we need to define what type of input it is, what label and description it has, if it is required and so on. +Static forms with predefined fields are straightforward to implement. However, elek.io allows users to define their own content structures through Collections, which means forms must be generated dynamically based on user-defined schemas. -This is done using field definitions. A field definition is simply a JSON object that contains all the necessary information to render a form field. +**Why Dynamic Forms?** -When a user creates a Collection he can define those field definitions: +Users need the flexibility to create custom content types without modifying code. For example: + +- A blog might need: title (text), content (textarea), published (boolean) +- A product catalog might need: name (text), price (number), inStock (boolean), images (file) +- An event calendar might need: title (text), date (date), location (text) + +We can't hardcode these forms since they are user-defined, instead we use **field definitions** to describe each field's properties, allowing the UI to render appropriate form controls dynamically. + +## Field Definitions + +A field definition is a JSON object that contains all necessary information to render and validate a form field. + +**Example field definition for a blog post title:** ```typescript { id: '467e57ea-e04a-44a7-b34b-684ed3ba6f49', - valueType: 'string', - fieldType: 'text', - label: { + valueType: 'string', // Data type for validation + fieldType: 'text', // UI component to render + label: { // Label with translations en: 'Title', de: 'Titel', }, - description: { + description: { // Description with translations en: 'A short title for the Entry', de: 'Ein kurzer Titel für den Eintrag', }, - min: null, - max: 100, - defaultValue: null, - inputWidth: '12', - isDisabled: false, - isRequired: true, - isUnique: false, + min: null, // Minimum length (for strings/arrays) + max: 100, // Maximum length + defaultValue: null, // Default value when creating new Entry + inputWidth: '12', // Grid column width (1-12) + isDisabled: false, // Whether field is read-only + isRequired: true, // Whether field must be filled + isUnique: false, // Whether value must be unique across Entries } ``` -## UI for defining field definitions +**Key Properties:** + +Depending on the field type, definitions may include different properties, but common ones are: + +- `id`: Unique identifier for this field +- `valueType`: The data type (`string`, `number`, `boolean`, etc.) - used for validation +- `fieldType`: The UI component type (`text`, `textarea`, `number`, `switch`, `select`, etc.) +- `label` / `description`: Translatable strings for multiple languages that help users understand the field's purpose +- `min` / `max`: Validation constraints +- `isRequired`: Whether the field must have a value +- `inputWidth`: Responsive grid width (using 12-column grid) + +## Creating Field Definitions via a Collection + +When users create or update a Collection, they use a visual editor to define the fields for that Collection type. + +**Location:** [`components/pages/create-update-collection-page.tsx`](../../src/renderer/components/pages/create-update-collection-page.tsx) + +**Features:** + +- Add, remove, and reorder fields via drag-and-drop +- Select field type (text, textarea, number, boolean, select, etc.) +- Set labels and descriptions with multi-language support +- Configure validation rules (min/max length, required, unique) +- Set default values and input width +- Preview how the form will look to content editors + +## Rendering Dynamic Forms + +When rendering a form to create or edit an Entry, we iterate over the Collection's field definitions and render appropriate form controls for each field. + +**Location:** See [`components/pages/create-update-entry-page.tsx`](../../src/renderer/components/pages/create-update-entry-page.tsx) for usage. + +### Component Architecture + +The dynamic form rendering system is built with layered components, each adding functionality: + +#### Base UI Components + +Located in [`components/ui/`](../../src/renderer/components/ui/): + +- **`Input`**, **`Textarea`**, **`Switch`**, **`Slider`**, **`Select`**: Basic HTML form controls with styling and [Radix UI primitives](https://www.radix-ui.com/primitives) for accessibility +- These are the fundamental building blocks used across the application + +#### Form Field Components + +Located in [`components/ui/form.tsx`](../../src/renderer/components/ui/form.tsx): + +**1. `FormComponentFromFieldDefinition`** (line ~290) + +- Takes a field definition and renders the appropriate input component +- Handles value transformation (e.g., converting string inputs to numbers) +- Returns `null` for empty values instead of empty strings (important for validation) +- Maps `fieldType` to the correct UI component + +**2. `FormComponentFromFieldDefinitionTranslatable`** (line ~420) + +- Extends `FormComponentFromFieldDefinition` +- Adds multi-language support for translatable fields +- Renders a dialog for entering translations in all supported languages +- Shows language switcher button next to field + +**3. `FormFieldFromDefinition`** (recommended entry point) + +- Use this component when rendering form fields from definitions +- Wraps `FormComponentFromFieldDefinitionTranslatable` with: + - Label (with required indicator) + - Description text + - Validation error messages + - Proper spacing and layout + +### Example Usage + +```typescript +import { FormFieldFromDefinition } from '@renderer/components/ui/form'; + +// In your component: +const collection = /* ... Collection with fieldDefinitions ... */; + +return ( +
+ + {collection.fieldDefinitions.map((fieldDef) => ( + + ))} + + +); +``` + +## Validation with Zod -When creating or updating a Collection, the user can add, remove and edit field definitions. You can find the UI for that under [`src/renderer/components/pages/create-update-collection-page.tsx`](/src/renderer/components/pages/create-update-collection-page.tsx). It allows the user to select the field type (e.g. text, number, boolean etc.), set the label and description (with support for multiple languages), define validation rules (e.g. min/max length, if it's required etc.). +All user input validation uses [Zod](https://zod.dev/), a TypeScript-first schema validation library. -## Rendering the dynamic form +### Schema Generation -When the form to **create a new Entry** for the Collection is rendered, we loop over the field definitions and render the corresponding input component that adheres to the defined type and limitations (e.g. min/max length, if it's required etc.). +**Source:** [@elek-io/core](https://github.com/elek-io/core) -This way we can render forms dynamically based on user defined field definitions. +Core provides utilities to convert field definitions into Zod schemas: -### UI Component overview +- **`getValueSchemaFromFieldDefinition()`**: Generates a Zod schema for a single field +- See [schema generation utilities](https://github.com/elek-io/core/blob/main/src/schema/schemaFromFieldDefinition.ts) for more functions -The following components are used: +**Example:** -- The [`Input`](/src/renderer/components/ui/input.tsx), [`Textarea`](/src/renderer/components/ui/textarea.tsx), [`Switch`](/src/renderer/components/ui/switch.tsx), [`Slider`](/src/renderer/components/ui/slider.tsx) and [`Select`](/src/renderer/components/ui/select.tsx) are basic HTML inputs with added styling and [Radix UI primitives](https://www.radix-ui.com/primitives) for accessibility. -- The components [`FormInputField`, `FormTextareaField`, `FormRangeField` and others](/src/renderer/components/ui/form.tsx) wrap the corresponding basic HTML component and transform the value the user put in (e.g. in case of an Input of type "number" to a number) before handing it back to the attached forms field. This is done because the inputs value internally is always a string. But this does become a problem when the form requires the type to be a number. It also returns null instead of an empty string if the user does not put in a value, since a form can allow for strings with a minimum lenght and null but an empty string should fail the validation. -- The [`FormComponentFromFieldDefinition`](/src/renderer/components/ui/form.tsx) simply takes a fieldDefinition and renders a component based on it. -- The [`FormComponentFromFieldDefinitionTranslatable`](/src/renderer/components/ui/form.tsx) extends the FormComponentFromFieldDefinition. If there are multiple supported languages, it renders a button next to the field that opens a dialog where translations for all supported languages can be entered. -- Finally, the [`FormFieldFromDefinition`](/src/renderer/components/ui/form.tsx) wraps the FormComponentFromFieldDefinitionTranslatable in a FormItem and adds a label, description and validation message. This is the component that should be used when rendering a form field based on a field definition. +```typescript +import { getValueSchemaFromFieldDefinition } from '@elek-io/core'; -## Generating validation schemas and types +const fieldDefinition = { + valueType: 'string', + min: 5, + max: 100, + isRequired: true, + // ... +}; + +// Generated schema validates: +// - Type is string +// - Length between 5-100 characters +// - Field is required (not null/undefined) +const schema = getValueSchemaFromFieldDefinition(fieldDefinition); +``` + +### Form Validation + +Use the generated Zod schema with [React Hook Form's Zod resolver](https://react-hook-form.com/get-started#SchemaValidation): + +```typescript +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { getUpdateEntrySchemaFromFieldDefinitions } from '@elek-io/core'; + +const collection = /* ... */; + +// Generate schema from all field definitions +const schema = getUpdateEntrySchemaFromFieldDefinitions( + collection.fieldDefinitions +); + +// Create form with validation +const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { /* ... */ } +}); + +// Form validates automatically and shows error messages via FormMessage component +``` -All validation of user input is done using [Zod](https://zod.dev/). elek.io Core provides [predefined Zod schemas for all supported field types](https://github.com/elek-io/core/blob/main/src/schema/fieldSchema.ts). +**Benefits:** -Via the `getValueSchemaFromFieldDefinition()` or any other [schema generating function inside `@elek-io/core`](https://github.com/elek-io/core/blob/main/src/schema/schemaFromFieldDefinition.ts) the field definitions are converted to Zod schemas for validation and infered TypeScript types. +- Type-safe form data (inferred from generated Zod schema) +- Automatic validation based on field definitions +- Consistent validation between frontend and backend +- User-friendly error messages +- Prevents invalid data from reaching Core -## Validating generated forms +--- -This schema can then be used in conjunction with the [React Hook Form Zod resolver](https://react-hook-form.com/get-started#SchemaValidation) to validate the user input before the form is submitted to `@elek-io/core` via IPC and provide feedback to the user if the input is invalid. +**Last Updated:** 2025-11-18 diff --git a/documentation/renderer/loading-and-updating-data.md b/documentation/renderer/loading-and-updating-data.md index d9a8849..16981f5 100644 --- a/documentation/renderer/loading-and-updating-data.md +++ b/documentation/renderer/loading-and-updating-data.md @@ -1,16 +1,128 @@ -# Loading and updating data +# Loading and Updating Data -elek.io Client uses [TanStack Query](https://tanstack.com/query/latest) to load (query) and update (mutate) data. +## Overview -Base configuration for all queries and mutations can be found inside the [`client.ts` file](../../src/renderer/queries/client.ts). Here, `staleTime` is set to `Infinity` which keeps all queries `fresh`, meaning they are not refetched if a query with the same `queryKey` is executed. Additionally all mutations trigger either a success or failure toast. +elek.io Client uses [TanStack Query](https://tanstack.com/query/latest) (formerly React Query) for all data fetching and mutations. This provides a consistent, type-safe way to interact with the main process via IPC, with built-in caching, loading states, and error handling. -All `queryOptions` and `mutationOptions` are configured in the [`options.ts` file](../../src/renderer/queries/options.ts). They often share the same cache and can automatically update each others cache when data changes to avoid having to manually invalidate their cache and force a refetch. +## Configuration -## Querying data +### Query Client Setup -Wherever possible we query data without blocking loading and rendering the page where the data is displayed. Changing pages should feel instantanious. If a query takes some time to resolve, we show a Skeleton component instead. See the [Project's `index.tsx` file](../../src/renderer/routes/projects/index.tsx) for an example. +The base configuration for all queries and mutations is in [`client.ts`](../../src/renderer/queries/client.ts): -## Mutating data +**Key Settings:** -When mutating data we often render a form with it's values being the current data of a query. But when the page loads, the query is executed and might not be resolved yet for the first page render. -To avoid a loading spinner or creating and showing a Skeleton for each and every form, we disable the form with an `fieldset` HTML element as long as the query is not finished. See the [Users `profile.tsx` file](../../src/renderer/routes/user/profile.tsx) for an example. +- `staleTime: Infinity` (line 25) - Queries remain "fresh" indefinitely and won't automatically refetch. This is appropriate for local data that only changes through user actions. +- Automatic toast notifications for all mutations (success and failure) +- Error and success logging to Core logger + +### Query and Mutation Options + +All `queryOptions` and `mutationOptions` are centrally defined in [`options.ts`](../../src/renderer/queries/options.ts). This file contains: + +- Typed wrappers around IPC calls +- Query keys for cache management +- Automatic cache updates on mutations - Mutations automatically update related queries' cache data. For example, when you create a Project, both the individual Project cache AND the Projects list cache are updated without requiring a refetch. +- Metadata for toast notifications (method and objectType) + +## Querying Data + +### Non-Blocking Page Loads + +We prioritize fast, responsive UI by rendering pages immediately without waiting for data to load. Navigation between pages should feel instantaneous. + +**Pattern:** Show Skeleton components while data loads, then replace them with actual content when ready. + +**Example:** See [`routes/projects/index.tsx:115-120`](../../src/renderer/routes/projects/index.tsx) + +```typescript +const { data: projects, isPending } = useQuery( + queryOptions.projects.list({ limit: 0 }) +); + +return ( +
+ {isPending + ? [1, 2, 3, 4, 5].map((i) => ) + : projects.list.map((project) => ( + + ))} +
+); +``` + +This ensures the page structure renders immediately, with placeholders that are replaced by real data as it loads. + +## Mutating Data + +### Form Mutations with Existing Data + +When building forms to update existing data, we need to handle the loading state gracefully. Forms need to be populated with current values, but those values come from a query that may not be resolved yet. + +**Pattern:** Use `
` to disable the entire form while data loads, instead of showing loading spinners or skeleton forms. + +**Example:** See [`routes/user/profile.tsx:178`](../../src/renderer/routes/user/profile.tsx) + +```typescript +const { data: user, isPending: isUserPending } = useQuery( + queryOptions.user.get() +); + +const setUserForm = useForm({ + defaultValues: { /* ... */ } +}); + +// Update form when data loads +useEffect(() => { + if (user) { + setUserForm.reset({ + name: user.name, + email: user.email, + // ... other fields + }); + } +}, [user, setUserForm]); + +return ( +
+ +
+ {/* Form fields here - automatically disabled while loading */} + + +
+
+ +); +``` + +**Benefits:** + +- No need for skeleton components in forms +- Form structure renders immediately +- Native browser styling for disabled state +- Prevents user interaction during loading +- Cleaner code with less conditional rendering + +### Mutation Metadata + +All mutations should include metadata for proper toast notifications and logging: + +```typescript +mutationOptions({ + mutationFn: async () => { + /* ... */ + }, + meta: { + method: 'create', + objectType: 'project', + }, + // ... +}); +``` + +This metadata is used by the query client to automatically display success/error toasts and log all mutations for debugging. + +--- + +**Last Updated:** 2025-11-18 From 2a903e24c356c7fb0c70fd17f841ba941e6dbd03 Mon Sep 17 00:00:00 2001 From: Nils Kolvenbach Date: Wed, 19 Nov 2025 00:20:57 +0100 Subject: [PATCH 10/36] Implemented content translation context and provider. Refactored project sidebar and dashboard components to utilize new hooks and skeleton loading states. --- src/renderer/components/commit-history.tsx | 21 +- src/renderer/components/commit.tsx | 40 ++- src/renderer/components/project-sidebar.tsx | 109 +++++--- src/renderer/components/project-switcher.tsx | 97 +++++-- src/renderer/hooks/useContentTranslation.ts | 26 ++ .../providers/ContentTranslationProvider.tsx | 60 ++++ src/renderer/queries/options.ts | 238 +++++++++++++++- src/renderer/routes/projects/$projectId.tsx | 264 ++---------------- .../routes/projects/$projectId/dashboard.tsx | 68 +++-- 9 files changed, 566 insertions(+), 357 deletions(-) create mode 100644 src/renderer/hooks/useContentTranslation.ts create mode 100644 src/renderer/providers/ContentTranslationProvider.tsx diff --git a/src/renderer/components/commit-history.tsx b/src/renderer/components/commit-history.tsx index 751f244..75363ab 100644 --- a/src/renderer/components/commit-history.tsx +++ b/src/renderer/components/commit-history.tsx @@ -1,8 +1,8 @@ 'use client'; -import { type HTMLAttributes, type ReactElement } from 'react'; +import { type HTMLAttributes } from 'react'; -import { Commit } from '@renderer/components/commit'; +import { Commit, CommitSkeleton } from '@renderer/components/commit'; import { cn } from '@renderer/lib/utils'; import { type GitCommit, type SupportedLanguage } from '@elek-io/core'; @@ -21,10 +21,10 @@ export function CommitHistory({ projectId, disabled, ...props -}: CommitHistoryProps): ReactElement { +}: CommitHistoryProps): React.JSX.Element { return (
-
+
{commits.map((commit) => ( ); } + +export function CommitHistorySkeleton(): React.JSX.Element { + return ( +
+
+
+ {[1, 2, 3].map((index) => ( + + ))} +
+
+ ); +} diff --git a/src/renderer/components/commit.tsx b/src/renderer/components/commit.tsx index 035180a..7c747ce 100644 --- a/src/renderer/components/commit.tsx +++ b/src/renderer/components/commit.tsx @@ -1,5 +1,6 @@ 'use client'; +import { QuestionMarkIcon } from '@radix-ui/react-icons'; import { Link, type LinkProps } from '@tanstack/react-router'; import { CircleFadingArrowUp, @@ -10,12 +11,14 @@ import { Tag, Trash2, } from 'lucide-react'; -import type { ReactElement } from 'react'; import { cn, formatDatetime } from '@renderer/lib/utils'; +import { sidebarMenuButtonVariants } from '@renderer/lib/variants'; import { type GitCommit, type SupportedLanguage } from '@elek-io/core'; +import { Skeleton } from './ui/skeleton'; + export interface CommitProps extends LinkProps { language: SupportedLanguage; commit: GitCommit; @@ -24,10 +27,9 @@ export interface CommitProps extends LinkProps { export function Commit({ commit, language, - activeProps, ...props -}: CommitProps): ReactElement { - let iconComponent: ReactElement; +}: CommitProps): React.JSX.Element { + let iconComponent: React.JSX.Element; switch (commit.message.method) { case 'create': @@ -54,26 +56,22 @@ export function Commit({ return ( -
+
{commit.tag ? ( ) : null} {iconComponent}
-
+
{commit.message.method} {commit.message.reference.objectType}
-
+
{commit.author.name} -{' '} {formatDatetime(commit.datetime, language).relative}
@@ -81,3 +79,17 @@ export function Commit({ ); } + +export function CommitSkeleton(): React.JSX.Element { + return ( +
+
+ +
+
+ + +
+
+ ); +} diff --git a/src/renderer/components/project-sidebar.tsx b/src/renderer/components/project-sidebar.tsx index fb77a4b..d0ed6bd 100644 --- a/src/renderer/components/project-sidebar.tsx +++ b/src/renderer/components/project-sidebar.tsx @@ -1,4 +1,4 @@ -import type { UseQueryResult } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { Link, type ToPathOption } from '@tanstack/react-router'; import { Layers, @@ -14,7 +14,10 @@ import { } from 'lucide-react'; import React from 'react'; -import { ProjectSwitcher } from '@renderer/components/project-switcher'; +import { + ProjectSwitcher, + ProjectSwitcherSkeleton, +} from '@renderer/components/project-switcher'; import { Button } from '@renderer/components/ui/button'; import { ButtonGroup, @@ -32,12 +35,13 @@ import { SidebarMenuButton, SidebarMenuItem, } from '@renderer/components/ui/sidebar'; +import { queryOptions } from '@renderer/queries'; -import type { GitCommit, Project } from '@elek-io/core'; +import type { Project } from '@elek-io/core'; const projectNavigation: { name: string; - to: ToPathOption; + to: ToPathOption; // @todo fix type icon: LucideIcon; }[] = [ { @@ -67,20 +71,41 @@ const projectNavigation: { }, ]; +function ProjectNavigation(): React.JSX.Element { + return ( + + Navigation + + + {projectNavigation.map((item) => ( + + + + + {item.name} + + + + ))} + + + + ); +} + export function ProjectSidebar({ project, - projectChangesQuery, - isSynchronizing, - onSynchronize, }: { project: Project; - projectChangesQuery: UseQueryResult<{ - ahead: GitCommit[]; - behind: GitCommit[]; - }>; - isSynchronizing: boolean; - onSynchronize: () => Promise; }): React.JSX.Element { + const { + data: projectChanges, + isFetching: isFetchingProjectChanges, + refetch: refetchProjectChanges, + } = useQuery(queryOptions.projects.getChanges(project)); + const { mutateAsync: synchronizeProject, isPending: isSynchronizingProject } = + useMutation(queryOptions.projects.synchronize); + return ( @@ -92,14 +117,16 @@ export function ProjectSidebar({ - -
- -

- {projectChangesQuery.isFetching ? ( - 'Loading' - ) : ( - - - {projectChangesQuery.data?.behind.length} - - {projectChangesQuery.data?.ahead.length} - - )} -

- - {projectChangesQuery.data - ? projectChangesQuery.data.ahead.map((commit) => ( - - )) - : null} -
- - ) : null} - + +
+ {isProjectPending ? ( + + ) : ( + )} - - - {isProjectSidebarNarrow ? ( - router.navigate({ to: '/projects' })} - > - - ) : null} - - {projectNavigation.map((navigation) => { - const item = ( - - - ); - - if (isProjectSidebarNarrow) { - return ( - - - {item} - -

{navigation.name}

-
-
-
- ); - } - - return item; - })} -
-
- */} -
+
+ +
+
+ ); } diff --git a/src/renderer/routes/projects/$projectId/dashboard.tsx b/src/renderer/routes/projects/$projectId/dashboard.tsx index 8952314..ba85ac2 100644 --- a/src/renderer/routes/projects/$projectId/dashboard.tsx +++ b/src/renderer/routes/projects/$projectId/dashboard.tsx @@ -1,36 +1,56 @@ +import { useQuery } from '@tanstack/react-query'; import { createFileRoute, useRouter } from '@tanstack/react-router'; -import { type ReactElement } from 'react'; -import { CommitHistory } from '@renderer/components/commit-history'; +import { + CommitHistory, + CommitHistorySkeleton, +} from '@renderer/components/commit-history'; import { Page } from '@renderer/components/page'; import { PageSection } from '@renderer/components/page-section'; import { Button } from '@renderer/components/ui/button'; +import { queryOptions } from '@renderer/queries'; export const Route = createFileRoute('/projects/$projectId/dashboard')({ component: ProjectDashboardPage, }); -function ProjectDashboardPage(): ReactElement { +function ProjectDashboardPage(): React.JSX.Element { + const { projectId } = Route.useParams(); const router = useRouter(); - const context = Route.useRouteContext(); + const { + data: project, + isPending: isProjectPending, + isError: isProjectError, + error: projectError, + } = useQuery(queryOptions.projects.read({ id: projectId })); - function Description(): ReactElement { + if (isProjectError) { + throw projectError; + } + + function Description(): React.JSX.Element { return <>The Dashboard gives you an overview of your project.; } - function LatestChangesActions(): ReactElement { + function LatestChangesActions(): React.JSX.Element { + if (isProjectError) { + throw projectError; + } + return ( <> - + {isProjectPending ? null : ( + + )} ); } @@ -45,7 +65,7 @@ function ProjectDashboardPage(): ReactElement { standalone >
-            {JSON.stringify(context.project, null, 2)}
+            {JSON.stringify(project, null, 2)}
           
- + {isProjectPending ? ( + + ) : ( + + )}
From 3b6304264b0b3589450e879a4584337305e7be5b Mon Sep 17 00:00:00 2001 From: Nils Kolvenbach Date: Wed, 19 Nov 2025 11:00:16 +0100 Subject: [PATCH 11/36] Fixed active ring color for sidebar. Improved app and user header --- src/renderer/components/app-header.tsx | 164 +++++++++++------------- src/renderer/components/user-header.tsx | 87 ++++--------- src/renderer/index.css | 4 +- src/renderer/routes/__root.tsx | 6 +- 4 files changed, 109 insertions(+), 152 deletions(-) diff --git a/src/renderer/components/app-header.tsx b/src/renderer/components/app-header.tsx index d4c75c1..72ae074 100644 --- a/src/renderer/components/app-header.tsx +++ b/src/renderer/components/app-header.tsx @@ -1,6 +1,6 @@ import { version as clientVersion, dependencies } from '@root/package.json'; import { ChevronDown, ExternalLink } from 'lucide-react'; -import { forwardRef, type HTMLAttributes, useState } from 'react'; +import { useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { @@ -13,92 +13,80 @@ import { DropdownMenuTrigger, } from '@renderer/components/ui/dropdown-menu'; import { cn } from '@renderer/lib/utils'; -import { type RouterContext } from '@renderer/routes/__root'; -export interface AppHeaderProps extends HTMLAttributes { - electron: RouterContext['electron']; -} - -const AppHeader = forwardRef( - ({ electron }, ref) => { - const [isElekInfoOpen, setIsElekInfoOpen] = useState(false); +export function AppHeader(): React.JSX.Element { + const [isElekInfoOpen, setIsElekInfoOpen] = useState(false); - return ( -
-
- - - - - - - - window.open( - 'https://github.com/elek-io/client/issues', - '_blank' - ) - } - > - Report an issue - - - - - - - - - elek.io Client - v{clientVersion} - - - elek.io Core - - v{dependencies['@elek-io/core']} - - - - - - - Electron - - v{electron.process.versions['electron']} - - - - Chromium - - v{electron.process.versions['chrome']} - - - - Node - - v{electron.process.versions['node']} - - - - - -
-
- ); - } -); -AppHeader.displayName = 'AppHeader'; - -export { AppHeader }; + return ( +
+ + + + + + + + window.open( + 'https://github.com/elek-io/client/issues', + '_blank' + ) + } + > + Report an issue + + + + + + + + + elek.io Client + v{clientVersion} + + + elek.io Core + + v{dependencies['@elek-io/core']} + + + + + + + Electron + + v{window.ipc.electron.process.versions['electron']} + + + + Chromium + + v{window.ipc.electron.process.versions['chrome']} + + + + Node + + v{window.ipc.electron.process.versions['node']} + + + + + +
+ ); +} diff --git a/src/renderer/components/user-header.tsx b/src/renderer/components/user-header.tsx index 3cebed2..8ac93ba 100644 --- a/src/renderer/components/user-header.tsx +++ b/src/renderer/components/user-header.tsx @@ -14,6 +14,7 @@ import { Button } from '@renderer/components/ui/button'; import { queryOptions } from '@renderer/queries'; import { useStore } from '@renderer/store'; +import { ButtonGroup } from './ui/button-group'; import { UserDropdown, UserDropdownSkeleton } from './user-dropdown'; export function UserHeader(): React.JSX.Element { @@ -25,13 +26,6 @@ export function UserHeader(): React.JSX.Element { isError: isUserError, error: userError, } = useQuery(queryOptions.user.get()); - // @todo causes "Maximum update depth exceeded" error - // const [isProjectSidebarNarrow, setIsProjectSidebarNarrow] = useStore( - // (storeState) => [ - // storeState.isProjectSidebarNarrow, - // storeState.setIsProjectSidebarNarrow, - // ] - // ); const breadcrumbLookupMap = useStore( (storeState) => storeState.breadcrumbLookupMap ); @@ -92,74 +86,49 @@ export function UserHeader(): React.JSX.Element { } return ( -
-