mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-03-10 02:26:21 -04:00
onboarding UI flow
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { TauriTransport, createClient } from '@rspc/client';
|
||||
import { createClient } from '@rspc/client';
|
||||
import { TauriTransport } from '@rspc/tauri';
|
||||
import { OperatingSystem, Operations, PlatformProvider, queryClient, rspc } from '@sd/client';
|
||||
import SpacedriveInterface, { Platform } from '@sd/interface';
|
||||
import { dialog, invoke, os } from '@tauri-apps/api';
|
||||
|
||||
@@ -26,7 +26,7 @@ export type Operations = {
|
||||
{ key: ["files.setNote", LibraryArgs<SetNoteArgs>], result: null } |
|
||||
{ key: ["jobs.generateThumbsForLocation", LibraryArgs<GenerateThumbsForLocationArgs>], result: null } |
|
||||
{ key: ["jobs.identifyUniqueFiles", LibraryArgs<IdentifyUniqueFilesArgs>], result: null } |
|
||||
{ key: ["library.create", string], result: null } |
|
||||
{ key: ["library.create", string], result: LibraryConfigWrapped } |
|
||||
{ key: ["library.delete", string], result: null } |
|
||||
{ key: ["library.edit", EditLibraryArgs], result: null } |
|
||||
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: null } |
|
||||
|
||||
@@ -26,7 +26,7 @@ export type Operations = {
|
||||
{ key: ["files.setNote", LibraryArgs<SetNoteArgs>], result: null } |
|
||||
{ key: ["jobs.generateThumbsForLocation", LibraryArgs<GenerateThumbsForLocationArgs>], result: null } |
|
||||
{ key: ["jobs.identifyUniqueFiles", LibraryArgs<IdentifyUniqueFilesArgs>], result: null } |
|
||||
{ key: ["library.create", string], result: null } |
|
||||
{ key: ["library.create", string], result: LibraryConfigWrapped } |
|
||||
{ key: ["library.delete", string], result: null } |
|
||||
{ key: ["library.edit", EditLibraryArgs], result: null } |
|
||||
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: null } |
|
||||
|
||||
@@ -111,20 +111,14 @@ impl LibraryManager {
|
||||
node_context,
|
||||
});
|
||||
|
||||
// TODO: Remove this before merging PR -> Currently it exists to make the app usable
|
||||
if this.libraries.read().await.len() == 0 {
|
||||
this.create(LibraryConfig {
|
||||
name: "My Default Library".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
/// create creates a new library with the given config and mounts it into the running [LibraryManager].
|
||||
pub(crate) async fn create(&self, config: LibraryConfig) -> Result<(), LibraryManagerError> {
|
||||
pub(crate) async fn create(
|
||||
&self,
|
||||
config: LibraryConfig,
|
||||
) -> Result<LibraryConfigWrapped, LibraryManagerError> {
|
||||
let id = Uuid::new_v4();
|
||||
LibraryConfig::save(
|
||||
Path::new(&self.libraries_dir).join(format!("{id}.sdlibrary")),
|
||||
@@ -135,7 +129,7 @@ impl LibraryManager {
|
||||
let library = Self::load(
|
||||
id,
|
||||
self.libraries_dir.join(format!("{id}.db")),
|
||||
config,
|
||||
config.clone(),
|
||||
self.node_context.clone(),
|
||||
)
|
||||
.await?;
|
||||
@@ -143,7 +137,7 @@ impl LibraryManager {
|
||||
invalidate_query!(library, "library.list");
|
||||
|
||||
self.libraries.write().await.push(library);
|
||||
Ok(())
|
||||
Ok(LibraryConfigWrapped { uuid: id, config })
|
||||
}
|
||||
|
||||
pub(crate) async fn get_all_libraries_config(&self) -> Vec<LibraryConfigWrapped> {
|
||||
|
||||
@@ -20,7 +20,7 @@ static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/prisma/migrations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MigrationError {
|
||||
#[error("An error occurred while initialising a new database connection: {0}")]
|
||||
NewClient(#[from] NewClientError),
|
||||
NewClient(#[from] Box<NewClientError>),
|
||||
#[error("The temporary file path for the database migrations is invalid.")]
|
||||
InvalidDirectory,
|
||||
#[error("An error occurred creating the temporary directory for the migrations: {0}")]
|
||||
@@ -40,7 +40,9 @@ pub async fn load_and_migrate(
|
||||
base_path: &Path,
|
||||
db_url: &str,
|
||||
) -> Result<PrismaClient, MigrationError> {
|
||||
let client = prisma::new_client_with_url(db_url).await?;
|
||||
let client = prisma::new_client_with_url(db_url)
|
||||
.await
|
||||
.map_err(|err| Box::new(err))?;
|
||||
let temp_migrations_dir = base_path.join("./migrations_temp");
|
||||
let migrations_directory_path = temp_migrations_dir
|
||||
.to_str()
|
||||
@@ -60,7 +62,7 @@ pub async fn load_and_migrate(
|
||||
.extract(&temp_migrations_dir)
|
||||
.map_err(MigrationError::ExtractMigrations)?;
|
||||
|
||||
let mut connector = match &ConnectionInfo::from_url(&db_url)? {
|
||||
let mut connector = match &ConnectionInfo::from_url(db_url)? {
|
||||
ConnectionInfo::Sqlite { .. } => SqlMigrationConnector::new_sqlite(),
|
||||
ConnectionInfo::InMemorySqlite { .. } => unreachable!(), // This is how it is in the Prisma Rust tests
|
||||
};
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
PropsWithChildren,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
import { proxy, useSnapshot } from 'valtio';
|
||||
|
||||
import { useBridgeQuery } from '../index';
|
||||
@@ -7,13 +15,36 @@ import { explorerStore } from '../stores/explorerStore';
|
||||
// The name of the localStorage key for caching library data
|
||||
const libraryCacheLocalStorageKey = 'sd-library-list';
|
||||
|
||||
type OnNoLibraryFunc = () => void | Promise<void>;
|
||||
|
||||
// Keep this private and use `useCurrentLibrary` hook to access or mutate it
|
||||
const currentLibraryUuidStore = proxy({ id: null as string | null });
|
||||
const CringeContext = createContext<{
|
||||
onNoLibrary: OnNoLibraryFunc;
|
||||
currentLibraryId: string | null;
|
||||
setCurrentLibraryId: (v: string | null) => void;
|
||||
}>(undefined!);
|
||||
|
||||
export const LibraryContextProvider = ({
|
||||
onNoLibrary,
|
||||
children
|
||||
}: PropsWithChildren<{ onNoLibrary: OnNoLibraryFunc }>) => {
|
||||
const [currentLibraryId, setCurrentLibraryId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<CringeContext.Provider value={{ onNoLibrary, currentLibraryId, setCurrentLibraryId }}>
|
||||
{children}
|
||||
</CringeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// this is a hook to get the current library loaded into the UI. It takes care of a bunch of invariants under the hood.
|
||||
export const useCurrentLibrary = () => {
|
||||
const currentLibraryUuid = useSnapshot(currentLibraryUuidStore).id;
|
||||
const { data: libraries, isLoading } = useBridgeQuery(['library.get'], {
|
||||
const ctx = useContext(CringeContext);
|
||||
if (ctx === undefined)
|
||||
throw new Error(
|
||||
"The 'LibraryContextProvider' was not mounted and you attempted do use the 'useCurrentLibrary' hook. Please add the provider in your component tree."
|
||||
);
|
||||
const { data: libraries, isLoading } = useBridgeQuery(['library.list'], {
|
||||
keepPreviousData: true,
|
||||
initialData: () => {
|
||||
const cachedData = localStorage.getItem(libraryCacheLocalStorageKey);
|
||||
@@ -29,25 +60,29 @@ export const useCurrentLibrary = () => {
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data));
|
||||
|
||||
// Redirect to the onboaording flow if the user doesn't have any libraries
|
||||
if (libraries?.length === 0) {
|
||||
ctx.onNoLibrary();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const switchLibrary = useCallback((libraryUuid: string) => {
|
||||
currentLibraryUuidStore.id = libraryUuid;
|
||||
ctx.setCurrentLibraryId(libraryUuid);
|
||||
explorerStore.reset();
|
||||
}, []);
|
||||
|
||||
// memorize library to avoid re-running find function
|
||||
const library = useMemo(() => {
|
||||
const current = libraries?.find((l: any) => l.uuid === currentLibraryUuid);
|
||||
const current = libraries?.find((l: any) => l.uuid === ctx.currentLibraryId);
|
||||
// switch to first library if none set
|
||||
if (libraries && !current && libraries[0]?.uuid) {
|
||||
switchLibrary(libraries[0]?.uuid);
|
||||
}
|
||||
return current;
|
||||
}, [libraries, currentLibraryUuid]); // TODO: This runs when the 'libraries' change causing the whole app to re-render which is cringe.
|
||||
|
||||
// TODO: Redirect to onboarding flow if the user hasn't completed it. -> localStorage?
|
||||
return current;
|
||||
}, [libraries, ctx.currentLibraryId]); // TODO: This runs when the 'libraries' change causing the whole app to re-render which is cringe.
|
||||
|
||||
return {
|
||||
library,
|
||||
@@ -1,9 +1,9 @@
|
||||
import '@fontsource/inter/variable.css';
|
||||
import { queryClient } from '@sd/client';
|
||||
import { LibraryContextProvider, queryClient } from '@sd/client';
|
||||
import { QueryClientProvider, defaultContext } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AppRouter } from './AppRouter';
|
||||
import { ErrorFallback } from './ErrorFallback';
|
||||
@@ -18,9 +18,20 @@ export default function SpacedriveInterface() {
|
||||
<ReactQueryDevtools position="bottom-right" context={defaultContext} />
|
||||
)}
|
||||
<MemoryRouter>
|
||||
<AppRouter />
|
||||
<AppRouterWrapper />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// This can't go in `<SpacedriveInterface />` cause it needs the router context but it can't go in `<AppRouter />` because that requires this context
|
||||
function AppRouterWrapper() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<LibraryContextProvider onNoLibrary={() => navigate('/onboarding')}>
|
||||
<AppRouter />
|
||||
</LibraryContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCurrentLibrary } from '@sd/client';
|
||||
import clsx from 'clsx';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
@@ -5,8 +6,14 @@ import { Sidebar } from './components/layout/Sidebar';
|
||||
import { useOperatingSystem } from './hooks/useOperatingSystem';
|
||||
|
||||
export function AppLayout() {
|
||||
const { libraries } = useCurrentLibrary();
|
||||
const os = useOperatingSystem();
|
||||
|
||||
// This will ensure nothing is rendered while the `useCurrentLibrary` hook navigates to the onboarding page. This prevents requests with an invalid library id being sent to the backend
|
||||
if (libraries?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onContextMenu={(e) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppLayout } from './AppLayout';
|
||||
import { NotFound } from './NotFound';
|
||||
import OnboardingScreen from './components/onboarding/Onboarding';
|
||||
import { useKeyboardHandler } from './hooks/useKeyboardHandler';
|
||||
import { ContentScreen } from './screens/Content';
|
||||
import { DebugScreen } from './screens/Debug';
|
||||
@@ -41,7 +42,7 @@ export function AppRouter() {
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="onboarding" element={<p>TODO</p>} />
|
||||
<Route path="onboarding" element={<OnboardingScreen />} />
|
||||
<Route element={<AppLayout />}>
|
||||
{/* As we are caching the libraries in localStore so this *shouldn't* result is visual problems unless something else is wrong */}
|
||||
{library === undefined ? (
|
||||
|
||||
@@ -5,7 +5,10 @@ import { PropsWithChildren, useState } from 'react';
|
||||
|
||||
import Dialog from '../layout/Dialog';
|
||||
|
||||
export default function CreateLibraryDialog({ children }: PropsWithChildren) {
|
||||
export default function CreateLibraryDialog({
|
||||
children,
|
||||
onSubmit
|
||||
}: PropsWithChildren<{ onSubmit?: () => void }>) {
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [newLibName, setNewLibName] = useState('');
|
||||
|
||||
@@ -13,9 +16,15 @@ export default function CreateLibraryDialog({ children }: PropsWithChildren) {
|
||||
const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation(
|
||||
'library.create',
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['library.get']); // TODO: Change to `library.list`
|
||||
onSuccess: (library) => {
|
||||
console.log('SUBMITTING');
|
||||
setOpenCreateModal(false);
|
||||
queryClient.setQueryData(['library.list'], (libraries: any) => [
|
||||
...(libraries || []),
|
||||
library
|
||||
]);
|
||||
|
||||
if (onSubmit) onSubmit();
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
const AssignTagMenuItems = (props: { objectId: number }) => {
|
||||
const tags = useLibraryQuery(['tags.getAll'], { suspense: true });
|
||||
const tags = useLibraryQuery(['tags.list'], { suspense: true });
|
||||
const tagsForFile = useLibraryQuery(['tags.getForFile', props.objectId], { suspense: true });
|
||||
|
||||
const { mutate: assignTag } = useLibraryMutation('tags.assign');
|
||||
|
||||
@@ -49,7 +49,6 @@ export default function Dialog(props: DialogProps) {
|
||||
</DialogPrimitive.Close>
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={props.ctaAction}
|
||||
size="sm"
|
||||
loading={props.loading}
|
||||
disabled={props.loading || props.submitDisabled}
|
||||
|
||||
@@ -64,7 +64,7 @@ function LibraryScopedSection() {
|
||||
const os = useOperatingSystem();
|
||||
const platform = usePlatform();
|
||||
const { data: locations } = useLibraryQuery(['locations.list'], { keepPreviousData: true });
|
||||
const { data: tags } = useLibraryQuery(['tags.getAll'], { keepPreviousData: true });
|
||||
const { data: tags } = useLibraryQuery(['tags.list'], { keepPreviousData: true });
|
||||
const { mutate: createLocation } = useLibraryMutation('locations.create');
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export function OnboardingPage() {
|
||||
import { Button } from '../../../../ui/src';
|
||||
import CreateLibraryDialog from '../dialog/CreateLibraryDialog';
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div>
|
||||
<h1>TODO: Onboarding Flow</h1>
|
||||
<div className="p-10 flex flex-col justify-center">
|
||||
<h1 className="text-red-500">Welcome to Spacedrive</h1>
|
||||
|
||||
{/* TODO: Library create button */}
|
||||
|
||||
{/* <Button variant="primary" className="mt-4" onClick={() => navigate(-1)}>
|
||||
Go Back
|
||||
</Button> */}
|
||||
<CreateLibraryDialog onSubmit={() => navigate('overview')}>
|
||||
<Button variant="primary" size="sm">
|
||||
Create your library
|
||||
</Button>
|
||||
</CreateLibraryDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
pnpm-lock.yaml
generated
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user