mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 12:13:27 +00:00
Merge remote-tracking branch 'origin/main' into use-rust-shortcuts
This commit is contained in:
parent
0768005106
commit
4673867ad1
19
.github/workflows/diagram.yml.disabled
vendored
19
.github/workflows/diagram.yml.disabled
vendored
|
@ -1,19 +0,0 @@
|
|||
name: Create diagram
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
get_data:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Update diagram
|
||||
uses: githubocto/repo-visualizer@main
|
||||
with:
|
||||
excluded_paths: '.github'
|
28
.github/workflows/org-readme.yml
vendored
28
.github/workflows/org-readme.yml
vendored
|
@ -1,28 +0,0 @@
|
|||
name: Update Org README
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- README.md
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-readme:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Update README
|
||||
uses: dmnemec/copy_file_to_another_repo_action@main
|
||||
env:
|
||||
API_TOKEN_GITHUB: ${{ secrets.SD_BOT_PAT }}
|
||||
with:
|
||||
source_file: 'README.md'
|
||||
destination_repo: 'spacedriveapp/.github'
|
||||
destination_folder: 'profile'
|
||||
user_email: 'actions@spacedrive.com'
|
||||
user_name: 'GH Actions'
|
||||
commit_message: 'Update README'
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
@ -23,11 +23,13 @@
|
|||
"subpackage",
|
||||
"svgr",
|
||||
"tailwindcss",
|
||||
"tanstack",
|
||||
"titlebar",
|
||||
"trivago",
|
||||
"tsparticles",
|
||||
"unlisten",
|
||||
"upsert"
|
||||
"upsert",
|
||||
"valtio"
|
||||
],
|
||||
"[rust]": {
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||
|
|
|
@ -25,10 +25,8 @@ swift-rs = { git = "https://github.com/Brendonovich/swift-rs.git", branch = "aut
|
|||
tauri-build = { version = "1.0.0", features = [] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.build-dependencies]
|
||||
swift-rs = { git = "https://github.com/Brendonovich/swift-rs.git", branch = "autorelease", features = [
|
||||
"build",
|
||||
] }
|
||||
swift-rs = { git = "https://github.com/Brendonovich/swift-rs.git", branch = "autorelease", features = ["build"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
default = [ "custom-protocol" ]
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
||||
|
|
|
@ -6,7 +6,12 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use sdcore::Node;
|
||||
use tauri::{api::path, async_runtime::block_on, Manager, RunEvent};
|
||||
use tauri::{
|
||||
api::path,
|
||||
async_runtime::block_on,
|
||||
http::{ResponseBuilder, Uri},
|
||||
Manager, RunEvent,
|
||||
};
|
||||
use tracing::{debug, error};
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
@ -32,6 +37,20 @@ async fn main() {
|
|||
let node = node.clone();
|
||||
move || node.get_request_context()
|
||||
}))
|
||||
.register_uri_scheme_protocol("spacedrive", {
|
||||
let node = node.clone();
|
||||
move |_, req| {
|
||||
let url = req.uri().parse::<Uri>().unwrap();
|
||||
let mut path = url.path().split('/').collect::<Vec<_>>();
|
||||
path[0] = url.host().unwrap(); // The first forward slash causes an empty item and we replace it with the URL's host which you expect to be at the start
|
||||
|
||||
let (status_code, content_type, body) = node.handle_custom_uri(path);
|
||||
ResponseBuilder::new()
|
||||
.status(status_code)
|
||||
.mimetype(content_type)
|
||||
.body(body)
|
||||
}
|
||||
})
|
||||
.setup(|app| {
|
||||
let app = app.handle();
|
||||
|
||||
|
|
|
@ -79,7 +79,7 @@
|
|||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src asset: https://asset.localhost blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
|
||||
"csp": "default-src spacedrive: asset: https://asset.localhost blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,7 +78,7 @@
|
|||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src asset: blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
|
||||
"csp": "default-src spacedrive: asset: https://asset.localhost blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
</head>
|
||||
<body style="overflow: hidden">
|
||||
<div id="root"></div>
|
||||
<script src="http://localhost:8097"></script>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
// import Spacedrive JS client
|
||||
import { createClient } from '@rspc/client';
|
||||
import { TauriTransport } from '@rspc/tauri';
|
||||
import { Operations, queryClient, rspc } from '@sd/client';
|
||||
import { OperatingSystem, Operations, PlatformProvider, queryClient, rspc } from '@sd/client';
|
||||
import SpacedriveInterface, { Platform } from '@sd/interface';
|
||||
import { KeybindEvent } from '@sd/interface';
|
||||
import { dialog, invoke, os, shell } from '@tauri-apps/api';
|
||||
import { dialog, invoke, os } from '@tauri-apps/api';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { convertFileSrc } from '@tauri-apps/api/tauri';
|
||||
import { appWindow } from '@tauri-apps/api/window';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '@sd/ui/style';
|
||||
|
@ -17,60 +14,48 @@ const client = createClient<Operations>({
|
|||
transport: new TauriTransport()
|
||||
});
|
||||
|
||||
function App() {
|
||||
function getPlatform(platform: string): Platform {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return 'macOS';
|
||||
case 'win32':
|
||||
return 'windows';
|
||||
case 'linux':
|
||||
return 'linux';
|
||||
default:
|
||||
return 'browser';
|
||||
}
|
||||
async function getOs(): Promise<OperatingSystem> {
|
||||
switch (await os.type()) {
|
||||
case 'Linux':
|
||||
return 'linux';
|
||||
case 'Windows_NT':
|
||||
return 'windows';
|
||||
case 'Darwin':
|
||||
return 'macOS';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
const [platform, setPlatform] = useState<Platform>('unknown');
|
||||
const [focused, setFocused] = useState(true);
|
||||
const platform: Platform = {
|
||||
platform: 'tauri',
|
||||
getThumbnailUrlById: (casId) => `spacedrive://thumbnail/${encodeURIComponent(casId)}`,
|
||||
openLink: open,
|
||||
getOs,
|
||||
openFilePickerDialog: () => dialog.open({ directory: true })
|
||||
};
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
os.platform().then((platform) => setPlatform(getPlatform(platform)));
|
||||
// This tells Tauri to show the current window because it's finished loading
|
||||
invoke('app_ready');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const focusListener = listen('tauri://focus', () => setFocused(true));
|
||||
const blurListener = listen('tauri://blur', () => setFocused(false));
|
||||
const keybindListener = listen('keybind', (input) => {
|
||||
document.dispatchEvent(new KeybindEvent(input.payload as string));
|
||||
});
|
||||
|
||||
return () => {
|
||||
focusListener.then((unlisten) => unlisten());
|
||||
blurListener.then((unlisten) => unlisten());
|
||||
keybindListener.then((unlisten) => unlisten());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<rspc.Provider client={client} queryClient={queryClient}>
|
||||
<SpacedriveInterface
|
||||
platform={platform}
|
||||
convertFileSrc={function (url: string): string {
|
||||
return convertFileSrc(url);
|
||||
}}
|
||||
openDialog={function (options: {
|
||||
directory?: boolean | undefined;
|
||||
}): Promise<string | string[] | null> {
|
||||
return dialog.open(options);
|
||||
}}
|
||||
isFocused={focused}
|
||||
onClose={() => appWindow.close()}
|
||||
onFullscreen={() => appWindow.setFullscreen(true)}
|
||||
onMinimize={() => appWindow.minimize()}
|
||||
onOpen={(path: string) => shell.open(path)}
|
||||
/>
|
||||
<PlatformProvider platform={platform}>
|
||||
<SpacedriveInterface />
|
||||
</PlatformProvider>
|
||||
</rspc.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,9 +10,7 @@ export default defineConfig({
|
|||
port: 8001
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
jsxRuntime: 'classic'
|
||||
}),
|
||||
react(),
|
||||
svgr({
|
||||
svgrOptions: {
|
||||
icon: true
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getWindow } from '../utils';
|
||||
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { Tag } from '@tryghost/content-api';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
export interface BlogTagProps {
|
||||
tag: Tag;
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import Particles from 'react-tsparticles';
|
||||
import { loadFull } from 'tsparticles';
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ import {
|
|||
Twitter
|
||||
} from '@icons-pack/react-simple-icons';
|
||||
import AppLogo from '@sd/assets/images/logo.png';
|
||||
import React from 'react';
|
||||
|
||||
function FooterLink(props: { children: string | JSX.Element; link: string; blank?: boolean }) {
|
||||
return (
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import '../atom-one.css';
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import AppLogo from '@sd/assets/images/logo.png';
|
|||
import { Dropdown, DropdownItem } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import { List } from 'phosphor-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { positions } from '../pages/careers.page';
|
||||
import { getWindow } from '../utils';
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import React from 'react';
|
||||
|
||||
export interface NewBannerProps {
|
||||
headline: string;
|
||||
href: string;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Github, Twitch, Twitter } from '@icons-pack/react-simple-icons';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
export interface TeamMemberProps {
|
||||
// Name of team member
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { PostOrPage, PostsOrPages, Tag } from '@tryghost/content-api';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PostOrPage, Tag } from '@tryghost/content-api';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { BlogTag } from '../../components/BlogTag';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { PostOrPage, Tag } from '@tryghost/content-api';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import '../../atom-one.css';
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
StarIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Button } from '@sd/ui';
|
||||
import React from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
interface PositionPosting {
|
||||
|
@ -59,7 +59,7 @@ const perks = [
|
|||
},
|
||||
{
|
||||
title: 'Paid Time Off',
|
||||
desc: `Rest is important, you deliver your best work when you've had your downtime. We offer 2 weeks paid time off per year, and if you need more, we'll give you more.`,
|
||||
desc: `Rest is important, you deliver your best work when you've had your downtime. We offer 4 weeks paid time off per year, and if you need more, we'll give you more.`,
|
||||
icon: FaceSmileIcon,
|
||||
color: '#9210FF'
|
||||
},
|
||||
|
@ -84,7 +84,7 @@ const perks = [
|
|||
];
|
||||
|
||||
function Page() {
|
||||
const openPositionsRef = React.useRef<HTMLHRElement>(null);
|
||||
const openPositionsRef = useRef<HTMLHRElement>(null);
|
||||
const scrollToPositions = () => openPositionsRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { ReactComponent as Content } from '~/docs/changelog/index.md';
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { ReactComponent as Content } from '~/docs/architecture/distributed-data-sync.md';
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { ReactComponent as Content } from '~/docs/product/faq.md';
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { Suspense, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { ReactComponent as Info } from '@sd/interface/assets/svg/info.svg';
|
||||
|
@ -100,11 +100,11 @@ function Page() {
|
|||
</Helmet>
|
||||
<div className="mt-22 lg:mt-28" id="content" aria-hidden="true" />
|
||||
<div className="mt-24 lg:mt-5" />
|
||||
<NewBanner
|
||||
headline="Spacedrive raises $2M led by OSS Capital"
|
||||
href="/blog/spacedrive-funding-announcement"
|
||||
link="Read post"
|
||||
/>
|
||||
<NewBanner
|
||||
headline="Spacedrive raises $2M led by OSS Capital"
|
||||
href="/blog/spacedrive-funding-announcement"
|
||||
link="Read post"
|
||||
/>
|
||||
{unsubscribedFromWaitlist && (
|
||||
<div
|
||||
className={
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { ReactComponent as Content } from '~/docs/product/roadmap.md';
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { ReactComponent as ArrowRight } from '@sd/interface/assets/svg/arrow-right.svg';
|
||||
|
|
|
@ -1,20 +1,19 @@
|
|||
import React from 'react'
|
||||
import { hydrateRoot } from 'react-dom/client'
|
||||
import App from '../App'
|
||||
import type { PageContext } from './types'
|
||||
import type { PageContextBuiltInClient } from 'vite-plugin-ssr/client'
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import type { PageContextBuiltInClient } from 'vite-plugin-ssr/client';
|
||||
|
||||
export { render }
|
||||
import App from '../App';
|
||||
import type { PageContext } from './types';
|
||||
|
||||
export { render };
|
||||
|
||||
async function render(pageContext: PageContextBuiltInClient & PageContext) {
|
||||
const { Page, pageProps } = pageContext
|
||||
hydrateRoot(
|
||||
document.getElementById('page-view')!,
|
||||
<App pageContext={pageContext as any}>
|
||||
<Page {...pageProps} />
|
||||
</App>,
|
||||
)
|
||||
const { Page, pageProps } = pageContext;
|
||||
hydrateRoot(
|
||||
document.getElementById('page-view')!,
|
||||
<App pageContext={pageContext as any}>
|
||||
<Page {...pageProps} />
|
||||
</App>
|
||||
);
|
||||
}
|
||||
|
||||
export const clientRouting = true
|
||||
|
||||
export const clientRouting = true;
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { dangerouslySkipEscape, escapeInject } from 'vite-plugin-ssr';
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Button } from '@sd/ui';
|
||||
import { SmileyXEyes } from 'phosphor-react';
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import Markdown from '../components/Markdown';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// `usePageContext` allows us to access `pageContext` in any React component.
|
||||
// More infos: https://vite-plugin-ssr.com/pageContext-anywhere
|
||||
import React, { useContext } from 'react';
|
||||
import { ReactNode, createContext, useContext } from 'react';
|
||||
import { PageContextBuiltIn } from 'vite-plugin-ssr';
|
||||
|
||||
import type { PageContext } from './types';
|
||||
|
@ -8,14 +8,14 @@ import type { PageContext } from './types';
|
|||
export { PageContextProvider };
|
||||
export { usePageContext };
|
||||
|
||||
const Context = React.createContext<PageContextBuiltIn>(undefined as any);
|
||||
const Context = createContext<PageContextBuiltIn>(undefined as any);
|
||||
|
||||
function PageContextProvider({
|
||||
pageContext,
|
||||
children
|
||||
}: {
|
||||
pageContext: PageContextBuiltIn;
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <Context.Provider value={pageContext}>{children}</Context.Provider>;
|
||||
}
|
||||
|
|
1
apps/landing/src/vite-env.d.ts
vendored
1
apps/landing/src/vite-env.d.ts
vendored
|
@ -19,6 +19,5 @@ declare module '*.md' {
|
|||
const html: string;
|
||||
|
||||
// When "Mode.React" is requested. VFC could take a generic like React.VFC<{ MyComponent: TypeOfMyComponent }>
|
||||
import React from 'react';
|
||||
const ReactComponent: React.VFC;
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
|
|||
import { DefaultTheme, NavigationContainer, Theme } from '@react-navigation/native';
|
||||
import { createClient } from '@rspc/client';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { useDeviceContext } from 'twrnc';
|
||||
|
|
|
@ -7,7 +7,7 @@ export const setItemToStorage = async (key: string, value: string | null) => {
|
|||
return true;
|
||||
} catch (e: any) {
|
||||
// saving error
|
||||
console.log('Error', e);
|
||||
console.error('Error', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
@ -20,7 +20,7 @@ export const getItemFromStorage = async (key: string) => {
|
|||
}
|
||||
return undefined;
|
||||
} catch (e: any) {
|
||||
console.log('Error', e);
|
||||
console.error('Error', e);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ export const setObjStorage = async (key: string, value: any) => {
|
|||
return true;
|
||||
} catch (e: any) {
|
||||
// saving error
|
||||
console.log('Error', e);
|
||||
console.error('Error', e);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ export const getObjFromStorage = async (key: string) => {
|
|||
return null;
|
||||
} catch (e: any) {
|
||||
// error reading value
|
||||
console.log('Error', e);
|
||||
console.error('Error', e);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ export async function removeFromStorage(key: string) {
|
|||
await AsyncStorage.removeItem(key);
|
||||
} catch (e: any) {
|
||||
// remove error
|
||||
console.log('Error', e);
|
||||
console.error('Error', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -26,10 +26,10 @@ 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: Location } |
|
||||
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: null } |
|
||||
{ key: ["locations.delete", LibraryArgs<number>], result: null } |
|
||||
{ key: ["locations.fullRescan", LibraryArgs<number>], result: null } |
|
||||
{ key: ["locations.indexer_rules.create", LibraryArgs<IndexerRuleCreateArgs>], result: IndexerRule } |
|
||||
|
@ -69,7 +69,7 @@ export interface IndexerRuleCreateArgs { kind: RuleKind, name: string, parameter
|
|||
|
||||
export interface InvalidateOperationEvent { key: string, arg: any }
|
||||
|
||||
export interface JobReport { id: string, name: string, data: Array<number> | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: number }
|
||||
export interface JobReport { id: string, name: string, data: Array<number> | null, metadata: any | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: number }
|
||||
|
||||
export type JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused"
|
||||
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
use std::{env, net::SocketAddr, path::Path};
|
||||
|
||||
use axum::{handler::Handler, routing::get};
|
||||
use axum::{
|
||||
extract,
|
||||
handler::Handler,
|
||||
http::{header::CONTENT_TYPE, HeaderMap, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
use sdcore::Node;
|
||||
use tracing::info;
|
||||
|
||||
|
@ -34,6 +39,23 @@ async fn main() {
|
|||
let app = axum::Router::new()
|
||||
.route("/", get(|| async { "Spacedrive Server!" }))
|
||||
.route("/health", get(|| async { "OK" }))
|
||||
.route("/spacedrive/:id", {
|
||||
let node = node.clone();
|
||||
get(|extract::Path(path): extract::Path<String>| async move {
|
||||
let (status_code, content_type, body) =
|
||||
node.handle_custom_uri(path.split('/').collect());
|
||||
|
||||
(
|
||||
StatusCode::from_u16(status_code).unwrap(),
|
||||
{
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, content_type.parse().unwrap());
|
||||
headers
|
||||
},
|
||||
body,
|
||||
)
|
||||
})
|
||||
})
|
||||
.route(
|
||||
"/rspcws",
|
||||
router.axum_ws_handler(move || node.get_request_context()),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { WebsocketTransport, createClient } from '@rspc/client';
|
||||
import { Operations, queryClient, rspc } from '@sd/client';
|
||||
import SpacedriveInterface from '@sd/interface';
|
||||
import { Operations, PlatformProvider, queryClient, rspc } from '@sd/client';
|
||||
import SpacedriveInterface, { Platform } from '@sd/interface';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const client = createClient<Operations>({
|
||||
|
@ -9,26 +9,22 @@ const client = createClient<Operations>({
|
|||
)
|
||||
});
|
||||
|
||||
const platform: Platform = {
|
||||
platform: 'web',
|
||||
getThumbnailUrlById: (casId) => `spacedrive://thumbnail/${encodeURIComponent(casId)}`,
|
||||
openLink: (url) => window.open(url, '_blank')?.focus(),
|
||||
demoMode: true
|
||||
};
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
window.parent.postMessage('spacedrive-hello', '*');
|
||||
}, []);
|
||||
useEffect(() => window.parent.postMessage('spacedrive-hello', '*'), []);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<rspc.Provider client={client} queryClient={queryClient}>
|
||||
<SpacedriveInterface
|
||||
demoMode
|
||||
platform={'browser'}
|
||||
convertFileSrc={function (url: string): string {
|
||||
return url;
|
||||
}}
|
||||
openDialog={function (options: {
|
||||
directory?: boolean | undefined;
|
||||
}): Promise<string | string[]> {
|
||||
return Promise.resolve([]);
|
||||
}}
|
||||
/>
|
||||
<PlatformProvider platform={platform}>
|
||||
<SpacedriveInterface />
|
||||
</PlatformProvider>
|
||||
</rspc.Provider>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -10,14 +10,7 @@ export default defineConfig({
|
|||
server: {
|
||||
port: 8002
|
||||
},
|
||||
plugins: [
|
||||
// @ts-ignore
|
||||
react({
|
||||
jsxRuntime: 'classic'
|
||||
}),
|
||||
svg({ svgrOptions: { icon: true } }),
|
||||
tsconfigPaths()
|
||||
],
|
||||
plugins: [react(), svg({ svgrOptions: { icon: true } }), tsconfigPaths()],
|
||||
root: 'src',
|
||||
publicDir: '../../packages/interface/src/assets',
|
||||
define: {
|
||||
|
|
|
@ -10,13 +10,9 @@ rust-version = "1.63.0"
|
|||
|
||||
[features]
|
||||
default = ["p2p"]
|
||||
p2p = [
|
||||
] # This feature controlls whether the Spacedrive Core contains the Peer to Peer syncing engine (It isn't required for the hosted core so we can disable it).
|
||||
mobile = [
|
||||
] # This feature allows features to be disabled when the Core is running on mobile.
|
||||
ffmpeg = [
|
||||
"dep:ffmpeg-next",
|
||||
] # This feature controls whether the Spacedrive Core contains functionality which requires FFmpeg.
|
||||
p2p = [] # This feature controlls whether the Spacedrive Core contains the Peer to Peer syncing engine (It isn't required for the hosted core so we can disable it).
|
||||
mobile = [] # This feature allows features to be disabled when the Core is running on mobile.
|
||||
ffmpeg = ["dep:ffmpeg-next"] # This feature controls whether the Spacedrive Core contains functionality which requires FFmpeg.
|
||||
|
||||
[dependencies]
|
||||
hostname = "0.3.1"
|
||||
|
@ -64,4 +60,4 @@ enumflags2 = "0.7.5"
|
|||
|
||||
[dev-dependencies]
|
||||
tempfile = "^3.3.0"
|
||||
tracing-test = "^0.2.3"
|
||||
tracing-test = "^0.2.3"
|
|
@ -26,10 +26,10 @@ 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: Location } |
|
||||
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: null } |
|
||||
{ key: ["locations.delete", LibraryArgs<number>], result: null } |
|
||||
{ key: ["locations.fullRescan", LibraryArgs<number>], result: null } |
|
||||
{ key: ["locations.indexer_rules.create", LibraryArgs<IndexerRuleCreateArgs>], result: IndexerRule } |
|
||||
|
@ -69,7 +69,7 @@ export interface IndexerRuleCreateArgs { kind: RuleKind, name: string, parameter
|
|||
|
||||
export interface InvalidateOperationEvent { key: string, arg: any }
|
||||
|
||||
export interface JobReport { id: string, name: string, data: Array<number> | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: number }
|
||||
export interface JobReport { id: string, name: string, data: Array<number> | null, metadata: any | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: number }
|
||||
|
||||
export type JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused"
|
||||
|
||||
|
|
|
@ -225,6 +225,7 @@ CREATE TABLE "jobs" (
|
|||
"action" INTEGER NOT NULL,
|
||||
"status" INTEGER NOT NULL DEFAULT 0,
|
||||
"data" BLOB,
|
||||
"metadata" BLOB,
|
||||
"task_count" INTEGER NOT NULL DEFAULT 1,
|
||||
"completed_task_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
@ -313,12 +313,13 @@ model FileInSpace {
|
|||
}
|
||||
|
||||
model Job {
|
||||
id Bytes @id
|
||||
name String
|
||||
node_id Int
|
||||
action Int
|
||||
status Int @default(0)
|
||||
data Bytes?
|
||||
id Bytes @id
|
||||
name String
|
||||
node_id Int
|
||||
action Int
|
||||
status Int @default(0)
|
||||
data Bytes?
|
||||
metadata Bytes?
|
||||
|
||||
task_count Int @default(1)
|
||||
completed_task_count Int @default(0)
|
||||
|
|
|
@ -2,9 +2,11 @@ use crate::{
|
|||
encode::{ThumbnailJob, ThumbnailJobInit},
|
||||
file::cas::{FileIdentifierJob, FileIdentifierJobInit},
|
||||
job::{Job, JobManager},
|
||||
location::{fetch_location, LocationError},
|
||||
prisma::location,
|
||||
};
|
||||
|
||||
use rspc::Type;
|
||||
use rspc::{ErrorCode, Type};
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
@ -33,6 +35,16 @@ pub(crate) fn mount() -> RouterBuilder {
|
|||
.library_mutation(
|
||||
"generateThumbsForLocation",
|
||||
|_, args: GenerateThumbsForLocationArgs, library| async move {
|
||||
if library
|
||||
.db
|
||||
.location()
|
||||
.count(vec![location::id::equals(args.id)])
|
||||
.exec()
|
||||
.await? == 0
|
||||
{
|
||||
return Err(LocationError::IdNotFound(args.id).into());
|
||||
}
|
||||
|
||||
library
|
||||
.spawn_job(Job::new(
|
||||
ThumbnailJobInit {
|
||||
|
@ -50,6 +62,13 @@ pub(crate) fn mount() -> RouterBuilder {
|
|||
.library_mutation(
|
||||
"identifyUniqueFiles",
|
||||
|_, args: IdentifyUniqueFilesArgs, library| async move {
|
||||
if fetch_location(&library, args.id).exec().await?.is_none() {
|
||||
return Err(rspc::Error::new(
|
||||
ErrorCode::NotFound,
|
||||
"Location not found".into(),
|
||||
));
|
||||
}
|
||||
|
||||
library
|
||||
.spawn_job(Job::new(
|
||||
FileIdentifierJobInit {
|
||||
|
|
|
@ -2,8 +2,9 @@ use crate::{
|
|||
encode::THUMBNAIL_CACHE_DIR_NAME,
|
||||
invalidate_query,
|
||||
location::{
|
||||
indexer::indexer_rules::IndexerRuleCreateArgs, scan_location, LocationCreateArgs,
|
||||
LocationUpdateArgs,
|
||||
fetch_location,
|
||||
indexer::{indexer_job::indexer_job_location, indexer_rules::IndexerRuleCreateArgs},
|
||||
scan_location, LocationCreateArgs, LocationError, LocationUpdateArgs,
|
||||
},
|
||||
prisma::{file, file_path, indexer_rule, indexer_rules_in_location, location, tag},
|
||||
};
|
||||
|
@ -129,9 +130,8 @@ pub(crate) fn mount() -> RouterBuilder {
|
|||
"create",
|
||||
|_, args: LocationCreateArgs, library| async move {
|
||||
let location = args.create(&library).await?;
|
||||
scan_location(&library, location.id).await?;
|
||||
|
||||
Ok(location)
|
||||
scan_location(&library, location).await?;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.library_mutation(
|
||||
|
@ -171,9 +171,16 @@ pub(crate) fn mount() -> RouterBuilder {
|
|||
Ok(())
|
||||
})
|
||||
.library_mutation("fullRescan", |_, location_id: i32, library| async move {
|
||||
scan_location(&library, location_id)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
scan_location(
|
||||
&library,
|
||||
fetch_location(&library, location_id)
|
||||
.include(indexer_job_location::include())
|
||||
.exec()
|
||||
.await?
|
||||
.ok_or(LocationError::IdNotFound(location_id))?,
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
})
|
||||
.library_mutation("quickRescan", |_, _: (), _| async move {
|
||||
#[allow(unreachable_code)]
|
||||
|
|
|
@ -44,6 +44,7 @@ pub(crate) struct InvalidRequests {
|
|||
}
|
||||
|
||||
impl InvalidRequests {
|
||||
#[allow(unused)]
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
queries: Vec::new(),
|
||||
|
|
|
@ -53,13 +53,12 @@ impl StatefulJob for ThumbnailJob {
|
|||
&self,
|
||||
ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> JobResult {
|
||||
) -> Result<(), JobError> {
|
||||
let library_ctx = ctx.library_ctx();
|
||||
let thumbnail_dir = library_ctx
|
||||
.config()
|
||||
.data_directory()
|
||||
.join(THUMBNAIL_CACHE_DIR_NAME);
|
||||
// .join(state.init.location_id.to_string());
|
||||
|
||||
let location = library_ctx
|
||||
.db
|
||||
|
@ -102,7 +101,7 @@ impl StatefulJob for ThumbnailJob {
|
|||
&self,
|
||||
ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> JobResult {
|
||||
) -> Result<(), JobError> {
|
||||
let step = &state.steps[0];
|
||||
ctx.progress(vec![JobReportUpdate::Message(format!(
|
||||
"Processing {}",
|
||||
|
@ -163,7 +162,7 @@ impl StatefulJob for ThumbnailJob {
|
|||
&self,
|
||||
_ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> Result<(), JobError> {
|
||||
) -> JobResult {
|
||||
let data = state
|
||||
.data
|
||||
.as_ref()
|
||||
|
@ -173,7 +172,9 @@ impl StatefulJob for ThumbnailJob {
|
|||
state.init.location_id,
|
||||
data.root_path.display()
|
||||
);
|
||||
Ok(())
|
||||
|
||||
// TODO: Serialize and return metadata here
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -66,15 +66,17 @@ impl StatefulJob for FileIdentifierJob {
|
|||
&self,
|
||||
ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> JobResult {
|
||||
info!("Identifying orphan file paths...");
|
||||
) -> Result<(), JobError> {
|
||||
info!("Identifying orphan Paths...");
|
||||
|
||||
let library = ctx.library_ctx();
|
||||
|
||||
let location_id = state.init.location_id;
|
||||
|
||||
let location = library
|
||||
.db
|
||||
.location()
|
||||
.find_unique(location::id::equals(state.init.location_id))
|
||||
.find_unique(location::id::equals(location_id))
|
||||
.exec()
|
||||
.await?
|
||||
.unwrap();
|
||||
|
@ -89,18 +91,30 @@ impl StatefulJob for FileIdentifierJob {
|
|||
info!("Found {} orphan file paths", total_count);
|
||||
|
||||
let task_count = (total_count as f64 / CHUNK_SIZE as f64).ceil() as usize;
|
||||
info!("Will process {} tasks", task_count);
|
||||
info!(
|
||||
"Found {} orphan Paths. Will execute {} tasks...",
|
||||
total_count, task_count
|
||||
);
|
||||
|
||||
// update job with total task count based on orphan file_paths count
|
||||
ctx.progress(vec![JobReportUpdate::TaskCount(task_count)]);
|
||||
|
||||
let first_path_id = library
|
||||
.db
|
||||
.file_path()
|
||||
.find_first(orphan_path_filters(location_id, None))
|
||||
.exec()
|
||||
.await?
|
||||
.map(|d| d.id)
|
||||
.unwrap_or(1);
|
||||
|
||||
state.data = Some(FileIdentifierJobState {
|
||||
total_count,
|
||||
task_count,
|
||||
location,
|
||||
location_path,
|
||||
cursor: FilePathIdAndLocationIdCursor {
|
||||
file_path_id: 1,
|
||||
file_path_id: first_path_id,
|
||||
location_id: state.init.location_id,
|
||||
},
|
||||
});
|
||||
|
@ -114,7 +128,7 @@ impl StatefulJob for FileIdentifierJob {
|
|||
&self,
|
||||
ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> JobResult {
|
||||
) -> Result<(), JobError> {
|
||||
let db = ctx.library_ctx().db;
|
||||
|
||||
// link file_path ids to a CreateFile struct containing unique file data
|
||||
|
@ -124,19 +138,21 @@ impl StatefulJob for FileIdentifierJob {
|
|||
let data = state
|
||||
.data
|
||||
.as_mut()
|
||||
.expect("critical error: missing data on job state");
|
||||
.expect("Critical error: missing data on job state");
|
||||
|
||||
// get chunk of orphans to process
|
||||
let file_paths =
|
||||
match get_orphan_file_paths(&ctx.library_ctx(), &data.cursor, data.location.id).await {
|
||||
Ok(file_paths) => file_paths,
|
||||
Err(e) => {
|
||||
info!("Error getting orphan file paths: {:#?}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
get_orphan_file_paths(&ctx.library_ctx(), &data.cursor, data.location.id).await?;
|
||||
|
||||
// if no file paths found, abort entire job early
|
||||
if file_paths.is_empty() {
|
||||
return Err(JobError::JobDataNotFound(
|
||||
"Expected orphan Paths not returned from database query for this chunk".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"Processing {:?} orphan files. ({} completed of {})",
|
||||
"Processing {:?} orphan Paths. ({} completed of {})",
|
||||
file_paths.len(),
|
||||
state.step_number,
|
||||
data.task_count
|
||||
|
@ -145,7 +161,7 @@ impl StatefulJob for FileIdentifierJob {
|
|||
// analyze each file_path
|
||||
for file_path in &file_paths {
|
||||
// get the cas_id and extract metadata
|
||||
match prepare_file(&data.location_path, file_path).await {
|
||||
match assemble_object_metadata(&data.location_path, file_path).await {
|
||||
Ok(file) => {
|
||||
let cas_id = file.cas_id.clone();
|
||||
// create entry into chunks for created file data
|
||||
|
@ -153,7 +169,7 @@ impl StatefulJob for FileIdentifierJob {
|
|||
cas_lookup.insert(cas_id, file_path.id);
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Error processing file: {:#?}", e);
|
||||
error!("Error assembling Object metadata: {:#?}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
@ -182,7 +198,7 @@ impl StatefulJob for FileIdentifierJob {
|
|||
.exec()
|
||||
.await
|
||||
{
|
||||
info!("Error updating file_id: {:#?}", e);
|
||||
error!("Error updating file_id: {:#?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,65 +214,67 @@ impl StatefulJob for FileIdentifierJob {
|
|||
.filter(|create_file| !existing_files_cas_ids.contains(&create_file.cas_id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// assemble prisma values for new unique files
|
||||
let mut values = Vec::with_capacity(new_files.len() * 3);
|
||||
for file in &new_files {
|
||||
values.extend([
|
||||
PrismaValue::String(file.cas_id.clone()),
|
||||
PrismaValue::Int(file.size_in_bytes),
|
||||
PrismaValue::DateTime(file.date_created),
|
||||
]);
|
||||
}
|
||||
if !new_files.is_empty() {
|
||||
// assemble prisma values for new unique files
|
||||
let mut values = Vec::with_capacity(new_files.len() * 3);
|
||||
for file in &new_files {
|
||||
values.extend([
|
||||
PrismaValue::String(file.cas_id.clone()),
|
||||
PrismaValue::Int(file.size_in_bytes),
|
||||
PrismaValue::DateTime(file.date_created),
|
||||
]);
|
||||
}
|
||||
|
||||
// create new file records with assembled values
|
||||
// TODO: Use create_many with skip_duplicates. Waiting on https://github.com/Brendonovich/prisma-client-rust/issues/143
|
||||
let created_files: Vec<FileCreated> = db
|
||||
._query_raw(Raw::new(
|
||||
&format!(
|
||||
"INSERT INTO files (cas_id, size_in_bytes, date_created) VALUES {}
|
||||
// create new file records with assembled values
|
||||
// TODO: Use create_many with skip_duplicates. Waiting on https://github.com/Brendonovich/prisma-client-rust/issues/143
|
||||
let created_files: Vec<FileCreated> = db
|
||||
._query_raw(Raw::new(
|
||||
&format!(
|
||||
"INSERT INTO files (cas_id, size_in_bytes, date_created) VALUES {}
|
||||
ON CONFLICT (cas_id) DO NOTHING RETURNING id, cas_id",
|
||||
vec!["({}, {}, {})"; new_files.len()].join(",")
|
||||
),
|
||||
values,
|
||||
))
|
||||
.exec()
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Error inserting files: {:#?}", e);
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
for created_file in created_files {
|
||||
// associate newly created files with their respective file_paths
|
||||
// TODO: this is potentially bottle necking the chunk system, individually linking file_path to file, 100 queries per chunk
|
||||
// - insert many could work, but I couldn't find a good way to do this in a single SQL query
|
||||
if let Err(e) = db
|
||||
.file_path()
|
||||
.update(
|
||||
file_path::location_id_id(
|
||||
state.init.location_id,
|
||||
*cas_lookup.get(&created_file.cas_id).unwrap(),
|
||||
vec!["({}, {}, {})"; new_files.len()].join(",")
|
||||
),
|
||||
vec![file_path::file_id::set(Some(created_file.id))],
|
||||
)
|
||||
values,
|
||||
))
|
||||
.exec()
|
||||
.await
|
||||
{
|
||||
info!("Error updating file_id: {:#?}", e);
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Error inserting files: {:#?}", e);
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
for created_file in created_files {
|
||||
// associate newly created files with their respective file_paths
|
||||
// TODO: this is potentially bottle necking the chunk system, individually linking file_path to file, 100 queries per chunk
|
||||
// - insert many could work, but I couldn't find a good way to do this in a single SQL query
|
||||
if let Err(e) = ctx
|
||||
.library_ctx()
|
||||
.db
|
||||
.file_path()
|
||||
.update(
|
||||
file_path::location_id_id(
|
||||
state.init.location_id,
|
||||
*cas_lookup.get(&created_file.cas_id).unwrap(),
|
||||
),
|
||||
vec![file_path::file_id::set(Some(created_file.id))],
|
||||
)
|
||||
.exec()
|
||||
.await
|
||||
{
|
||||
info!("Error updating file_id: {:#?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle last step
|
||||
// set the step data cursor to the last row of this chunk
|
||||
if let Some(last_row) = file_paths.last() {
|
||||
data.cursor.file_path_id = last_row.id;
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ctx.progress(vec![
|
||||
JobReportUpdate::CompletedTaskCount(state.step_number),
|
||||
JobReportUpdate::Message(format!(
|
||||
"Processed {} of {} orphan files",
|
||||
"Processed {} of {} orphan Paths",
|
||||
state.step_number * CHUNK_SIZE,
|
||||
data.total_count
|
||||
)),
|
||||
|
@ -270,7 +288,7 @@ impl StatefulJob for FileIdentifierJob {
|
|||
&self,
|
||||
_ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> Result<(), JobError> {
|
||||
) -> JobResult {
|
||||
let data = state
|
||||
.data
|
||||
.as_ref()
|
||||
|
@ -281,10 +299,23 @@ impl StatefulJob for FileIdentifierJob {
|
|||
data.task_count
|
||||
);
|
||||
|
||||
Ok(())
|
||||
Ok(Some(serde_json::to_value(&state.init)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn orphan_path_filters(location_id: i32, file_path_id: Option<i32>) -> Vec<file_path::WhereParam> {
|
||||
let mut params = vec![
|
||||
file_path::file_id::equals(None),
|
||||
file_path::is_dir::equals(false),
|
||||
file_path::location_id::equals(location_id),
|
||||
];
|
||||
// this is a workaround for the cursor not working properly
|
||||
if let Some(file_path_id) = file_path_id {
|
||||
params.push(file_path::id::gte(file_path_id))
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
struct CountRes {
|
||||
count: Option<usize>,
|
||||
|
@ -314,19 +345,16 @@ async fn get_orphan_file_paths(
|
|||
location_id: i32,
|
||||
) -> Result<Vec<file_path::Data>, prisma_client_rust::QueryError> {
|
||||
info!(
|
||||
"discovering {} orphan file paths at cursor: {:?}",
|
||||
"Querying {} orphan Paths at cursor: {:?}",
|
||||
CHUNK_SIZE, cursor
|
||||
);
|
||||
ctx.db
|
||||
.file_path()
|
||||
.find_many(vec![
|
||||
file_path::file_id::equals(None),
|
||||
file_path::is_dir::equals(false),
|
||||
file_path::location_id::equals(location_id),
|
||||
])
|
||||
.find_many(orphan_path_filters(location_id, Some(cursor.file_path_id)))
|
||||
.order_by(file_path::id::order(Direction::Asc))
|
||||
.cursor(cursor.into())
|
||||
// .cursor(cursor.into())
|
||||
.take(CHUNK_SIZE as i64)
|
||||
.skip(1)
|
||||
.exec()
|
||||
.await
|
||||
}
|
||||
|
@ -344,7 +372,7 @@ struct FileCreated {
|
|||
pub cas_id: String,
|
||||
}
|
||||
|
||||
async fn prepare_file(
|
||||
async fn assemble_object_metadata(
|
||||
location_path: impl AsRef<Path>,
|
||||
file_path: &file_path::Data,
|
||||
) -> Result<CreateFile, io::Error> {
|
||||
|
|
|
@ -8,6 +8,7 @@ use crate::{
|
|||
};
|
||||
|
||||
use int_enum::IntEnum;
|
||||
use prisma_client_rust::Direction;
|
||||
use rspc::Type;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
|
@ -127,6 +128,8 @@ impl JobManager {
|
|||
.db
|
||||
.job()
|
||||
.find_many(vec![job::status::not(JobStatus::Running.int_value())])
|
||||
.order_by(job::date_created::order(Direction::Desc))
|
||||
.take(100)
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
|
@ -213,6 +216,7 @@ pub struct JobReport {
|
|||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub data: Option<Vec<u8>>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
// client_id: i32,
|
||||
pub date_created: chrono::DateTime<chrono::Utc>,
|
||||
pub date_modified: chrono::DateTime<chrono::Utc>,
|
||||
|
@ -250,6 +254,12 @@ impl From<job::Data> for JobReport {
|
|||
date_created: data.date_created.into(),
|
||||
date_modified: data.date_modified.into(),
|
||||
data: data.data,
|
||||
metadata: data.metadata.and_then(|m| {
|
||||
serde_json::from_slice(&m).unwrap_or_else(|e| -> Option<serde_json::Value> {
|
||||
error!("Failed to deserialize job metadata: {}", e);
|
||||
None
|
||||
})
|
||||
}),
|
||||
message: String::new(),
|
||||
seconds_elapsed: data.seconds_elapsed,
|
||||
}
|
||||
|
@ -267,6 +277,7 @@ impl JobReport {
|
|||
status: JobStatus::Queued,
|
||||
task_count: 0,
|
||||
data: None,
|
||||
metadata: None,
|
||||
completed_task_count: 0,
|
||||
message: String::new(),
|
||||
seconds_elapsed: 0,
|
||||
|
@ -295,6 +306,7 @@ impl JobReport {
|
|||
vec![
|
||||
job::status::set(self.status.int_value()),
|
||||
job::data::set(self.data.clone()),
|
||||
job::metadata::set(serde_json::to_vec(&self.metadata).ok()),
|
||||
job::task_count::set(self.task_count),
|
||||
job::completed_task_count::set(self.completed_task_count),
|
||||
job::date_modified::set(chrono::Utc::now().into()),
|
||||
|
|
|
@ -26,6 +26,8 @@ pub enum JobError {
|
|||
StateEncode(#[from] EncodeError),
|
||||
#[error("Job state decode error: {0}")]
|
||||
StateDecode(#[from] DecodeError),
|
||||
#[error("Job metadata serialization error: {0}")]
|
||||
MetadataSerialization(#[from] serde_json::Error),
|
||||
#[error("Tried to resume a job with unknown name: job <name='{1}', uuid='{0}'>")]
|
||||
UnknownJobName(Uuid, String),
|
||||
#[error(
|
||||
|
@ -34,11 +36,14 @@ pub enum JobError {
|
|||
MissingJobDataState(Uuid, String),
|
||||
#[error("Indexer error: {0}")]
|
||||
IndexerError(#[from] IndexerError),
|
||||
#[error("Data needed for job execution not found: job <name='{0}'>")]
|
||||
JobDataNotFound(String),
|
||||
#[error("Job paused")]
|
||||
Paused(Vec<u8>),
|
||||
}
|
||||
|
||||
pub type JobResult = Result<(), JobError>;
|
||||
pub type JobResult = Result<JobMetadata, JobError>;
|
||||
pub type JobMetadata = Option<serde_json::Value>;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait StatefulJob: Send + Sync {
|
||||
|
@ -51,13 +56,13 @@ pub trait StatefulJob: Send + Sync {
|
|||
&self,
|
||||
ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> JobResult;
|
||||
) -> Result<(), JobError>;
|
||||
|
||||
async fn execute_step(
|
||||
&self,
|
||||
ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> JobResult;
|
||||
) -> Result<(), JobError>;
|
||||
|
||||
async fn finalize(
|
||||
&self,
|
||||
|
@ -149,6 +154,7 @@ where
|
|||
fn name(&self) -> &'static str {
|
||||
self.stateful_job.name()
|
||||
}
|
||||
|
||||
async fn run(&mut self, ctx: WorkerContext) -> JobResult {
|
||||
// Checking if we have a brand new job, or if we are resuming an old one.
|
||||
if self.state.data.is_none() {
|
||||
|
@ -181,8 +187,6 @@ where
|
|||
|
||||
self.stateful_job
|
||||
.finalize(ctx.clone(), &mut self.state)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,13 +13,16 @@ use tokio::{
|
|||
};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use super::JobReport;
|
||||
use super::{JobMetadata, JobReport};
|
||||
|
||||
// used to update the worker state from inside the worker thread
|
||||
#[derive(Debug)]
|
||||
pub enum WorkerEvent {
|
||||
Progressed(Vec<JobReportUpdate>),
|
||||
Completed(oneshot::Sender<()>),
|
||||
Progressed {
|
||||
updates: Vec<JobReportUpdate>,
|
||||
debounce: bool,
|
||||
},
|
||||
Completed(oneshot::Sender<()>, JobMetadata),
|
||||
Failed(oneshot::Sender<()>),
|
||||
Paused(Vec<u8>, oneshot::Sender<()>),
|
||||
}
|
||||
|
@ -34,7 +37,18 @@ pub struct WorkerContext {
|
|||
impl WorkerContext {
|
||||
pub fn progress(&self, updates: Vec<JobReportUpdate>) {
|
||||
self.events_tx
|
||||
.send(WorkerEvent::Progressed(updates))
|
||||
.send(WorkerEvent::Progressed {
|
||||
updates,
|
||||
debounce: false,
|
||||
})
|
||||
.expect("critical error: failed to send worker worker progress event updates");
|
||||
}
|
||||
pub fn progress_debounced(&self, updates: Vec<JobReportUpdate>) {
|
||||
self.events_tx
|
||||
.send(WorkerEvent::Progressed {
|
||||
updates,
|
||||
debounce: true,
|
||||
})
|
||||
.expect("critical error: failed to send worker worker progress event updates");
|
||||
}
|
||||
|
||||
|
@ -124,9 +138,10 @@ impl Worker {
|
|||
loop {
|
||||
interval.tick().await;
|
||||
if events_tx
|
||||
.send(WorkerEvent::Progressed(vec![
|
||||
JobReportUpdate::SecondsElapsed(1),
|
||||
]))
|
||||
.send(WorkerEvent::Progressed {
|
||||
updates: vec![JobReportUpdate::SecondsElapsed(1)],
|
||||
debounce: false,
|
||||
})
|
||||
.is_err() && events_tx.is_closed()
|
||||
{
|
||||
break;
|
||||
|
@ -136,25 +151,27 @@ impl Worker {
|
|||
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
|
||||
if let Err(e) = job.run(worker_ctx.clone()).await {
|
||||
if let JobError::Paused(state) = e {
|
||||
match job.run(worker_ctx.clone()).await {
|
||||
Ok(metadata) => {
|
||||
// handle completion
|
||||
worker_ctx
|
||||
.events_tx
|
||||
.send(WorkerEvent::Completed(done_tx, metadata))
|
||||
.expect("critical error: failed to send worker complete event");
|
||||
}
|
||||
Err(JobError::Paused(state)) => {
|
||||
worker_ctx
|
||||
.events_tx
|
||||
.send(WorkerEvent::Paused(state, done_tx))
|
||||
.expect("critical error: failed to send worker pause event");
|
||||
} else {
|
||||
}
|
||||
Err(e) => {
|
||||
error!("job '{}' failed with error: {:#?}", job_id, e);
|
||||
worker_ctx
|
||||
.events_tx
|
||||
.send(WorkerEvent::Failed(done_tx))
|
||||
.expect("critical error: failed to send worker fail event");
|
||||
}
|
||||
} else {
|
||||
// handle completion
|
||||
worker_ctx
|
||||
.events_tx
|
||||
.send(WorkerEvent::Completed(done_tx))
|
||||
.expect("critical error: failed to send worker complete event");
|
||||
}
|
||||
|
||||
if let Err(e) = done_rx.await {
|
||||
|
@ -171,17 +188,27 @@ impl Worker {
|
|||
mut worker_events_rx: UnboundedReceiver<WorkerEvent>,
|
||||
library: LibraryContext,
|
||||
) {
|
||||
let mut last = Instant::now();
|
||||
|
||||
while let Some(command) = worker_events_rx.recv().await {
|
||||
let mut worker = worker.lock().await;
|
||||
|
||||
match command {
|
||||
WorkerEvent::Progressed(changes) => {
|
||||
WorkerEvent::Progressed { updates, debounce } => {
|
||||
if debounce {
|
||||
let current = Instant::now();
|
||||
if current.duration_since(last) > Duration::from_millis(1000 / 60) {
|
||||
last = current
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// protect against updates if job is not running
|
||||
if worker.report.status != JobStatus::Running {
|
||||
continue;
|
||||
};
|
||||
for change in changes {
|
||||
match change {
|
||||
for update in updates {
|
||||
match update {
|
||||
JobReportUpdate::TaskCount(task_count) => {
|
||||
worker.report.task_count = task_count as i32;
|
||||
}
|
||||
|
@ -199,9 +226,10 @@ impl Worker {
|
|||
|
||||
invalidate_query!(library, "jobs.getRunning");
|
||||
}
|
||||
WorkerEvent::Completed(done_tx) => {
|
||||
WorkerEvent::Completed(done_tx, metadata) => {
|
||||
worker.report.status = JobStatus::Completed;
|
||||
worker.report.data = None;
|
||||
worker.report.metadata = metadata;
|
||||
if let Err(e) = worker.report.update(&library).await {
|
||||
error!("failed to update job report: {:#?}", e);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ use api::{CoreEvent, Ctx, Router};
|
|||
use job::JobManager;
|
||||
use library::LibraryManager;
|
||||
use node::NodeConfigManager;
|
||||
use std::{path::Path, sync::Arc};
|
||||
use std::{fs::File, io::Read, path::Path, sync::Arc};
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::{filter::LevelFilter, fmt, prelude::*, EnvFilter};
|
||||
|
||||
|
@ -115,6 +115,51 @@ impl Node {
|
|||
}
|
||||
}
|
||||
|
||||
// Note: this system doesn't use chunked encoding which could prove a problem with large files but I can't see an easy way to do chunked encoding with Tauri custom URIs.
|
||||
// It would also be nice to use Tokio Filesystem operations instead of the std ones which block. Tauri's custom URI protocols don't seem to support async out of the box.
|
||||
pub fn handle_custom_uri(
|
||||
&self,
|
||||
path: Vec<&str>,
|
||||
) -> (
|
||||
u16, /* Status Code */
|
||||
&str, /* Content-Type */
|
||||
Vec<u8>, /* Body */
|
||||
) {
|
||||
match path.first().copied() {
|
||||
Some("thumbnail") => {
|
||||
if path.len() != 2 {
|
||||
return (
|
||||
400,
|
||||
"text/html",
|
||||
b"Bad Request: Invalid number of parameters".to_vec(),
|
||||
);
|
||||
}
|
||||
|
||||
let filename = Path::new(&self.config.data_directory())
|
||||
.join("thumbnails")
|
||||
.join(path[1] /* file_cas_id */)
|
||||
.with_extension("webp");
|
||||
match File::open(&filename) {
|
||||
Ok(mut file) => {
|
||||
let mut buf = match std::fs::metadata(&filename) {
|
||||
Ok(metadata) => Vec::with_capacity(metadata.len() as usize),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
file.read_to_end(&mut buf).unwrap();
|
||||
(200, "image/webp", buf)
|
||||
}
|
||||
Err(_) => (404, "text/html", b"File Not Found".to_vec()),
|
||||
}
|
||||
}
|
||||
_ => (
|
||||
400,
|
||||
"text/html",
|
||||
b"Bad Request: Invalid operation!".to_vec(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
info!("Spacedrive shutting down...");
|
||||
self.jobs.pause().await;
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use crate::{
|
||||
job::{JobReportUpdate, JobResult, JobState, StatefulJob, WorkerContext},
|
||||
job::{JobError, JobReportUpdate, JobResult, JobState, StatefulJob, WorkerContext},
|
||||
prisma::{file_path, location},
|
||||
};
|
||||
|
||||
|
@ -70,7 +70,7 @@ pub struct IndexerJobStepEntry {
|
|||
|
||||
impl IndexerJobData {
|
||||
fn on_scan_progress(ctx: WorkerContext, progress: Vec<ScanProgress>) {
|
||||
ctx.progress(
|
||||
ctx.progress_debounced(
|
||||
progress
|
||||
.iter()
|
||||
.map(|p| match p.clone() {
|
||||
|
@ -98,7 +98,7 @@ impl StatefulJob for IndexerJob {
|
|||
&self,
|
||||
ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> JobResult {
|
||||
) -> Result<(), JobError> {
|
||||
let location_path = state
|
||||
.init
|
||||
.location
|
||||
|
@ -225,7 +225,7 @@ impl StatefulJob for IndexerJob {
|
|||
&self,
|
||||
ctx: WorkerContext,
|
||||
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
|
||||
) -> JobResult {
|
||||
) -> Result<(), JobError> {
|
||||
let location_path = &state
|
||||
.data
|
||||
.as_ref()
|
||||
|
@ -303,7 +303,7 @@ impl StatefulJob for IndexerJob {
|
|||
.expect("critical error: non-negative duration"),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
Ok(Some(serde_json::to_value(state)?))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,7 +37,10 @@ pub struct LocationCreateArgs {
|
|||
}
|
||||
|
||||
impl LocationCreateArgs {
|
||||
pub async fn create(self, ctx: &LibraryContext) -> Result<location::Data, LocationError> {
|
||||
pub async fn create(
|
||||
self,
|
||||
ctx: &LibraryContext,
|
||||
) -> Result<indexer_job_location::Data, LocationError> {
|
||||
// check if we have access to this location
|
||||
if !self.path.exists() {
|
||||
return Err(LocationError::PathNotFound(self.path));
|
||||
|
@ -75,6 +78,7 @@ impl LocationCreateArgs {
|
|||
location::local_path::set(Some(self.path.to_string_lossy().to_string())),
|
||||
],
|
||||
)
|
||||
.include(indexer_job_location::include())
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
|
@ -86,6 +90,7 @@ impl LocationCreateArgs {
|
|||
|
||||
// Updating our location variable to include information about the indexer rules
|
||||
location = fetch_location(ctx, location.id)
|
||||
.include(indexer_job_location::include())
|
||||
.exec()
|
||||
.await?
|
||||
.ok_or(LocationError::IdNotFound(location.id))?;
|
||||
|
@ -232,35 +237,28 @@ async fn link_location_and_indexer_rules(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn scan_location(ctx: &LibraryContext, location_id: i32) -> Result<(), LocationError> {
|
||||
let location = ctx
|
||||
.db
|
||||
.location()
|
||||
.find_unique(location::id::equals(location_id))
|
||||
.include(indexer_job_location::include())
|
||||
.exec()
|
||||
.await?
|
||||
.ok_or(LocationError::IdNotFound(location_id))?;
|
||||
|
||||
pub async fn scan_location(
|
||||
ctx: &LibraryContext,
|
||||
location: indexer_job_location::Data,
|
||||
) -> Result<(), LocationError> {
|
||||
if location.local_path.is_none() {
|
||||
return Err(LocationError::MissingLocalPath(location.id));
|
||||
};
|
||||
|
||||
ctx.spawn_job(Job::new(
|
||||
IndexerJobInit { location },
|
||||
Box::new(IndexerJob {}),
|
||||
))
|
||||
.await;
|
||||
|
||||
let location_id = location.id;
|
||||
ctx.queue_job(Job::new(
|
||||
FileIdentifierJobInit {
|
||||
location_id,
|
||||
location_id: location.id,
|
||||
sub_path: None,
|
||||
},
|
||||
Box::new(FileIdentifierJob {}),
|
||||
))
|
||||
.await;
|
||||
|
||||
ctx.spawn_job(Job::new(
|
||||
IndexerJobInit { location },
|
||||
Box::new(IndexerJob {}),
|
||||
))
|
||||
.await;
|
||||
ctx.queue_job(Job::new(
|
||||
ThumbnailJobInit {
|
||||
location_id,
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
# Nodes
|
||||
|
||||
Nodes are instances of the Spacedrive core running on a device, they are able to connect to each other via a peer-to-peer network. A node is able to run many libraries simultaneously, but must be authorized per-library in order to synchronize.
|
||||
|
||||
|
||||
|
||||
p2p, connecting nodes, protocols.
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
Spacedrive generates compressed preview media for images, videos and text files.
|
||||
|
||||
|
||||
Preview media is stored in the Node's data folder in a single directory. Images are stored as WEBP format with their CAS id as the name.
|
||||
|
||||
ffmpeg, syncing, security
|
|
@ -26,6 +26,8 @@
|
|||
"eventemitter3": "^4.0.7",
|
||||
"immer": "^9.0.15",
|
||||
"lodash": "^4.17.21",
|
||||
"valtio": "^1.7.0",
|
||||
"valtio-persist": "^1.0.2",
|
||||
"zustand": "4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const AppPropsContext = createContext<AppProps | null>(null);
|
||||
|
||||
export type Platform = 'browser' | 'macOS' | 'windows' | 'linux' | 'unknown';
|
||||
export type CdnUrl = 'internal' | string;
|
||||
|
||||
export const useAppProps = () => useContext(AppPropsContext);
|
||||
|
||||
export interface AppProps {
|
||||
platform: Platform;
|
||||
cdn_url?: CdnUrl;
|
||||
data_path?: string;
|
||||
convertFileSrc: (url: string) => string;
|
||||
openDialog: (options: { directory?: boolean }) => Promise<string | string[] | null>;
|
||||
onClose?: () => void;
|
||||
onMinimize?: () => void;
|
||||
onFullscreen?: () => void;
|
||||
onOpen?: (path: string) => void;
|
||||
isFocused?: boolean;
|
||||
demoMode?: boolean;
|
||||
}
|
37
packages/client/src/context/Platform.tsx
Normal file
37
packages/client/src/context/Platform.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { PropsWithChildren, createContext, useContext } from 'react';
|
||||
|
||||
export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown';
|
||||
|
||||
// Platform represents the underlying native layer the app is running on.
|
||||
// This could be Tauri or web.
|
||||
export type Platform = {
|
||||
platform: 'web' | 'tauri'; // This represents the specific platform implementation
|
||||
getThumbnailUrlById: (casId: string) => string;
|
||||
openLink: (url: string) => void;
|
||||
demoMode?: boolean; // TODO: Remove this in favour of demo mode being handled at the React Query level
|
||||
getOs?(): Promise<OperatingSystem>;
|
||||
openFilePickerDialog?(): Promise<null | string | string[]>;
|
||||
};
|
||||
|
||||
// Keep this private and use through helpers below
|
||||
const context = createContext<Platform>(undefined!);
|
||||
|
||||
// is a hook which allows you to fetch information about the current platform from the React context.
|
||||
export function usePlatform(): Platform {
|
||||
const ctx = useContext(context);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"The 'PlatformProvider' has not been mounted above the current 'usePlatform' call."
|
||||
);
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// provides the platform context to the rest of the app through React context.
|
||||
// Mount it near the top of your component tree.
|
||||
export function PlatformProvider({
|
||||
platform,
|
||||
children
|
||||
}: PropsWithChildren<{ platform: Platform }>) {
|
||||
return <context.Provider value={platform}>{children}</context.Provider>;
|
||||
}
|
|
@ -1,2 +1,2 @@
|
|||
export * from './AppPropsContext';
|
||||
export * from './LocationContext';
|
||||
export * from './Platform';
|
||||
|
|
1
packages/client/src/hooks/index.ts
Normal file
1
packages/client/src/hooks/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './useCurrentLibrary';
|
80
packages/client/src/hooks/useCurrentLibrary.tsx
Normal file
80
packages/client/src/hooks/useCurrentLibrary.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from 'react';
|
||||
import { proxy, useSnapshot } from 'valtio';
|
||||
|
||||
import { useBridgeQuery, useExplorerStore } from '../index';
|
||||
|
||||
// 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;
|
||||
}>(undefined!);
|
||||
|
||||
export const LibraryContextProvider = ({
|
||||
onNoLibrary,
|
||||
children
|
||||
}: PropsWithChildren<{ onNoLibrary: OnNoLibraryFunc }>) => {
|
||||
return <CringeContext.Provider value={{ onNoLibrary }}>{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 explorerStore = useExplorerStore();
|
||||
const currentLibraryUuid = useSnapshot(currentLibraryUuidStore).id;
|
||||
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);
|
||||
if (cachedData) {
|
||||
// If we fail to load cached data, it's fine
|
||||
try {
|
||||
return JSON.parse(cachedData);
|
||||
} catch (e) {
|
||||
console.error("Error loading cached 'sd-library-list' data", e);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data));
|
||||
|
||||
// Redirect to the onboarding flow if the user doesn't have any libraries
|
||||
if (libraries?.length === 0) {
|
||||
ctx.onNoLibrary();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const switchLibrary = useCallback((libraryUuid: string) => {
|
||||
currentLibraryUuidStore.id = libraryUuid;
|
||||
explorerStore.reset();
|
||||
}, []);
|
||||
|
||||
// memorize library to avoid re-running find function
|
||||
const library = useMemo(() => {
|
||||
const current = libraries?.find((l: any) => l.uuid === currentLibraryUuid);
|
||||
// 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.
|
||||
|
||||
return {
|
||||
library,
|
||||
libraries,
|
||||
isLoading,
|
||||
switchLibrary
|
||||
};
|
||||
};
|
|
@ -1,4 +1,5 @@
|
|||
export * from './stores';
|
||||
export * from './context';
|
||||
export * from './rspc';
|
||||
export * from './hooks';
|
||||
export type { Operations } from '@sd/core';
|
||||
|
|
|
@ -3,6 +3,8 @@ import { createReactQueryHooks } from '@rspc/react';
|
|||
import { LibraryArgs, Operations } from '@sd/core';
|
||||
import {
|
||||
QueryClient,
|
||||
UseInfiniteQueryOptions,
|
||||
UseInfiniteQueryResult,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
|
@ -10,7 +12,7 @@ import {
|
|||
useMutation as _useMutation
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { useLibraryStore } from './stores';
|
||||
import { useCurrentLibrary } from './index';
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
export const rspc = createReactQueryHooks<Operations>();
|
||||
|
@ -41,10 +43,30 @@ export function useLibraryQuery<K extends LibraryQueryKey>(
|
|||
key: LibraryQueryArgs<K> extends null | undefined ? [K] : [K, LibraryQueryArgs<K>],
|
||||
options?: UseQueryOptions<LibraryQueryResult<K>, RSPCError>
|
||||
): UseQueryResult<LibraryQueryResult<K>, RSPCError> {
|
||||
const library_id = useLibraryStore((state) => state.currentLibraryUuid);
|
||||
if (!library_id) throw new Error(`Attempted to do library query with no library set!`);
|
||||
const { library } = useCurrentLibrary();
|
||||
|
||||
if (!library?.uuid) throw new Error(`Attempted to do library query with no library set!`);
|
||||
// @ts-ignore
|
||||
return rspc.useQuery([key[0], { library_id: library_id || '', arg: key[1] || null }], options);
|
||||
return rspc.useQuery(
|
||||
// @ts-ignore
|
||||
[key[0], { library_id: library?.uuid || '', arg: key[1] || null }],
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export function useInfiniteLibraryQuery<K extends LibraryQueryKey>(
|
||||
key: LibraryQueryArgs<K> extends null | undefined ? [K] : [K, LibraryQueryArgs<K>],
|
||||
options?: UseInfiniteQueryOptions<LibraryQueryResult<K>, RSPCError>
|
||||
): UseInfiniteQueryResult<LibraryQueryResult<K>, RSPCError> {
|
||||
const { library } = useCurrentLibrary();
|
||||
|
||||
if (!library?.uuid) throw new Error(`Attempted to do library query with no library set!`);
|
||||
// @ts-ignore
|
||||
return rspc.useInfiniteQuery(
|
||||
// @ts-ignore
|
||||
[key[0], { library_id: library?.uuid || '', arg: key[1] || null }],
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
type LibraryMutations = Extract<Operations['mutations'], { key: [string, LibraryArgs<any>] }>;
|
||||
|
@ -58,12 +80,13 @@ export function useLibraryMutation<K extends LibraryMutationKey>(
|
|||
options?: UseMutationOptions<LibraryMutationResult<K>, RSPCError>
|
||||
) {
|
||||
const ctx = rspc.useContext();
|
||||
const library_id = useLibraryStore((state) => state.currentLibraryUuid);
|
||||
if (!library_id) throw new Error(`Attempted to do library query with no library set!`);
|
||||
const { library } = useCurrentLibrary();
|
||||
if (!library?.uuid) throw new Error(`Attempted to do library query with no library set!`);
|
||||
|
||||
// @ts-ignore
|
||||
return _useMutation<LibraryMutationResult<K>, RSPCError, LibraryMutationArgs<K>>(
|
||||
async (data) => ctx.client.mutation([key, { library_id: library_id || '', arg: data || null }]),
|
||||
async (data) =>
|
||||
ctx.client.mutation([key, { library_id: library?.uuid || '', arg: data || null }]),
|
||||
{
|
||||
...options,
|
||||
context: rspc.ReactQueryContext
|
||||
|
|
49
packages/client/src/stores/explorerStore.ts
Normal file
49
packages/client/src/stores/explorerStore.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { proxy, useSnapshot } from 'valtio';
|
||||
|
||||
import { resetStore } from './util';
|
||||
|
||||
export type ExplorerLayoutMode = 'list' | 'grid';
|
||||
|
||||
export enum ExplorerKind {
|
||||
Location,
|
||||
Tag,
|
||||
Space
|
||||
}
|
||||
|
||||
const state = {
|
||||
locationId: null as number | null,
|
||||
layoutMode: 'grid' as ExplorerLayoutMode,
|
||||
gridItemSize: 100,
|
||||
listItemSize: 40,
|
||||
selectedRowIndex: 1,
|
||||
showInspector: true,
|
||||
multiSelectIndexes: [] as number[],
|
||||
contextMenuObjectId: null as number | null,
|
||||
newThumbnails: {} as Record<string, boolean>
|
||||
};
|
||||
|
||||
// Keep the private and use `useExplorerState` or `getExplorerStore` or you will get production build issues.
|
||||
const explorerStore = proxy({
|
||||
...state,
|
||||
reset: () => resetStore(explorerStore, state),
|
||||
addNewThumbnail: (cas_id: string) => {
|
||||
explorerStore.newThumbnails[cas_id] = true;
|
||||
},
|
||||
selectMore: (indexes: number[]) => {
|
||||
if (!explorerStore.multiSelectIndexes.length && indexes.length) {
|
||||
explorerStore.multiSelectIndexes = [explorerStore.selectedRowIndex, ...indexes];
|
||||
} else {
|
||||
explorerStore.multiSelectIndexes = [
|
||||
...new Set([...explorerStore.multiSelectIndexes, ...indexes])
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function useExplorerStore() {
|
||||
return useSnapshot(explorerStore);
|
||||
}
|
||||
|
||||
export function getExplorerStore() {
|
||||
return explorerStore;
|
||||
}
|
|
@ -1,2 +1 @@
|
|||
export * from './useLibraryStore';
|
||||
export * from './useExplorerStore';
|
||||
export * from './explorerStore';
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
import produce from 'immer';
|
||||
import create from 'zustand';
|
||||
|
||||
type LayoutMode = 'list' | 'grid';
|
||||
|
||||
export enum ExplorerKind {
|
||||
Location,
|
||||
Tag,
|
||||
Space
|
||||
}
|
||||
|
||||
type ExplorerStore = {
|
||||
layoutMode: LayoutMode;
|
||||
locationId: number | null; // used by top bar
|
||||
showInspector: boolean;
|
||||
selectedRowIndex: number;
|
||||
multiSelectIndexes: number[];
|
||||
contextMenuObjectId: number | null;
|
||||
newThumbnails: Record<string, boolean>;
|
||||
addNewThumbnail: (cas_id: string) => void;
|
||||
selectMore: (indexes: number[]) => void;
|
||||
reset: () => void;
|
||||
set: (changes: Partial<ExplorerStore>) => void;
|
||||
};
|
||||
|
||||
export const useExplorerStore = create<ExplorerStore>((set) => ({
|
||||
layoutMode: 'grid',
|
||||
locationId: null,
|
||||
showInspector: true,
|
||||
selectedRowIndex: 1,
|
||||
multiSelectIndexes: [],
|
||||
contextMenuObjectId: -1,
|
||||
newThumbnails: {},
|
||||
addNewThumbnail: (cas_id) =>
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
draft.newThumbnails[cas_id] = true;
|
||||
})
|
||||
),
|
||||
selectMore: (indexes) => {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
if (!draft.multiSelectIndexes.length && indexes.length) {
|
||||
draft.multiSelectIndexes = [draft.selectedRowIndex, ...indexes];
|
||||
} else {
|
||||
draft.multiSelectIndexes = [...new Set([...draft.multiSelectIndexes, ...indexes])];
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
reset: () => set(() => ({})),
|
||||
set: (changes) => set((state) => ({ ...state, ...changes }))
|
||||
}));
|
|
@ -1,68 +0,0 @@
|
|||
import { useBridgeQuery } from '../index';
|
||||
import { useExplorerStore } from './useExplorerStore';
|
||||
import { LibraryConfigWrapped } from '@sd/core';
|
||||
import produce from 'immer';
|
||||
import { useMemo } from 'react';
|
||||
import create from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
|
||||
type LibraryStore = {
|
||||
// the uuid of the currently active library
|
||||
currentLibraryUuid: string | null;
|
||||
// for full functionality this should be triggered along-side query invalidation
|
||||
switchLibrary: (uuid: string) => void;
|
||||
// a function
|
||||
init: (libraries: LibraryConfigWrapped[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useLibraryStore = create<LibraryStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
currentLibraryUuid: null,
|
||||
switchLibrary: (uuid) => {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
draft.currentLibraryUuid = uuid;
|
||||
})
|
||||
);
|
||||
// reset other stores
|
||||
useExplorerStore().reset();
|
||||
},
|
||||
init: async (libraries) => {
|
||||
set((state) =>
|
||||
produce(state, (draft) => {
|
||||
// use first library default if none set
|
||||
if (!state.currentLibraryUuid) {
|
||||
draft.currentLibraryUuid = libraries[0].uuid;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}),
|
||||
{ name: 'sd-library-store' }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// this must be used at least once in the app to correct the initial state
|
||||
// is memorized and can be used safely in any component
|
||||
export const useCurrentLibrary = () => {
|
||||
const { currentLibraryUuid, switchLibrary } = useLibraryStore();
|
||||
const { data: libraries } = useBridgeQuery(['library.list'], {
|
||||
onSuccess: (data) => {},
|
||||
onError: (err) => {}
|
||||
});
|
||||
|
||||
// memorize library to avoid re-running find function
|
||||
const currentLibrary = useMemo(() => {
|
||||
const current = libraries?.find((l) => l.uuid === currentLibraryUuid);
|
||||
// switch to first library if none set
|
||||
if (Array.isArray(libraries) && !current && libraries[0]?.uuid) {
|
||||
switchLibrary(libraries[0]?.uuid);
|
||||
}
|
||||
return current;
|
||||
}, [libraries, currentLibraryUuid]);
|
||||
|
||||
return { currentLibrary, libraries, currentLibraryUuid };
|
||||
};
|
18
packages/client/src/stores/util.ts
Normal file
18
packages/client/src/stores/util.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { ProxyPersistStorageEngine } from 'valtio-persist';
|
||||
|
||||
export function resetStore<T extends Record<string, any>, E extends Record<string, any>>(
|
||||
store: T,
|
||||
defaults: E
|
||||
) {
|
||||
for (const key in defaults) {
|
||||
// @ts-ignore
|
||||
store[key] = defaults[key];
|
||||
}
|
||||
}
|
||||
|
||||
export const storageEngine: ProxyPersistStorageEngine = {
|
||||
getItem: (name) => window.localStorage.getItem(name),
|
||||
setItem: (name, value) => window.localStorage.setItem(name, value),
|
||||
removeItem: (name) => window.localStorage.removeItem(name),
|
||||
getAllKeys: () => Object.keys(window.localStorage)
|
||||
};
|
|
@ -15,7 +15,6 @@
|
|||
"lint": "eslint src/**/*.{ts,tsx} && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.9",
|
||||
"@fontsource/inter": "^4.5.11",
|
||||
"@headlessui/react": "^1.6.6",
|
||||
"@heroicons/react": "^2.0.10",
|
||||
|
@ -24,19 +23,22 @@
|
|||
"@radix-ui/react-icons": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^0.1.4",
|
||||
"@radix-ui/react-slider": "^0.1.4",
|
||||
"@radix-ui/react-tabs": "^1.0.0",
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"@sd/assets": "workspace:*",
|
||||
"@sd/client": "workspace:*",
|
||||
"@sd/core": "workspace:*",
|
||||
"@sd/ui": "workspace:*",
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
"@tanstack/react-query": "^4.0.10",
|
||||
"@tanstack/react-query": "^4.2.3",
|
||||
"@tanstack/react-query-devtools": "^4.0.10",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.18",
|
||||
"@types/styled-components": "^5.1.25",
|
||||
"@vitejs/plugin-react": "^2.0.0",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"byte-size": "^8.1.0",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^2.29.2",
|
||||
"immer": "^9.0.15",
|
||||
"jotai": "^1.7.6",
|
||||
"lodash": "^4.17.21",
|
||||
|
@ -67,6 +69,8 @@
|
|||
"tailwindcss": "^3.1.6",
|
||||
"use-count-up": "^3.0.1",
|
||||
"use-debounce": "^8.0.3",
|
||||
"valtio": "^1.7.0",
|
||||
"valtio-persist": "^1.0.2",
|
||||
"zod": "^3.18.0",
|
||||
"zustand": "4.0.0"
|
||||
},
|
||||
|
|
|
@ -1,50 +1,15 @@
|
|||
import '@fontsource/inter/variable.css';
|
||||
import {
|
||||
AppProps,
|
||||
AppPropsContext,
|
||||
queryClient,
|
||||
useBridgeQuery,
|
||||
useInvalidateQuery
|
||||
} from '@sd/client';
|
||||
import { LibraryContextProvider, queryClient } from '@sd/client';
|
||||
import { QueryClientProvider, defaultContext } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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';
|
||||
import './style.scss';
|
||||
|
||||
function RouterContainer(props: { props: AppProps }) {
|
||||
const [appProps, setAppProps] = useState(props.props);
|
||||
const { data: client } = useBridgeQuery(['getNode']);
|
||||
|
||||
useEffect(() => {
|
||||
setAppProps((appProps) => ({
|
||||
...appProps,
|
||||
data_path: client?.data_path
|
||||
}));
|
||||
}, [client?.data_path]);
|
||||
|
||||
return (
|
||||
<AppPropsContext.Provider value={Object.assign({ isFocused: true }, appProps)}>
|
||||
<MemoryRouter>
|
||||
<AppRouter />
|
||||
</MemoryRouter>
|
||||
</AppPropsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SpacedriveInterface(props: AppProps) {
|
||||
useInvalidateQuery();
|
||||
|
||||
// hotfix for bug where props are not updated, not sure of the cause
|
||||
if (props.platform === 'unknown') {
|
||||
// this should be a loading screen if we can't fix the issue above
|
||||
return <></>;
|
||||
}
|
||||
|
||||
export default function SpacedriveInterface() {
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<QueryClientProvider client={queryClient} contextSharing={true}>
|
||||
|
@ -52,8 +17,21 @@ export default function SpacedriveInterface(props: AppProps) {
|
|||
{import.meta.env.MODE === 'development' && (
|
||||
<ReactQueryDevtools position="bottom-right" context={defaultContext} />
|
||||
)}
|
||||
<RouterContainer props={props} />
|
||||
<MemoryRouter>
|
||||
<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,15 +1,18 @@
|
|||
import { useAppProps } from '@sd/client';
|
||||
import { useCurrentLibrary } from '@sd/client';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { Sidebar } from './components/layout/Sidebar';
|
||||
import { useOperatingSystem } from './hooks/useOperatingSystem';
|
||||
|
||||
export function AppLayout() {
|
||||
const appProps = useAppProps();
|
||||
const { libraries } = useCurrentLibrary();
|
||||
const os = useOperatingSystem();
|
||||
|
||||
const isWindowRounded = appProps?.platform === 'macOS';
|
||||
const hasWindowBorder = appProps?.platform !== 'browser' && appProps?.platform !== 'windows';
|
||||
// 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
|
||||
|
@ -20,8 +23,8 @@ export function AppLayout() {
|
|||
}}
|
||||
className={clsx(
|
||||
'flex flex-row h-screen overflow-hidden text-gray-900 select-none dark:text-white cursor-default',
|
||||
isWindowRounded && 'rounded-xl',
|
||||
hasWindowBorder && 'border border-gray-200 dark:border-gray-500'
|
||||
os === 'macOS' && 'rounded-xl',
|
||||
os !== 'browser' && os !== 'windows' && 'border border-gray-200 dark:border-gray-500'
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { useBridgeQuery, useLibraryStore } from '@sd/client';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useCurrentLibrary, useInvalidateQuery } from '@sd/client';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppLayout } from './AppLayout';
|
||||
import { NotFound } from './NotFound';
|
||||
import OnboardingScreen from './components/onboarding/Onboarding';
|
||||
import { useKeybindHandler } from './hooks/useKeyboardHandler';
|
||||
import { ContentScreen } from './screens/Content';
|
||||
import { DebugScreen } from './screens/Debug';
|
||||
import { LocationExplorer } from './screens/LocationExplorer';
|
||||
|
@ -32,45 +33,27 @@ import TagsSettings from './screens/settings/library/TagsSettings';
|
|||
import ExperimentalSettings from './screens/settings/node/ExperimentalSettings';
|
||||
import LibrarySettings from './screens/settings/node/LibrariesSettings';
|
||||
import P2PSettings from './screens/settings/node/P2PSettings';
|
||||
import { KeybindEvent } from './util/keybind';
|
||||
|
||||
export function AppRouter() {
|
||||
const location = useLocation();
|
||||
const state = location.state as { backgroundLocation?: Location };
|
||||
const libraryState = useLibraryStore();
|
||||
const navigate = useNavigate();
|
||||
const { data: libraries } = useBridgeQuery(['library.list']);
|
||||
const { library } = useCurrentLibrary();
|
||||
|
||||
// TODO: This can be removed once we add a setup flow to the app
|
||||
useEffect(() => {
|
||||
if (libraryState.currentLibraryUuid === null && libraries && libraries.length > 0) {
|
||||
libraryState.switchLibrary(libraries[0].uuid);
|
||||
}
|
||||
}, [libraryState, libraryState.currentLibraryUuid, libraries]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeybind = (e: KeybindEvent) => {
|
||||
if (e.detail.action === 'open_settings') {
|
||||
navigate('/settings');
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keybindexec', handleKeybind);
|
||||
return () => document.removeEventListener('keybindexec', handleKeybind);
|
||||
}, [navigate]);
|
||||
useKeybindHandler();
|
||||
useInvalidateQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
{libraryState.currentLibraryUuid === null ? (
|
||||
<>
|
||||
{/* TODO: Remove this when adding app setup flow */}
|
||||
<h1>No Library Loaded...</h1>
|
||||
</>
|
||||
) : (
|
||||
<Routes location={state?.backgroundLocation || location}>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Routes>
|
||||
<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 ? (
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<h1 className="text-white p-4">Please select or create a library in the sidebar.</h1>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Route index element={<RedirectPage to="/overview" />} />
|
||||
<Route path="overview" element={<OverviewScreen />} />
|
||||
<Route path="content" element={<ContentScreen />} />
|
||||
|
@ -105,9 +88,9 @@ export function AppRouter() {
|
|||
<Route path="location/:id" element={<LocationExplorer />} />
|
||||
<Route path="tag/:id" element={<TagExplorer />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import { rspc, usePlatform } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
import React from 'react';
|
||||
import { FallbackProps } from 'react-error-boundary';
|
||||
|
||||
import { guessOperatingSystem } from './hooks/useOperatingSystem';
|
||||
|
||||
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
const platform = usePlatform();
|
||||
const version = 'unknown'; // TODO: Embed the version into the frontend via ENV var when compiled so we can use it here.
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
|
@ -16,7 +21,21 @@ export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
|||
<Button variant="primary" className="mt-2" onClick={resetErrorBoundary}>
|
||||
Reload
|
||||
</Button>
|
||||
<Button variant="gray" className="mt-2" onClick={resetErrorBoundary}>
|
||||
<Button
|
||||
variant="gray"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
platform.openLink(
|
||||
`https://github.com/spacedriveapp/spacedrive/issues/new?assignees=&labels=kind%2Fbug%2Cstatus%2Fneeds-triage&template=bug_report.yml&logs=${encodeURIComponent(
|
||||
error.toString()
|
||||
)}&info=${encodeURIComponent(
|
||||
`App version ${version} running on ${guessOperatingSystem() || 'unknown'}`
|
||||
)}`
|
||||
);
|
||||
|
||||
resetErrorBoundary();
|
||||
}}
|
||||
>
|
||||
Send report
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { Button } from '@sd/ui';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export function NotFound() {
|
||||
|
|
|
@ -2,7 +2,7 @@ import { KeyIcon } from '@heroicons/react/24/outline';
|
|||
import { CogIcon, LockClosedIcon } from '@heroicons/react/24/solid';
|
||||
import { Button } from '@sd/ui';
|
||||
import { Cloud, Desktop, DeviceMobileCamera, DotsSixVertical, Laptop } from 'phosphor-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import FileItem from '../explorer/FileItem';
|
||||
import Loader from '../primitive/Loader';
|
||||
|
|
|
@ -1,22 +1,33 @@
|
|||
import { useBridgeMutation } from '@sd/client';
|
||||
import { Input } from '@sd/ui';
|
||||
import React, { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
|
||||
import Dialog from '../layout/Dialog';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function CreateLibraryDialog(props: Props) {
|
||||
export default function CreateLibraryDialog({
|
||||
children,
|
||||
onSubmit
|
||||
}: PropsWithChildren<{ onSubmit?: () => void }>) {
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [newLibName, setNewLibName] = useState('');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation(
|
||||
'library.create',
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (library) => {
|
||||
console.log('SUBMITTING');
|
||||
setOpenCreateModal(false);
|
||||
queryClient.setQueryData(['library.list'], (libraries: any) => [
|
||||
...(libraries || []),
|
||||
library
|
||||
]);
|
||||
|
||||
if (onSubmit) onSubmit();
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -31,7 +42,7 @@ export default function CreateLibraryDialog(props: Props) {
|
|||
loading={createLibLoading}
|
||||
submitDisabled={!newLibName}
|
||||
ctaLabel="Create"
|
||||
trigger={props.children}
|
||||
trigger={children}
|
||||
>
|
||||
<Input
|
||||
className="flex-grow w-full mt-3"
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { useBridgeMutation } from '@sd/client';
|
||||
import { LibraryConfigWrapped } from '@sd/core';
|
||||
import { Input } from '@sd/ui';
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Dialog from '../layout/Dialog';
|
||||
|
||||
|
|
|
@ -1,212 +1,45 @@
|
|||
import {
|
||||
rspc,
|
||||
useExplorerStore,
|
||||
useLibraryMutation,
|
||||
useLibraryQuery,
|
||||
useLibraryStore
|
||||
} from '@sd/client';
|
||||
import { getExplorerStore, rspc, useCurrentLibrary } from '@sd/client';
|
||||
import { ExplorerData } from '@sd/core';
|
||||
import {
|
||||
ArrowBendUpRight,
|
||||
LockSimple,
|
||||
Package,
|
||||
Plus,
|
||||
Share,
|
||||
TagSimple,
|
||||
Trash,
|
||||
TrashSimple
|
||||
} from 'phosphor-react';
|
||||
import React from 'react';
|
||||
|
||||
import { FileList } from '../explorer/FileList';
|
||||
import { Inspector } from '../explorer/Inspector';
|
||||
import { WithContextMenu } from '../layout/MenuOverlay';
|
||||
import { TopBar } from '../layout/TopBar';
|
||||
import ExplorerContextMenu from './ExplorerContextMenu';
|
||||
import { VirtualizedList } from './VirtualizedList';
|
||||
|
||||
interface Props {
|
||||
data: ExplorerData;
|
||||
}
|
||||
|
||||
export default function Explorer(props: Props) {
|
||||
const { selectedRowIndex, addNewThumbnail, contextMenuObjectId, showInspector } =
|
||||
useExplorerStore();
|
||||
const expStore = getExplorerStore();
|
||||
const { library } = useCurrentLibrary();
|
||||
|
||||
const { currentLibraryUuid } = useLibraryStore();
|
||||
|
||||
const { data: tags } = useLibraryQuery(['tags.list'], {});
|
||||
|
||||
const { mutate: assignTag } = useLibraryMutation('tags.assign');
|
||||
|
||||
const { data: tagsForFile } = useLibraryQuery(['tags.getForFile', contextMenuObjectId || -1]);
|
||||
|
||||
rspc.useSubscription(['jobs.newThumbnail', { library_id: currentLibraryUuid!, arg: null }], {
|
||||
rspc.useSubscription(['jobs.newThumbnail', { library_id: library!.uuid, arg: null }], {
|
||||
onNext: (cas_id) => {
|
||||
addNewThumbnail(cas_id);
|
||||
expStore.addNewThumbnail(cas_id);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<WithContextMenu
|
||||
menu={[
|
||||
[
|
||||
// `file-${props.identifier}`,
|
||||
{
|
||||
label: 'Open'
|
||||
},
|
||||
{
|
||||
label: 'Open with...'
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Quick view'
|
||||
},
|
||||
{
|
||||
label: 'Open in Finder'
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Rename'
|
||||
},
|
||||
{
|
||||
label: 'Duplicate'
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Share',
|
||||
icon: Share,
|
||||
onClick(e) {
|
||||
e.preventDefault();
|
||||
navigator.share?.({
|
||||
title: 'Spacedrive',
|
||||
text: 'Check out this cool app',
|
||||
url: 'https://spacedrive.com'
|
||||
});
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Assign tag',
|
||||
icon: TagSimple,
|
||||
children: [
|
||||
tags?.map((tag) => {
|
||||
const active = !!tagsForFile?.find((t) => t.id === tag.id);
|
||||
return {
|
||||
label: tag.name || '',
|
||||
|
||||
// leftItem: <Checkbox checked={!!tagsForFile?.find((t) => t.id === tag.id)} />,
|
||||
leftItem: (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="block w-[15px] h-[15px] mr-0.5 border rounded-full"
|
||||
style={{
|
||||
backgroundColor: active
|
||||
? tag.color || '#efefef'
|
||||
: 'transparent' || '#efefef',
|
||||
borderColor: tag.color || '#efefef'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
onClick(e) {
|
||||
e.preventDefault();
|
||||
if (contextMenuObjectId != null)
|
||||
assignTag({
|
||||
tag_id: tag.id,
|
||||
file_id: contextMenuObjectId,
|
||||
unassign: active
|
||||
});
|
||||
}
|
||||
};
|
||||
}) || []
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'More actions...',
|
||||
icon: Plus,
|
||||
|
||||
children: [
|
||||
// [
|
||||
// {
|
||||
// label: 'Move to library',
|
||||
// icon: FilePlus,
|
||||
// children: [libraries?.map((library) => ({ label: library.config.name })) || []]
|
||||
// },
|
||||
// {
|
||||
// label: 'Remove from library',
|
||||
// icon: FileX
|
||||
// }
|
||||
// ],
|
||||
[
|
||||
{
|
||||
label: 'Encrypt',
|
||||
icon: LockSimple
|
||||
},
|
||||
{
|
||||
label: 'Compress',
|
||||
icon: Package
|
||||
},
|
||||
{
|
||||
label: 'Convert to',
|
||||
icon: ArrowBendUpRight,
|
||||
|
||||
children: [
|
||||
[
|
||||
{
|
||||
label: 'PNG'
|
||||
},
|
||||
{
|
||||
label: 'WebP'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
// {
|
||||
// label: 'Mint NFT',
|
||||
// icon: TrashIcon
|
||||
// }
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Secure delete',
|
||||
icon: TrashSimple
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: Trash,
|
||||
danger: true
|
||||
}
|
||||
]
|
||||
]}
|
||||
>
|
||||
<ExplorerContextMenu>
|
||||
<div className="relative flex flex-col w-full bg-gray-650">
|
||||
<TopBar />
|
||||
<div className="relative flex flex-row w-full max-h-full">
|
||||
<FileList data={props.data?.items || []} context={props.data.context} />
|
||||
{showInspector && (
|
||||
<VirtualizedList data={props.data?.items || []} context={props.data.context} />
|
||||
{expStore.showInspector && (
|
||||
<div className="min-w-[260px] max-w-[260px]">
|
||||
{props.data.items[selectedRowIndex]?.id && (
|
||||
{props.data.items[expStore.selectedRowIndex]?.id && (
|
||||
<Inspector
|
||||
key={props.data.items[selectedRowIndex].id}
|
||||
data={props.data.items[selectedRowIndex]}
|
||||
key={props.data.items[expStore.selectedRowIndex].id}
|
||||
data={props.data.items[expStore.selectedRowIndex]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WithContextMenu>
|
||||
</ExplorerContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
import { useExplorerStore, useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { ContextMenu as CM } from '@sd/ui';
|
||||
import {
|
||||
ArrowBendUpRight,
|
||||
FilePlus,
|
||||
FileX,
|
||||
LockSimple,
|
||||
Package,
|
||||
Plus,
|
||||
Share,
|
||||
TagSimple,
|
||||
Trash,
|
||||
TrashSimple
|
||||
} from 'phosphor-react';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
const AssignTagMenuItems = (props: { objectId: number }) => {
|
||||
const tags = useLibraryQuery(['tags.list'], { suspense: true });
|
||||
const tagsForFile = useLibraryQuery(['tags.getForFile', props.objectId], { suspense: true });
|
||||
|
||||
const { mutate: assignTag } = useLibraryMutation('tags.assign');
|
||||
|
||||
return (
|
||||
<>
|
||||
{tags.data?.map((tag) => {
|
||||
const active = !!tagsForFile.data?.find((t) => t.id === tag.id);
|
||||
|
||||
return (
|
||||
<CM.Item
|
||||
key={tag.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.objectId === null) return;
|
||||
|
||||
assignTag({
|
||||
tag_id: tag.id,
|
||||
file_id: props.objectId,
|
||||
unassign: active
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="block w-[15px] h-[15px] mr-0.5 border rounded-full"
|
||||
style={{
|
||||
backgroundColor: active ? tag.color || '#efefef' : 'transparent' || '#efefef',
|
||||
borderColor: tag.color || '#efefef'
|
||||
}}
|
||||
/>
|
||||
<p>{tag.name}</p>
|
||||
</CM.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ExplorerContextMenu(props: Props) {
|
||||
const store = useExplorerStore();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CM.ContextMenu trigger={props.children}>
|
||||
<CM.Item label="Open" />
|
||||
<CM.Item label="Open with..." />
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
<CM.Item label="Quick view" />
|
||||
<CM.Item label="Open in Finder" />
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
<CM.Item label="Rename" />
|
||||
<CM.Item label="Duplicate" />
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
<CM.Item
|
||||
label="Share"
|
||||
icon={Share}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
navigator.share?.({
|
||||
title: 'Spacedrive',
|
||||
text: 'Check out this cool app',
|
||||
url: 'https://spacedrive.com'
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
{store.contextMenuObjectId && (
|
||||
<CM.SubMenu label="Assign tag" icon={TagSimple}>
|
||||
<AssignTagMenuItems objectId={store.contextMenuObjectId} />
|
||||
</CM.SubMenu>
|
||||
)}
|
||||
<CM.SubMenu label="More actions..." icon={Plus}>
|
||||
<CM.SubMenu label="Move to library" icon={FilePlus}>
|
||||
{/* {libraries.map(library => <CM.Item key={library.id} label={library.config.name} />)} */}
|
||||
<CM.Item label="Remove from library" icon={FileX} />
|
||||
</CM.SubMenu>
|
||||
<CM.Separator />
|
||||
<CM.Item label="Encrypt" icon={LockSimple} />
|
||||
<CM.Item label="Compress" icon={Package} />
|
||||
<CM.SubMenu label="Convert to" icon={ArrowBendUpRight}>
|
||||
<CM.Item label="PNG" />
|
||||
<CM.Item label="WebP" />
|
||||
</CM.SubMenu>
|
||||
<CM.Item label="Secure delete" icon={TrashSimple} />
|
||||
</CM.SubMenu>
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
<CM.Item icon={Trash} label="Delete" variant="danger" />
|
||||
</CM.ContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,31 +1,29 @@
|
|||
import { ReactComponent as Folder } from '@sd/assets/svgs/folder.svg';
|
||||
import { LocationContext, useExplorerStore } from '@sd/client';
|
||||
import { ExplorerData, ExplorerItem, File, FilePath } from '@sd/core';
|
||||
import { getExplorerStore, useExplorerStore } from '@sd/client';
|
||||
import { ExplorerItem } from '@sd/core';
|
||||
import clsx from 'clsx';
|
||||
import React, { useContext } from 'react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import icons from '../../assets/icons';
|
||||
import FileThumb from './FileThumb';
|
||||
import { isObject, isPath } from './utils';
|
||||
import { isObject } from './utils';
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
data: ExplorerItem;
|
||||
selected: boolean;
|
||||
size: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export default function FileItem(props: Props) {
|
||||
const { set } = useExplorerStore();
|
||||
const size = props.size || 100;
|
||||
function FileItem(props: Props) {
|
||||
const store = useExplorerStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
onContextMenu={(e) => {
|
||||
const objectId = isObject(props.data) ? props.data.id : props.data.file?.id;
|
||||
if (objectId != undefined) {
|
||||
set({ contextMenuObjectId: objectId });
|
||||
if (props.index != undefined) set({ selectedRowIndex: props.index });
|
||||
getExplorerStore().contextMenuObjectId = objectId;
|
||||
if (props.index != undefined) {
|
||||
getExplorerStore().selectedRowIndex = props.index;
|
||||
}
|
||||
}
|
||||
}}
|
||||
draggable
|
||||
|
@ -33,7 +31,7 @@ export default function FileItem(props: Props) {
|
|||
className={clsx('inline-block w-[100px] mb-3', props.className)}
|
||||
>
|
||||
<div
|
||||
style={{ width: size, height: size }}
|
||||
style={{ width: store.gridItemSize, height: store.gridItemSize }}
|
||||
className={clsx(
|
||||
'border-2 border-transparent rounded-lg text-center mb-1 active:translate-y-[1px]',
|
||||
{
|
||||
|
@ -51,7 +49,7 @@ export default function FileItem(props: Props) {
|
|||
'border-4 border-gray-250 rounded-sm shadow-md shadow-gray-750 max-h-full max-w-full overflow-hidden'
|
||||
)}
|
||||
data={props.data}
|
||||
size={100}
|
||||
size={store.gridItemSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -71,3 +69,5 @@ export default function FileItem(props: Props) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileItem;
|
||||
|
|
|
@ -1,255 +0,0 @@
|
|||
import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid';
|
||||
import { LocationContext, useBridgeQuery, useExplorerStore, useLibraryQuery } from '@sd/client';
|
||||
import { ExplorerContext, ExplorerItem, FilePath } from '@sd/core';
|
||||
import clsx from 'clsx';
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Virtuoso, VirtuosoGrid, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { useKey, useWindowSize } from 'rooks';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import FileItem from './FileItem';
|
||||
import FileThumb from './FileThumb';
|
||||
import { isPath } from './utils';
|
||||
|
||||
interface IColumn {
|
||||
column: string;
|
||||
key: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
// Function ensure no types are lost, but guarantees that they are Column[]
|
||||
function ensureIsColumns<T extends IColumn[]>(data: T) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const columns = ensureIsColumns([
|
||||
{ column: 'Name', key: 'name', width: 280 } as const,
|
||||
// { column: 'Size', key: 'size_in_bytes', width: 120 } as const,
|
||||
{ column: 'Type', key: 'extension', width: 100 } as const
|
||||
]);
|
||||
|
||||
type ColumnKey = typeof columns[number]['key'];
|
||||
|
||||
// these styled components are out of place, but are here to follow the virtuoso docs. could probably be translated to tailwind somehow, since the `components` prop only accepts a styled div, not a react component.
|
||||
const GridContainer = styled.div`
|
||||
display: flex;
|
||||
margin-top: 60px;
|
||||
margin-left: 10px;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
const GridItemContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
context: ExplorerContext;
|
||||
data: ExplorerItem[];
|
||||
}
|
||||
|
||||
export const FileList: React.FC<Props> = (props) => {
|
||||
const size = useWindowSize();
|
||||
const tableContainer = useRef<null | HTMLDivElement>(null);
|
||||
const VList = useRef<null | VirtuosoHandle>(null);
|
||||
|
||||
const { data: client } = useBridgeQuery(['getNode'], {
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
const { selectedRowIndex, set, layoutMode } = useExplorerStore();
|
||||
const [goingUp, setGoingUp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRowIndex === 0 && goingUp) {
|
||||
VList.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
if (selectedRowIndex !== -1 && typeof VList.current?.scrollIntoView === 'function') {
|
||||
VList.current?.scrollIntoView({
|
||||
index: goingUp ? selectedRowIndex - 1 : selectedRowIndex
|
||||
});
|
||||
}
|
||||
}, [goingUp, selectedRowIndex]);
|
||||
|
||||
useKey('ArrowUp', (e) => {
|
||||
e.preventDefault();
|
||||
setGoingUp(true);
|
||||
if (selectedRowIndex !== -1 && selectedRowIndex !== 0)
|
||||
set({ selectedRowIndex: selectedRowIndex - 1 });
|
||||
});
|
||||
|
||||
useKey('ArrowDown', (e) => {
|
||||
e.preventDefault();
|
||||
setGoingUp(false);
|
||||
if (selectedRowIndex !== -1 && selectedRowIndex !== (props.data.length ?? 1) - 1)
|
||||
set({ selectedRowIndex: selectedRowIndex + 1 });
|
||||
});
|
||||
|
||||
const createRenderItem = (RenderItem: React.FC<RenderItemProps>) => {
|
||||
return (index: number) => {
|
||||
const row = props.data[index];
|
||||
if (!row) return null;
|
||||
return <RenderItem key={index} index={index} item={row} />;
|
||||
};
|
||||
};
|
||||
|
||||
const Header = () => (
|
||||
<div>
|
||||
{props.context.name && (
|
||||
<h1 className="pt-20 pl-4 text-xl font-bold ">{props.context.name}</h1>
|
||||
)}
|
||||
<div className="table-head">
|
||||
<div className="flex flex-row p-2 table-head-row">
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="relative flex flex-row items-center pl-2 table-head-cell group"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
<EllipsisHorizontalIcon className="absolute hidden w-5 h-5 -ml-5 cursor-move group-hover:block drag-handle opacity-10" />
|
||||
<span className="text-sm font-medium text-gray-500">{col.column}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={tableContainer} style={{ marginTop: -44 }} className="w-full pl-2 cursor-default ">
|
||||
{layoutMode === 'grid' && (
|
||||
<VirtuosoGrid
|
||||
ref={VList}
|
||||
overscan={5000}
|
||||
components={{
|
||||
Item: GridItemContainer,
|
||||
List: GridContainer
|
||||
}}
|
||||
style={{ height: size.innerHeight ?? 600 }}
|
||||
totalCount={props.data.length || 0}
|
||||
itemContent={createRenderItem(RenderGridItem)}
|
||||
className="w-full overflow-x-hidden outline-none explorer-scroll"
|
||||
/>
|
||||
)}
|
||||
{layoutMode === 'list' && (
|
||||
<Virtuoso
|
||||
data={props.data} // this might be redundant, row data is retrieved by index in renderRow
|
||||
ref={VList}
|
||||
style={{ height: size.innerHeight ?? 600 }}
|
||||
totalCount={props.data.length || 0}
|
||||
itemContent={createRenderItem(RenderRow)}
|
||||
components={{
|
||||
Header,
|
||||
Footer: () => <div className="w-full " />
|
||||
}}
|
||||
increaseViewportBy={{ top: 400, bottom: 200 }}
|
||||
className="outline-none explorer-scroll"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RenderItemProps {
|
||||
item: ExplorerItem;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const RenderGridItem: React.FC<RenderItemProps> = ({ item, index }) => {
|
||||
const { selectedRowIndex, set } = useExplorerStore();
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
|
||||
return (
|
||||
<FileItem
|
||||
onDoubleClick={() => {
|
||||
if (item.type === 'Path' && item.is_dir) {
|
||||
setSearchParams({ path: item.materialized_path });
|
||||
}
|
||||
}}
|
||||
index={index}
|
||||
data={item}
|
||||
selected={selectedRowIndex === index}
|
||||
onClick={() => {
|
||||
set({ selectedRowIndex: selectedRowIndex == index ? -1 : index });
|
||||
}}
|
||||
size={100}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderRow: React.FC<RenderItemProps> = ({ item, index }) => {
|
||||
const { selectedRowIndex, set } = useExplorerStore();
|
||||
const isActive = selectedRowIndex === index;
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<div
|
||||
onClick={() => set({ selectedRowIndex: selectedRowIndex == index ? -1 : index })}
|
||||
onDoubleClick={() => {
|
||||
if (isPath(item) && item.is_dir) {
|
||||
setSearchParams({ path: item.materialized_path });
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
'table-body-row mr-2 flex flex-row rounded-lg border-2',
|
||||
isActive ? 'border-primary-500' : 'border-transparent',
|
||||
index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]'
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="flex items-center px-4 py-2 pr-2 table-body-cell"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
<RenderCell data={item} colKey={col.key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[item.id, isActive]
|
||||
);
|
||||
};
|
||||
|
||||
const RenderCell: React.FC<{
|
||||
colKey: ColumnKey;
|
||||
data: ExplorerItem;
|
||||
}> = ({ colKey, data }) => {
|
||||
switch (colKey) {
|
||||
case 'name':
|
||||
return (
|
||||
<div className="flex flex-row items-center overflow-hidden">
|
||||
<div className="flex items-center justify-center w-6 h-6 mr-3 shrink-0">
|
||||
<FileThumb data={data} size={0} />
|
||||
</div>
|
||||
{/* {colKey == 'name' &&
|
||||
(() => {
|
||||
switch (row.extension.toLowerCase()) {
|
||||
case 'mov' || 'mp4':
|
||||
return <FilmIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
|
||||
|
||||
default:
|
||||
if (row.is_dir)
|
||||
return <FolderIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
|
||||
return <DocumentIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
|
||||
}
|
||||
})()} */}
|
||||
<span className="text-xs truncate">{data[colKey]}</span>
|
||||
</div>
|
||||
);
|
||||
// case 'size_in_bytes':
|
||||
// return <span className="text-xs text-left">{byteSize(Number(value || 0))}</span>;
|
||||
case 'extension':
|
||||
return <span className="text-xs text-left">{data[colKey]}</span>;
|
||||
// case 'meta_integrity_hash':
|
||||
// return <span className="truncate">{value}</span>;
|
||||
// case 'tags':
|
||||
// return renderCellWithIcon(MusicNoteIcon);
|
||||
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
95
packages/interface/src/components/explorer/FileRow.tsx
Normal file
95
packages/interface/src/components/explorer/FileRow.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import { ExplorerItem } from '@sd/core';
|
||||
import clsx from 'clsx';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import FileThumb from './FileThumb';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
data: ExplorerItem;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
function FileRow({ data, index, selected, ...props }: Props) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={clsx(
|
||||
'table-body-row mr-2 flex w-full flex-row rounded-lg border-2',
|
||||
selected ? 'border-primary-500' : 'border-transparent',
|
||||
index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]'
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="flex items-center px-4 py-2 pr-2 table-body-cell"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
<RenderCell data={data} colKey={col.key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RenderCell: React.FC<{
|
||||
colKey: ColumnKey;
|
||||
data: ExplorerItem;
|
||||
}> = ({ colKey, data }) => {
|
||||
switch (colKey) {
|
||||
case 'name':
|
||||
return (
|
||||
<div className="flex flex-row items-center overflow-hidden">
|
||||
<div className="flex items-center justify-center w-6 h-6 mr-3 shrink-0">
|
||||
<FileThumb data={data} size={0} />
|
||||
</div>
|
||||
{/* {colKey == 'name' &&
|
||||
(() => {
|
||||
switch (row.extension.toLowerCase()) {
|
||||
case 'mov' || 'mp4':
|
||||
return <FilmIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
|
||||
|
||||
default:
|
||||
if (row.is_dir)
|
||||
return <FolderIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
|
||||
return <DocumentIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
|
||||
}
|
||||
})()} */}
|
||||
<span className="text-xs truncate">{data[colKey]}</span>
|
||||
</div>
|
||||
);
|
||||
// case 'size_in_bytes':
|
||||
// return <span className="text-xs text-left">{byteSize(Number(value || 0))}</span>;
|
||||
case 'extension':
|
||||
return <span className="text-xs text-left">{data[colKey]}</span>;
|
||||
// case 'meta_integrity_hash':
|
||||
// return <span className="truncate">{value}</span>;
|
||||
// case 'tags':
|
||||
// return renderCellWithIcon(MusicNoteIcon);
|
||||
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
interface IColumn {
|
||||
column: string;
|
||||
key: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
// Function ensure no types are lost, but guarantees that they are Column[]
|
||||
function ensureIsColumns<T extends IColumn[]>(data: T) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const columns = ensureIsColumns([
|
||||
{ column: 'Name', key: 'name', width: 280 } as const,
|
||||
// { column: 'Size', key: 'size_in_bytes', width: 120 } as const,
|
||||
{ column: 'Type', key: 'extension', width: 100 } as const
|
||||
]);
|
||||
|
||||
type ColumnKey = typeof columns[number]['key'];
|
||||
|
||||
export default FileRow;
|
|
@ -1,7 +1,7 @@
|
|||
import { AppPropsContext, useExplorerStore } from '@sd/client';
|
||||
import { useExplorerStore, usePlatform } from '@sd/client';
|
||||
import { ExplorerItem } from '@sd/core';
|
||||
import clsx from 'clsx';
|
||||
import React, { useContext } from 'react';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import icons from '../../assets/icons';
|
||||
import { Folder } from '../icons/Folder';
|
||||
|
@ -15,8 +15,8 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function FileThumb({ data, ...props }: Props) {
|
||||
const appProps = useContext(AppPropsContext);
|
||||
const { newThumbnails } = useExplorerStore();
|
||||
const platform = usePlatform();
|
||||
const store = useExplorerStore();
|
||||
|
||||
if (isPath(data) && data.is_dir) return <Folder size={props.size * 0.7} />;
|
||||
|
||||
|
@ -28,19 +28,15 @@ export default function FileThumb({ data, ...props }: Props) {
|
|||
? data.has_thumbnail
|
||||
: isPath(data)
|
||||
? data.file?.has_thumbnail
|
||||
: !!newThumbnails[cas_id];
|
||||
: !!store.newThumbnails[cas_id];
|
||||
|
||||
const file_thumb_url =
|
||||
has_thumbnail && appProps?.data_path
|
||||
? appProps?.convertFileSrc(`${appProps.data_path}/thumbnails/${cas_id}.webp`)
|
||||
: undefined;
|
||||
|
||||
if (file_thumb_url)
|
||||
if (has_thumbnail)
|
||||
return (
|
||||
<img
|
||||
// onLoad={}
|
||||
style={props.style}
|
||||
className={clsx('pointer-events-none z-90', props.className)}
|
||||
src={file_thumb_url}
|
||||
src={platform.getThumbnailUrlById(cas_id)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -83,6 +79,4 @@ export default function FileThumb({ data, ...props }: Props) {
|
|||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { ShareIcon } from '@heroicons/react/24/solid';
|
||||
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import { ExplorerContext, ExplorerItem, File, FilePath, Location } from '@sd/core';
|
||||
import { Button, TextArea } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import moment from 'moment';
|
||||
import { Heart, Link } from 'phosphor-react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'phosphor-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import types from '../../constants/file-types.json';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
|
@ -26,7 +26,19 @@ export const Inspector = (props: Props) => {
|
|||
|
||||
const objectData = isObject(props.data) ? props.data : props.data.file;
|
||||
|
||||
const { data: tags } = useLibraryQuery(['tags.getForFile', objectData?.id || -1]);
|
||||
// this prevents the inspector from fetching data when the user is navigating quickly
|
||||
const [readyToFetch, setReadyToFetch] = useState(false);
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setReadyToFetch(true);
|
||||
}, 350);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [props.data.id]);
|
||||
|
||||
// this is causing LAG
|
||||
const { data: tags } = useLibraryQuery(['tags.getForFile', objectData?.id || -1], {
|
||||
enabled: readyToFetch
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-2 pr-1 overflow-x-hidden custom-scroll inspector-scroll pb-[55px]">
|
||||
|
|
193
packages/interface/src/components/explorer/VirtualizedList.tsx
Normal file
193
packages/interface/src/components/explorer/VirtualizedList.tsx
Normal file
|
@ -0,0 +1,193 @@
|
|||
import { ExplorerLayoutMode, getExplorerStore, useExplorerStore } from '@sd/client';
|
||||
import { ExplorerContext, ExplorerItem, FilePath } from '@sd/core';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useKey, useOnWindowResize, useWindowSize } from 'rooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import FileItem from './FileItem';
|
||||
import FileRow from './FileRow';
|
||||
import { isPath } from './utils';
|
||||
|
||||
const TOP_BAR_HEIGHT = 50;
|
||||
const GRID_TEXT_AREA_HEIGHT = 25;
|
||||
|
||||
interface Props {
|
||||
context: ExplorerContext;
|
||||
data: ExplorerItem[];
|
||||
}
|
||||
|
||||
export const VirtualizedList: React.FC<Props> = ({ data, context }) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [goingUp, setGoingUp] = useState(false);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
const store = useExplorerStore();
|
||||
|
||||
function handleWindowResize() {
|
||||
// so the virtualizer can render the correct number of columns
|
||||
setWidth(innerRef.current?.offsetWidth || 0);
|
||||
}
|
||||
useOnWindowResize(handleWindowResize);
|
||||
useLayoutEffect(() => handleWindowResize(), []);
|
||||
|
||||
// sizing calculations
|
||||
const amountOfColumns = Math.floor(width / store.gridItemSize) || 8,
|
||||
amountOfRows =
|
||||
store.layoutMode === 'grid' ? Math.ceil(data.length / amountOfColumns) : data.length,
|
||||
itemSize =
|
||||
store.layoutMode === 'grid' ? store.gridItemSize + GRID_TEXT_AREA_HEIGHT : store.listItemSize;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: amountOfRows,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 500,
|
||||
estimateSize: () => itemSize,
|
||||
measureElement: (index) => itemSize
|
||||
});
|
||||
|
||||
// TODO: Make scroll adjustment work with both list and grid layout, currently top bar offset disrupts positioning of list, and grid just doesn't work
|
||||
// useEffect(() => {
|
||||
// if (selectedRowIndex === 0 && goingUp) rowVirtualizer.scrollToIndex(0, { smoothScroll: false });
|
||||
|
||||
// if (selectedRowIndex !== -1)
|
||||
// rowVirtualizer.scrollToIndex(goingUp ? selectedRowIndex - 1 : selectedRowIndex, {
|
||||
// smoothScroll: false
|
||||
// });
|
||||
// }, [goingUp, selectedRowIndex, rowVirtualizer]);
|
||||
|
||||
useKey('ArrowUp', (e) => {
|
||||
e.preventDefault();
|
||||
setGoingUp(true);
|
||||
if (store.selectedRowIndex !== -1 && store.selectedRowIndex !== 0)
|
||||
getExplorerStore().selectedRowIndex = store.selectedRowIndex - 1;
|
||||
});
|
||||
|
||||
useKey('ArrowDown', (e) => {
|
||||
e.preventDefault();
|
||||
setGoingUp(false);
|
||||
if (store.selectedRowIndex !== -1 && store.selectedRowIndex !== (data.length ?? 1) - 1)
|
||||
getExplorerStore().selectedRowIndex = store.selectedRowIndex + 1;
|
||||
});
|
||||
|
||||
// const Header = () => (
|
||||
// <div>
|
||||
// {props.context.name && (
|
||||
// <h1 className="pt-20 pl-4 text-xl font-bold ">{props.context.name}</h1>
|
||||
// )}
|
||||
// <div className="table-head">
|
||||
// <div className="flex flex-row p-2 table-head-row">
|
||||
// {columns.map((col) => (
|
||||
// <div
|
||||
// key={col.key}
|
||||
// className="relative flex flex-row items-center pl-2 table-head-cell group"
|
||||
// style={{ width: col.width }}
|
||||
// >
|
||||
// <EllipsisHorizontalIcon className="absolute hidden w-5 h-5 -ml-5 cursor-move group-hover:block drag-handle opacity-10" />
|
||||
// <span className="text-sm font-medium text-gray-500">{col.column}</span>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: -TOP_BAR_HEIGHT }} className="w-full pl-2 cursor-default">
|
||||
<div ref={scrollRef} className="h-screen custom-scroll explorer-scroll">
|
||||
<div
|
||||
ref={innerRef}
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
marginTop: `${TOP_BAR_HEIGHT}px`
|
||||
}}
|
||||
className="relative w-full"
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
className="absolute top-0 left-0 flex w-full"
|
||||
key={virtualRow.key}
|
||||
>
|
||||
{store.layoutMode === 'list' ? (
|
||||
<WrappedItem
|
||||
kind="list"
|
||||
isSelected={store.selectedRowIndex === virtualRow.index}
|
||||
index={virtualRow.index}
|
||||
item={data[virtualRow.index]}
|
||||
/>
|
||||
) : (
|
||||
[...Array(amountOfColumns)].map((_, i) => {
|
||||
const index = virtualRow.index * amountOfColumns + i;
|
||||
const item = data[index];
|
||||
return (
|
||||
<div key={index} className="w-32 h-32">
|
||||
<div className="flex">
|
||||
{item && (
|
||||
<WrappedItem
|
||||
kind="grid"
|
||||
isSelected={store.selectedRowIndex === index}
|
||||
index={index}
|
||||
item={item}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface WrappedItemProps {
|
||||
item: ExplorerItem;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
kind: ExplorerLayoutMode;
|
||||
}
|
||||
|
||||
// Wrap either list item or grid item with click logic as it is the same for both
|
||||
const WrappedItem: React.FC<WrappedItemProps> = memo(({ item, index, isSelected, kind }) => {
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
|
||||
const onDoubleClick = useCallback(() => {
|
||||
if (isPath(item) && item.is_dir) setSearchParams({ path: item.materialized_path });
|
||||
}, [item, setSearchParams]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
getExplorerStore().selectedRowIndex = isSelected ? -1 : index;
|
||||
}, [isSelected, index]);
|
||||
|
||||
if (kind === 'list') {
|
||||
return (
|
||||
<FileRow
|
||||
data={item}
|
||||
index={index}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
selected={isSelected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileItem
|
||||
data={item}
|
||||
index={index}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
selected={isSelected}
|
||||
/>
|
||||
);
|
||||
});
|
|
@ -1,3 +1 @@
|
|||
import React from 'react';
|
||||
|
||||
export const Divider = () => <div className="w-full my-1 h-[1px] bg-gray-100 dark:bg-gray-550" />;
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useLibraryMutation } from '@sd/client';
|
|||
import { File } from '@sd/core';
|
||||
import { Button } from '@sd/ui';
|
||||
import { Heart } from 'phosphor-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
data: File;
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import React from 'react';
|
||||
|
||||
interface MetaItemProps {
|
||||
title?: string;
|
||||
value: string | React.ReactNode;
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useLibraryMutation } from '@sd/client';
|
|||
import { File } from '@sd/core';
|
||||
import { TextArea } from '@sd/ui';
|
||||
import { debounce } from 'lodash';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Divider } from './Divider';
|
||||
import { MetaItem } from './MetaItem';
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import folderWhiteSvg from '@sd/assets/svgs/folder-white.svg';
|
||||
import folderSvg from '@sd/assets/svgs/folder.svg';
|
||||
import React from 'react';
|
||||
|
||||
interface FolderProps {
|
||||
/**
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
|
||||
|
|
101
packages/interface/src/components/jobs/JobManager.tsx
Normal file
101
packages/interface/src/components/jobs/JobManager.tsx
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { EyeIcon, FolderIcon, PhotoIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import { JobReport } from '@sd/core';
|
||||
import { Button } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import { formatDistanceToNow, formatDuration } from 'date-fns';
|
||||
import { ArrowsClockwise } from 'phosphor-react';
|
||||
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
|
||||
interface JobNiceData {
|
||||
name: string;
|
||||
icon: React.FC<React.ComponentProps<'svg'>>;
|
||||
}
|
||||
|
||||
const NiceData: Record<string, JobNiceData> = {
|
||||
indexer: {
|
||||
name: 'Indexed location',
|
||||
icon: FolderIcon
|
||||
},
|
||||
thumbnailer: {
|
||||
name: 'Generated thumbnails',
|
||||
icon: PhotoIcon
|
||||
},
|
||||
file_identifier: {
|
||||
name: 'Identified unique files',
|
||||
icon: EyeIcon
|
||||
}
|
||||
};
|
||||
|
||||
const StatusColors: Record<JobReport['status'], string> = {
|
||||
Running: 'text-blue-500',
|
||||
Failed: 'text-red-500',
|
||||
Completed: 'text-green-500',
|
||||
Queued: 'text-yellow-500',
|
||||
Canceled: 'text-gray-500',
|
||||
Paused: 'text-gray-500'
|
||||
};
|
||||
|
||||
function elapsed(seconds: number) {
|
||||
return new Date(seconds * 1000).toUTCString().match(/(\d\d:\d\d:\d\d)/)?.[0];
|
||||
}
|
||||
|
||||
export function JobsManager() {
|
||||
const jobs = useLibraryQuery(['jobs.getHistory']);
|
||||
return (
|
||||
<div className="h-full">
|
||||
{/* <div className="z-10 flex flex-row w-full h-10 bg-gray-500 border-b border-gray-700 bg-opacity-30"></div> */}
|
||||
<div className="h-full mr-1 overflow-x-hidden custom-scroll inspector-scroll">
|
||||
<div className="py-1 pl-2">
|
||||
<div className="fixed flex items-center h-10 ">
|
||||
<h3 className="mt-1.5 ml-2 text-md font-medium opacity-40">Recent Jobs</h3>
|
||||
</div>
|
||||
<div className="h-10"></div>
|
||||
{jobs.data?.map((job) => {
|
||||
const color = StatusColors[job.status];
|
||||
const niceData = NiceData[job.name];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center px-2 py-2 border-b border-gray-500 bg-opacity-60"
|
||||
key={job.id}
|
||||
>
|
||||
<Tooltip label={job.status}>
|
||||
<niceData.icon className={clsx('w-5 mr-3', color)} />
|
||||
</Tooltip>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex mt-0.5 items-center font-semibold">{niceData.name}</span>
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs opacity-60">
|
||||
{job.status === 'Failed' ? 'Failed after' : 'Took'}{' '}
|
||||
{job.seconds_elapsed
|
||||
? formatDuration({ seconds: job.seconds_elapsed })
|
||||
: 'less than a second'}
|
||||
</span>
|
||||
<span className="mx-1 opacity-30">•</span>
|
||||
<span className="text-xs opacity-60">
|
||||
{formatDistanceToNow(new Date(job.date_created))} ago
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs opacity-60">{job.data}</span>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
<div className="flex space-x-2">
|
||||
{job.status === 'Failed' && (
|
||||
<Button className="!p-0 w-7 h-7 flex items-center" variant="gray">
|
||||
<ArrowsClockwise className="w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button className="!p-0 w-7 h-7 flex items-center" variant="gray">
|
||||
<XMarkIcon className="w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { Transition } from '@headlessui/react';
|
||||
import { useLibraryQuery } from '@sd/client';
|
||||
import clsx from 'clsx';
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from 'react';
|
||||
import { DetailedHTMLProps, HTMLAttributes } from 'react';
|
||||
|
||||
import ProgressBar from '../primitive/ProgressBar';
|
||||
|
||||
|
@ -19,7 +19,7 @@ const MiddleTruncatedText = ({
|
|||
const endWidth = fontFaceScaleFactor * 4;
|
||||
|
||||
return (
|
||||
<div className="whitespace-nowrap overflow-hidden w-full">
|
||||
<div className="w-full overflow-hidden whitespace-nowrap">
|
||||
<span
|
||||
{...props}
|
||||
style={{
|
||||
|
@ -66,7 +66,10 @@ export default function RunningJobsWidget() {
|
|||
leaveFrom="translate-y-0"
|
||||
leaveTo="translate-y-24"
|
||||
>
|
||||
<div key={job.id} className="flex flex-col px-2 pt-1.5 pb-2 bg-gray-700 rounded">
|
||||
<div
|
||||
key={job.id}
|
||||
className="flex flex-col px-2 pt-1.5 pb-2 border bg-gray-600 bg-opacity-50 border-gray-500 rounded"
|
||||
>
|
||||
{/* <span className="mb-0.5 text-tiny font-bold text-gray-400">{job.status} Job</span> */}
|
||||
<MiddleTruncatedText className="mb-1.5 text-gray-450 text-tiny">
|
||||
{job.message}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from 'clsx';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function Card(props: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Button } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import Loader from '../primitive/Loader';
|
||||
|
||||
|
@ -24,34 +24,41 @@ export default function Dialog(props: DialogProps) {
|
|||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed top-0 dialog-overlay bottom-0 left-0 right-0 z-50 grid overflow-y-auto bg-black bg-opacity-50 rounded-xl place-items-center m-[1px]">
|
||||
<DialogPrimitive.Content className="min-w-[300px] max-w-[400px] dialog-content rounded-md bg-gray-650 text-white border border-gray-550 shadow-deep">
|
||||
<div className="p-5">
|
||||
<DialogPrimitive.Title className="mb-2 font-bold">
|
||||
{props.title}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="text-sm text-gray-300">
|
||||
{props.description}
|
||||
</DialogPrimitive.Description>
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="flex flex-row justify-end px-3 py-3 space-x-2 bg-gray-600 border-t border-gray-550">
|
||||
{props.loading && <Loader />}
|
||||
<div className="flex-grow" />
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button loading={props.loading} disabled={props.loading} size="sm" variant="gray">
|
||||
Close
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
if (props.ctaAction) props.ctaAction();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="p-5">
|
||||
<DialogPrimitive.Title className="mb-2 font-bold">
|
||||
{props.title}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="text-sm text-gray-300">
|
||||
{props.description}
|
||||
</DialogPrimitive.Description>
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="flex flex-row justify-end px-3 py-3 space-x-2 bg-gray-600 border-t border-gray-550">
|
||||
{props.loading && <Loader />}
|
||||
<div className="flex-grow" />
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button loading={props.loading} disabled={props.loading} size="sm" variant="gray">
|
||||
Close
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
loading={props.loading}
|
||||
disabled={props.loading || props.submitDisabled}
|
||||
variant={props.ctaDanger ? 'colored' : 'primary'}
|
||||
className={clsx(props.ctaDanger && 'bg-red-500 border-red-500')}
|
||||
>
|
||||
{props.ctaLabel}
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
<Button
|
||||
onClick={props.ctaAction}
|
||||
size="sm"
|
||||
loading={props.loading}
|
||||
disabled={props.loading || props.submitDisabled}
|
||||
variant={props.ctaDanger ? 'colored' : 'primary'}
|
||||
className={clsx(props.ctaDanger && 'bg-red-500 border-red-500')}
|
||||
>
|
||||
{props.ctaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Overlay>
|
||||
</DialogPrimitive.Portal>
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import { ContextMenu, ContextMenuProps, Root, Trigger } from '@sd/ui';
|
||||
import React, { ComponentProps } from 'react';
|
||||
|
||||
export const WithContextMenu: React.FC<{
|
||||
menu: ContextMenuProps['items'];
|
||||
children: ComponentProps<typeof Trigger>['children'];
|
||||
}> = (props) => {
|
||||
const { menu: sections = [], children } = props;
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<Trigger>{children}</Trigger>
|
||||
|
||||
<ContextMenu items={sections} />
|
||||
</Root>
|
||||
);
|
||||
};
|
|
@ -2,7 +2,6 @@ import { Transition } from '@headlessui/react';
|
|||
import { XMarkIcon } from '@heroicons/react/24/solid';
|
||||
import { Button } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export interface ModalProps {
|
||||
|
|
|
@ -1,25 +1,18 @@
|
|||
import CreateLibraryDialog from '../dialog/CreateLibraryDialog';
|
||||
import { Folder } from '../icons/Folder';
|
||||
import RunningJobsWidget from '../jobs/RunningJobsWidget';
|
||||
import { MacTrafficLights } from '../os/TrafficLights';
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
import { LockClosedIcon, PhotoIcon } from '@heroicons/react/24/outline';
|
||||
import { CogIcon, PlusIcon } from '@heroicons/react/24/solid';
|
||||
import {
|
||||
AppPropsContext,
|
||||
useCurrentLibrary,
|
||||
useLibraryMutation,
|
||||
useLibraryQuery,
|
||||
useLibraryStore
|
||||
} from '@sd/client';
|
||||
import { CogIcon, LockClosedIcon, PhotoIcon } from '@heroicons/react/24/outline';
|
||||
import { PlusIcon } from '@heroicons/react/24/solid';
|
||||
import { useCurrentLibrary, useLibraryMutation, useLibraryQuery, usePlatform } from '@sd/client';
|
||||
import { LocationCreateArgs } from '@sd/core';
|
||||
import { Button, Dropdown } from '@sd/ui';
|
||||
import { Button, Dropdown, OverlayPanel } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import { CirclesFour, Planet, WaveTriangle } from 'phosphor-react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { CheckCircle, CirclesFour, Planet, WaveTriangle } from 'phosphor-react';
|
||||
import { NavLink, NavLinkProps, useNavigate } from 'react-router-dom';
|
||||
|
||||
type SidebarProps = DefaultProps;
|
||||
import { useOperatingSystem } from '../../hooks/useOperatingSystem';
|
||||
import CreateLibraryDialog from '../dialog/CreateLibraryDialog';
|
||||
import { Folder } from '../icons/Folder';
|
||||
import { JobsManager } from '../jobs/JobManager';
|
||||
import RunningJobsWidget from '../jobs/RunningJobsWidget';
|
||||
import { MacTrafficLights } from '../os/TrafficLights';
|
||||
|
||||
export const SidebarLink = (props: NavLinkProps & { children: React.ReactNode }) => (
|
||||
<NavLink {...props}>
|
||||
|
@ -47,145 +40,40 @@ const Heading: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|||
<div className="mt-5 mb-1 ml-1 text-xs font-semibold text-gray-300">{children}</div>
|
||||
);
|
||||
|
||||
export const MacWindowControlsSpace: React.FC<{
|
||||
children?: React.ReactNode;
|
||||
}> = (props) => {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex-shrink-0 h-7">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function MacWindowControls() {
|
||||
const appProps = useContext(AppPropsContext);
|
||||
|
||||
return (
|
||||
<MacWindowControlsSpace>
|
||||
<MacTrafficLights
|
||||
onClose={appProps?.onClose}
|
||||
onFullscreen={appProps?.onFullscreen}
|
||||
onMinimize={appProps?.onMinimize}
|
||||
className="z-50 absolute top-[13px] left-[13px]"
|
||||
/>
|
||||
</MacWindowControlsSpace>
|
||||
);
|
||||
}
|
||||
|
||||
// cute little helper to decrease code clutter
|
||||
const macOnly = (platform: string | undefined, classnames: string) =>
|
||||
platform === 'macOS' ? classnames : '';
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const appProps = useContext(AppPropsContext);
|
||||
const { data: locations } = useLibraryQuery(['locations.list']);
|
||||
function WindowControls() {
|
||||
const { platform } = usePlatform();
|
||||
|
||||
// initialize libraries
|
||||
const { init: initLibraries, switchLibrary } = useLibraryStore();
|
||||
const { currentLibrary, libraries, currentLibraryUuid } = useCurrentLibrary();
|
||||
const showControls = window.location.search.includes('showControls');
|
||||
if (platform === 'tauri' || showControls) {
|
||||
return (
|
||||
<div data-tauri-drag-region className="flex-shrink-0 h-7">
|
||||
{/* We do not provide the onClick handlers for 'MacTrafficLights' because this is only used in demo mode */}
|
||||
{showControls && <MacTrafficLights className="z-50 absolute top-[13px] left-[13px]" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (libraries && !currentLibraryUuid) initLibraries(libraries);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [libraries, currentLibraryUuid]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function LibraryScopedSection() {
|
||||
const os = useOperatingSystem();
|
||||
const platform = usePlatform();
|
||||
const { data: locations } = useLibraryQuery(['locations.list'], { keepPreviousData: true });
|
||||
const { data: tags } = useLibraryQuery(['tags.list'], { keepPreviousData: true });
|
||||
const { mutate: createLocation } = useLibraryMutation('locations.create');
|
||||
|
||||
const { data: tags } = useLibraryQuery(['tags.list']);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col flex-grow-0 flex-shrink-0 w-48 min-h-full px-2.5 overflow-x-hidden overflow-y-scroll border-r border-gray-100 no-scrollbar bg-gray-50 dark:bg-gray-850 dark:border-gray-750',
|
||||
{
|
||||
'dark:!bg-opacity-40': appProps?.platform === 'macOS'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{appProps?.platform === 'browser' && window.location.search.includes('showControls') ? (
|
||||
<MacWindowControls />
|
||||
) : null}
|
||||
{appProps?.platform === 'macOS' ? <MacWindowControlsSpace /> : null}
|
||||
|
||||
<Dropdown
|
||||
buttonProps={{
|
||||
justifyLeft: true,
|
||||
className: clsx(
|
||||
`flex w-full text-left max-w-full mb-1 mt-1 -mr-0.5 shadow-xs rounded
|
||||
!bg-gray-50
|
||||
border-gray-150
|
||||
hover:!bg-gray-1000
|
||||
|
||||
dark:!bg-gray-500
|
||||
dark:hover:!bg-gray-500
|
||||
|
||||
dark:!border-gray-550
|
||||
dark:hover:!border-gray-500
|
||||
`,
|
||||
appProps?.platform === 'macOS' &&
|
||||
'dark:!bg-opacity-40 dark:hover:!bg-opacity-70 dark:!border-[#333949] dark:hover:!border-[#394052]'
|
||||
),
|
||||
variant: 'gray'
|
||||
}}
|
||||
// to support the transparent sidebar on macOS we use slightly adjusted styles
|
||||
itemsClassName={macOnly(appProps?.platform, 'dark:bg-gray-800 dark:divide-gray-600')}
|
||||
itemButtonClassName={macOnly(
|
||||
appProps?.platform,
|
||||
'dark:hover:bg-gray-550 dark:hover:bg-opacity-50'
|
||||
)}
|
||||
// this shouldn't default to "My Library", it is only this way for landing demo
|
||||
// TODO: implement demo mode for the sidebar and show loading indicator instead of "My Library"
|
||||
buttonText={currentLibrary?.config.name || ' '}
|
||||
items={[
|
||||
libraries?.map((library) => ({
|
||||
name: library.config.name,
|
||||
selected: library.uuid === currentLibraryUuid,
|
||||
onPress: () => switchLibrary(library.uuid)
|
||||
})) || [],
|
||||
[
|
||||
{
|
||||
name: 'Library Settings',
|
||||
icon: CogIcon,
|
||||
onPress: () => navigate('settings/library')
|
||||
},
|
||||
{
|
||||
name: 'Add Library',
|
||||
icon: PlusIcon,
|
||||
wrapItemComponent: CreateLibraryDialog
|
||||
},
|
||||
{
|
||||
name: 'Lock',
|
||||
icon: LockClosedIcon,
|
||||
onPress: () => {
|
||||
alert('todo');
|
||||
}
|
||||
}
|
||||
]
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="pt-1">
|
||||
<SidebarLink to="/overview">
|
||||
<Icon component={Planet} />
|
||||
Overview
|
||||
</SidebarLink>
|
||||
<SidebarLink to="content">
|
||||
<Icon component={CirclesFour} />
|
||||
Spaces
|
||||
</SidebarLink>
|
||||
<SidebarLink to="photos">
|
||||
<Icon component={PhotoIcon} />
|
||||
Photos
|
||||
</SidebarLink>
|
||||
</div>
|
||||
<>
|
||||
<div>
|
||||
<Heading>Locations</Heading>
|
||||
{locations?.map((location, index) => {
|
||||
{locations?.map((location) => {
|
||||
return (
|
||||
<div key={index} className="flex flex-row items-center">
|
||||
<div key={location.id} className="flex flex-row items-center">
|
||||
<NavLink
|
||||
className="relative w-full group"
|
||||
to={{
|
||||
|
@ -217,8 +105,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||
{(locations?.length || 0) < 1 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
appProps?.openDialog({ directory: true }).then((result) => {
|
||||
console.log(result);
|
||||
if (!platform.openFilePickerDialog) {
|
||||
// TODO: Support opening locations on web
|
||||
alert('Opening a dialogue is not supported on this platform!');
|
||||
return;
|
||||
}
|
||||
|
||||
platform.openFilePickerDialog().then((result) => {
|
||||
// TODO: Pass indexer rules ids to create location
|
||||
if (result)
|
||||
createLocation({
|
||||
|
@ -229,7 +122,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||
}}
|
||||
className={clsx(
|
||||
'w-full px-2 py-1.5 mt-1 text-xs font-bold text-center text-gray-400 border border-dashed rounded border-transparent cursor-normal border-gray-350 transition',
|
||||
appProps?.platform === 'macOS'
|
||||
os === 'macOS'
|
||||
? 'dark:text-gray-450 dark:border-gray-450 hover:dark:border-gray-400 dark:border-opacity-60'
|
||||
: 'dark:text-gray-450 dark:border-gray-550 hover:dark:border-gray-500'
|
||||
)}
|
||||
|
@ -256,21 +149,122 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const os = useOperatingSystem();
|
||||
const { library, libraries, isLoading: isLoadingLibraries, switchLibrary } = useCurrentLibrary();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col flex-grow-0 flex-shrink-0 w-48 min-h-full px-2.5 overflow-x-hidden overflow-y-scroll border-r border-gray-100 no-scrollbar bg-gray-50 dark:bg-gray-850 dark:border-gray-750',
|
||||
macOnly(os, 'dark:!bg-opacity-40')
|
||||
)}
|
||||
>
|
||||
<WindowControls />
|
||||
|
||||
<Dropdown
|
||||
buttonProps={{
|
||||
justifyLeft: true,
|
||||
className: clsx(
|
||||
`flex w-full text-left max-w-full mb-1 mt-1 -mr-0.5 shadow-xs rounded !bg-gray-50 border-gray-150 hover:!bg-gray-1000 dark:!bg-gray-500 dark:hover:!bg-gray-500 dark:!border-gray-550 dark:hover:!border-gray-500`,
|
||||
macOnly(
|
||||
os,
|
||||
'dark:!bg-opacity-40 dark:hover:!bg-opacity-70 dark:!border-[#333949] dark:hover:!border-[#394052]'
|
||||
)
|
||||
),
|
||||
variant: 'gray'
|
||||
}}
|
||||
// to support the transparent sidebar on macOS we use slightly adjusted styles
|
||||
itemsClassName={macOnly(os, 'dark:bg-gray-800 dark:divide-gray-600')}
|
||||
itemButtonClassName={macOnly(os, 'dark:hover:bg-gray-550 dark:hover:bg-opacity-50')}
|
||||
// this shouldn't default to "My Library", it is only this way for landing demo
|
||||
buttonText={isLoadingLibraries ? 'Loading...' : library ? library.config.name : ' '}
|
||||
buttonTextClassName={library === null || isLoadingLibraries ? 'text-gray-300' : undefined}
|
||||
items={[
|
||||
libraries?.map((library) => ({
|
||||
name: library.config.name,
|
||||
selected: library.uuid === library?.uuid,
|
||||
onPress: () => switchLibrary(library.uuid)
|
||||
})) || [],
|
||||
[
|
||||
{
|
||||
name: 'Library Settings',
|
||||
icon: CogIcon,
|
||||
onPress: () => navigate('settings/library')
|
||||
},
|
||||
{
|
||||
name: 'Add Library',
|
||||
icon: PlusIcon,
|
||||
wrapItemComponent: CreateLibraryDialog
|
||||
},
|
||||
{
|
||||
name: 'Lock',
|
||||
icon: LockClosedIcon,
|
||||
disabled: true,
|
||||
onPress: () => {
|
||||
alert('TODO: Not implemented yet!');
|
||||
}
|
||||
}
|
||||
]
|
||||
]}
|
||||
/>
|
||||
<div className="pt-1">
|
||||
<SidebarLink to="/overview">
|
||||
<Icon component={Planet} />
|
||||
Overview
|
||||
</SidebarLink>
|
||||
<SidebarLink to="content">
|
||||
<Icon component={CirclesFour} />
|
||||
Spaces
|
||||
</SidebarLink>
|
||||
<SidebarLink to="photos">
|
||||
<Icon component={PhotoIcon} />
|
||||
Photos
|
||||
</SidebarLink>
|
||||
</div>
|
||||
|
||||
{library && <LibraryScopedSection />}
|
||||
|
||||
<div className="flex-grow" />
|
||||
<RunningJobsWidget />
|
||||
<div className="mb-2">
|
||||
|
||||
{library && <RunningJobsWidget />}
|
||||
|
||||
<div className="mt-2 mb-2">
|
||||
<NavLink to="/settings/general">
|
||||
{({ isActive }) => (
|
||||
<Button
|
||||
noPadding
|
||||
variant={isActive ? 'default' : 'default'}
|
||||
className={clsx('px-[4px] mb-1')}
|
||||
variant={'default'}
|
||||
className={clsx('px-[4px] hover:!bg-opacity-20 mb-1')}
|
||||
>
|
||||
<CogIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</NavLink>
|
||||
<OverlayPanel
|
||||
className="focus:outline-none"
|
||||
disabled={!library}
|
||||
trigger={
|
||||
<Button
|
||||
noPadding
|
||||
className={clsx(
|
||||
'px-[4px] !outline-none hover:!bg-opacity-20 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="block w-[500px] h-96">
|
||||
<JobsManager />
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { AppProps, useAppProps, useExplorerStore, useLibraryMutation } from '@sd/client';
|
||||
import {
|
||||
OperatingSystem,
|
||||
getExplorerStore,
|
||||
useExplorerStore,
|
||||
useLibraryMutation
|
||||
} from '@sd/client';
|
||||
import { Dropdown } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
|
@ -11,22 +16,16 @@ import {
|
|||
SidebarSimple,
|
||||
SquaresFour
|
||||
} from 'phosphor-react';
|
||||
import React, { DetailedHTMLProps, HTMLAttributes } from 'react';
|
||||
import { DetailedHTMLProps, HTMLAttributes, forwardRef, useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useOperatingSystem } from '../../hooks/useOperatingSystem';
|
||||
import { KeybindEvent } from '../../util/keybind';
|
||||
import { Shortcut } from '../primitive/Shortcut';
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
|
||||
// useful for determining whether to show command or ctrl in keybinds
|
||||
const isPlatformMac = (appProps: AppProps | null) =>
|
||||
// running on macOS desktop app
|
||||
appProps?.platform === 'macOS' ||
|
||||
// running in browser on macOS
|
||||
navigator.platform.startsWith('Mac');
|
||||
|
||||
export type TopBarProps = DefaultProps;
|
||||
export interface TopBarButtonProps
|
||||
extends DetailedHTMLProps<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> {
|
||||
|
@ -65,9 +64,7 @@ const TopBarButton: React.FC<TopBarButtonProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const SearchBar = React.forwardRef<HTMLInputElement, DefaultProps>((props, forwardedRef) => {
|
||||
const appProps = useAppProps();
|
||||
|
||||
const SearchBar = forwardRef<HTMLInputElement, DefaultProps>((props, forwardedRef) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
|
@ -82,6 +79,9 @@ const SearchBar = React.forwardRef<HTMLInputElement, DefaultProps>((props, forwa
|
|||
}
|
||||
});
|
||||
|
||||
const platform = useOperatingSystem(false);
|
||||
const os = useOperatingSystem(true);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(() => null)} className="relative flex h-7">
|
||||
<input
|
||||
|
@ -95,15 +95,16 @@ const SearchBar = React.forwardRef<HTMLInputElement, DefaultProps>((props, forwa
|
|||
className="peer w-32 h-[30px] focus:w-52 text-sm p-3 rounded-lg outline-none focus:ring-2 placeholder-gray-400 dark:placeholder-gray-450 bg-[#F6F2F6] border border-gray-50 shadow-md dark:bg-gray-600 dark:border-gray-550 focus:ring-gray-100 dark:focus:ring-gray-550 dark:focus:bg-gray-800 transition-all"
|
||||
{...searchField}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'space-x-1 absolute top-[2px] right-1 peer-focus:invisible pointer-events-none',
|
||||
isDirty && 'hidden'
|
||||
)}
|
||||
>
|
||||
{appProps?.platform === 'browser' ? (
|
||||
{platform === 'browser' ? (
|
||||
<Shortcut chars="/" aria-label={'Press slash to focus search bar'} />
|
||||
) : isPlatformMac(appProps) ? (
|
||||
) : os === 'macOS' ? (
|
||||
<Shortcut chars="⌘F" aria-label={'Press Command-F to focus search bar'} />
|
||||
) : (
|
||||
<Shortcut chars="CTRL+F" aria-label={'Press CTRL-F to focus search bar'} />
|
||||
|
@ -115,21 +116,22 @@ const SearchBar = React.forwardRef<HTMLInputElement, DefaultProps>((props, forwa
|
|||
});
|
||||
|
||||
export const TopBar: React.FC<TopBarProps> = (props) => {
|
||||
const appProps = useAppProps();
|
||||
const platform = useOperatingSystem(false);
|
||||
const os = useOperatingSystem(true);
|
||||
|
||||
const { layoutMode, set, locationId, showInspector } = useExplorerStore();
|
||||
const store = useExplorerStore();
|
||||
const { mutate: generateThumbsForLocation } = useLibraryMutation(
|
||||
'jobs.generateThumbsForLocation',
|
||||
{
|
||||
onMutate: (data) => {
|
||||
console.log('GenerateThumbsForLocation', data);
|
||||
// console.log('GenerateThumbsForLocation', data);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const { mutate: identifyUniqueFiles } = useLibraryMutation('jobs.identifyUniqueFiles', {
|
||||
onMutate: (data) => {
|
||||
console.log('IdentifyUniqueFiles', data);
|
||||
// console.log('IdentifyUniqueFiles', data);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('IdentifyUniqueFiles', error);
|
||||
|
@ -138,7 +140,8 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const searchBarRef = React.useRef<HTMLInputElement>(null);
|
||||
//create function to focus on search box when cmd+k is pressed
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const focusSearchBar = (bar: HTMLInputElement, e?: Event): boolean => {
|
||||
bar.focus();
|
||||
|
@ -147,8 +150,8 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
return false;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const searchBar = searchBarRef.current;
|
||||
useEffect(() => {
|
||||
const searchBar = searchRef.current;
|
||||
|
||||
if (searchBar === null || !searchBar) return;
|
||||
|
||||
|
@ -165,9 +168,9 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const isBrowser = appProps?.platform === 'browser';
|
||||
const isBrowser = platform === 'browser';
|
||||
// use cmd on macOS and ctrl on Windows
|
||||
const hasModifier = isBrowser && isPlatformMac(appProps) ? e.metaKey : e.ctrlKey;
|
||||
const hasModifier = os === 'macOS' ? e.metaKey : e.ctrlKey;
|
||||
|
||||
if (
|
||||
// allow slash on all platforms
|
||||
|
@ -192,7 +195,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
document.removeEventListener('keydown', handleDOMKeydown);
|
||||
document.removeEventListener('keybindexec', handleKeybindAction);
|
||||
};
|
||||
}, [appProps]);
|
||||
}, [os, platform]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -221,22 +224,23 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
<TopBarButton
|
||||
group
|
||||
left
|
||||
active={layoutMode === 'list'}
|
||||
active={store.layoutMode === 'list'}
|
||||
icon={Rows}
|
||||
onClick={() => set({ layoutMode: 'list' })}
|
||||
onClick={() => (getExplorerStore().layoutMode = 'list')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Grid view">
|
||||
<TopBarButton
|
||||
group
|
||||
right
|
||||
active={layoutMode === 'grid'}
|
||||
active={store.layoutMode === 'grid'}
|
||||
icon={SquaresFour}
|
||||
onClick={() => set({ layoutMode: 'grid' })}
|
||||
onClick={() => (getExplorerStore().layoutMode = 'grid')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SearchBar ref={searchBarRef} />
|
||||
|
||||
<SearchBar ref={searchRef} />
|
||||
|
||||
<div className="flex mx-8 space-x-2">
|
||||
<Tooltip label="Major Key Alert">
|
||||
|
@ -257,8 +261,8 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
</div>
|
||||
<div className="flex mr-3 space-x-2">
|
||||
<TopBarButton
|
||||
active={showInspector}
|
||||
onClick={() => set({ showInspector: !showInspector })}
|
||||
active={store.showInspector}
|
||||
onClick={() => (getExplorerStore().showInspector = !store.showInspector)}
|
||||
className="my-2"
|
||||
icon={SidebarSimple}
|
||||
/>
|
||||
|
@ -271,12 +275,14 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
|||
name: 'Generate Thumbs',
|
||||
icon: ArrowsClockwise,
|
||||
onPress: () =>
|
||||
locationId && generateThumbsForLocation({ id: locationId, path: '' })
|
||||
store.locationId &&
|
||||
generateThumbsForLocation({ id: store.locationId, path: '' })
|
||||
},
|
||||
{
|
||||
name: 'Identify Unique',
|
||||
icon: ArrowsClockwise,
|
||||
onPress: () => locationId && identifyUniqueFiles({ id: locationId, path: '' })
|
||||
onPress: () =>
|
||||
store.locationId && identifyUniqueFiles({ id: store.locationId, path: '' })
|
||||
}
|
||||
]
|
||||
]}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue