diff --git a/Cargo.lock b/Cargo.lock index c6e2f875b..a49844386 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8e1f7cf5f..72dc6a031 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@tauri-apps/cli": "1.1.1", + "@tauri-apps/tauricon": "github:tauri-apps/tauricon", "@types/babel-core": "^6.25.7", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", diff --git a/apps/mobile/pnpm-lock.yaml b/apps/mobile/pnpm-lock.yaml new file mode 100644 index 000000000..f635ed63a Binary files /dev/null and b/apps/mobile/pnpm-lock.yaml differ diff --git a/core/Cargo.toml b/core/Cargo.toml index be783ebfa..be7fcef3a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -29,7 +29,7 @@ serde = { version = "1.0", features = ["derive"] } chrono = { version = "0.4.22", features = ["serde"] } serde_json = "1.0" futures = "0.3" -int-enum = "0.4.0" +int-enum = "0.5.0" rmp = "^0.8.11" rmp-serde = "^1.1.1" blake3 = "1.3.1" diff --git a/core/src/object/identifier_job.rs b/core/src/object/identifier_job.rs index d7c5ff478..03e8c2a6a 100644 --- a/core/src/object/identifier_job.rs +++ b/core/src/object/identifier_job.rs @@ -3,9 +3,10 @@ use crate::{ library::LibraryContext, prisma::{file_path, location, object}, }; - use chrono::{DateTime, FixedOffset}; +use int_enum::IntEnum; use prisma_client_rust::{prisma_models::PrismaValue, raw::Raw, Direction}; +use sd_file_ext::{extensions::Extension, kind::ObjectKind}; use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, @@ -131,8 +132,8 @@ impl StatefulJob for FileIdentifierJob { ) -> Result<(), JobError> { let db = ctx.library_ctx().db; - // link file_path ids to a CreateFile struct containing unique file data - let mut chunk: HashMap = HashMap::new(); + // link file_path ids to a CreateObject struct containing unique file data + let mut chunk: HashMap = HashMap::new(); let mut cas_lookup: HashMap = HashMap::new(); let data = state @@ -222,6 +223,7 @@ impl StatefulJob for FileIdentifierJob { PrismaValue::String(object.cas_id.clone()), PrismaValue::Int(object.size_in_bytes), PrismaValue::DateTime(object.date_created), + PrismaValue::Int(object.kind.int_value() as i64), ]); } @@ -230,9 +232,9 @@ impl StatefulJob for FileIdentifierJob { let created_files: Vec = db ._query_raw(Raw::new( &format!( - "INSERT INTO object (cas_id, size_in_bytes, date_created) VALUES {} + "INSERT INTO object (cas_id, size_in_bytes, date_created, kind) VALUES {} ON CONFLICT (cas_id) DO NOTHING RETURNING id, cas_id", - vec!["({}, {}, {})"; new_objects.len()].join(",") + vec!["({}, {}, {}, {})"; new_objects.len()].join(",") ), values, )) @@ -360,10 +362,11 @@ async fn get_orphan_file_paths( } #[derive(Deserialize, Serialize, Debug)] -struct CreateFile { +struct CreateObject { pub cas_id: String, pub size_in_bytes: i64, pub date_created: DateTime, + pub kind: ObjectKind, } #[derive(Deserialize, Serialize, Debug)] @@ -375,7 +378,7 @@ struct FileCreated { async fn assemble_object_metadata( location_path: impl AsRef, file_path: &file_path::Data, -) -> Result { +) -> Result { let path = location_path .as_ref() .join(file_path.materialized_path.as_str()); @@ -384,7 +387,19 @@ async fn assemble_object_metadata( let metadata = fs::metadata(&path).await?; - // let date_created: DateTime = metadata.created().unwrap().into(); + // derive Object kind + let object_kind: ObjectKind = match path.extension() { + Some(ext) => match ext.to_str() { + Some(ext) => { + let mut file = std::fs::File::open(&path).unwrap(); + let resolved_ext = Extension::resolve_conflicting(ext, &mut file, true); + + resolved_ext.map(Into::into).unwrap_or(ObjectKind::Unknown) + } + None => ObjectKind::Unknown, + }, + None => ObjectKind::Unknown, + }; let size = metadata.len(); @@ -398,9 +413,10 @@ async fn assemble_object_metadata( } }; - Ok(CreateFile { + Ok(CreateObject { cas_id, size_in_bytes: size as i64, date_created: file_path.date_created, + kind: object_kind, }) } diff --git a/crates/file-ext/src/kind.rs b/crates/file-ext/src/kind.rs index 4e330fdbf..01cb542b6 100644 --- a/crates/file-ext/src/kind.rs +++ b/crates/file-ext/src/kind.rs @@ -3,8 +3,8 @@ use int_enum::IntEnum; use serde::{Deserialize, Serialize}; -#[derive(Debug, Serialize, Deserialize, Clone, Copy, IntEnum)] -#[repr(u8)] +#[repr(i32)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, IntEnum)] pub enum ObjectKind { // A file that can not be identified by the indexer Unknown = 0, diff --git a/crates/sync/docs/HLC.md b/crates/sync/docs/HLC.md new file mode 100644 index 000000000..f4a947511 --- /dev/null +++ b/crates/sync/docs/HLC.md @@ -0,0 +1,73 @@ +```rust +pub fn update_with_timestamp(&self, timestamp: &Timestamp) -> Result<(), String> { + let mut now = (self.clock)(); + now.0 &= LMASK; + let msg_time = timestamp.get_time(); + if *msg_time > now && *msg_time - now > self.delta { + let err_msg = format!( + "incoming timestamp from {} exceeding delta {}ms is rejected: {} vs. now: {}", + timestamp.get_id(), + self.delta.to_duration().as_millis(), + msg_time, + now + ); + warn!("{}", err_msg); + Err(err_msg) + } else { + let mut last_time = lock!(self.last_time); + let max_time = cmp::max(cmp::max(now, *msg_time), *last_time); + if max_time == now { + *last_time = now; + } else if max_time == *msg_time { + *last_time = *msg_time + 1; + } else { + *last_time += 1; + } + Ok(()) + } +} +``` + +```javascript +Timestamp.recv = function (msg) { + if (!clock) { + return null; + } + + var now = Date.now(); + + var msg_time = msg.millis(); + var msg_time = msg.counter(); + + if (msg_time - now > config.maxDrift) { + throw new Timestamp.ClockDriftError(); + } + + var last_time = clock.timestamp.millis(); + var last_time = clock.timestamp.counter(); + + var max_time = Math.max(Math.max(last_time, now), msg_time); + + var last_time = + max_time === last_time && lNew === msg_time + ? Math.max(last_time, msg_time) + 1 + : max_time === last_time + ? last_time + 1 + : max_time === msg_time + ? msg_time + 1 + : 0; + + // 3. + if (max_time - phys > config.maxDrift) { + throw new Timestamp.ClockDriftError(); + } + if (last_time > MAX_COUNTER) { + throw new Timestamp.OverflowError(); + } + + clock.timestamp.setMillis(max_time); + clock.timestamp.setCounter(last_time); + + return new Timestamp(clock.timestamp.millis(), clock.timestamp.counter(), clock.timestamp.node()); +}; +``` diff --git a/docs/developers/architecture/sync.md b/docs/developers/architecture/sync.md index 38fb25500..50e05ee41 100644 --- a/docs/developers/architecture/sync.md +++ b/docs/developers/architecture/sync.md @@ -4,85 +4,15 @@ index: 12 # Sync -Spacedrive synchronizes data using a combination of master-slave replication and last-write-wins CRDTs, -with the synchronization method encoded into the Prisma schema using [record type attributes](#record-types). +Spacedrive synchronizes library data in realtime across the distributed network of Nodes. -In the cases where LWW CRDTs are used, -conflicts are resolved using a [Hybrid Logical Clock](https://github.com/atolab/uhlc-rs) -to determine the ordering of events. +Using a Unique Hybrid Logical Clock for distributed time synchronization. -We would be remiss to not credit credit [Actual Budget](https://actualbudget.com/) -with many of the CRDT concepts used in Spacedrive's sync system. +A combination of several property level CRDT types: -## Record Types +- **Local data** - migrations, statistics, sync events +- **Owned data** - locations, paths, volumes +- **Shared data** - objects, tags, spaces, jobs +- **Relationship data** - many to many tables -All data in a library conforms to one of the following types. -Each type uses a different strategy for syncing. - -### Local Records - -Local records exist entirely outside of the sync system. -They don't have Sync IDs and never leave the node they were created on. - -Used for Nodes, Statistics, and Sync Events. - -`@local` - -### Owned Records - -Owned records are only ever modified by the node they are created by, -so they can be synced in a master-slave fashion. -The creator of an owned record dictates the state of the record to other nodes, -who will simply accept new changes without considering conflicts. - -File paths are owned records since they only exist on one node, -and that node can inform all other nodes about the correct state of the paths. - -Used for Locations, Paths, and Volumes. - -`@owned(owner: String, id?: String)` -- `owner` - Field that identifies the owner of this model. - If a scalar, will directly use that value in sync operations. - If a relation, the Sync ID of the related model will be resolved for sync operations. -- `id` - Scalar field to override the default Sync ID. - -### Shared Records - -Shared records encompass most data synced in the CRDT fashion. -Updates are applied per-field using a last-write-wins strategy. - -Used for Objects, Tags, Spaces, and Jobs. - -`@shared(create: SharedCreateType, id?: String)` -- `id` - Scalar field to override the default Sync ID. -- `create` - How the model should be created. - - `Unique` (default): Model can be created with many required arguemnts, - but ID provided _must_ be unique across all nodes. - Useful for Tags since their IDs are non-deterministic. - - `Atomic`: Require the model to have no required arguments apart from ID and apply all create arguments as atomic updates. - Necessary for models with the same ID that can be created on multiple nodes. - Useful for Objects since their ID is dependent on their content, - and could be the same across nodes. - -### Relation Records - -Similar to shared records, but represent a many-to-many relation between two records. -Sync ID is the combination of `item` and `group` Sync IDs. - -Used for TagOnFile and FileInSpace. - -`@relation(item: String, group: String)` -- `item` - Field that identifies the item that the relation is connecting. - Similar to the `owner` argument of `@owned`. -- `group` - Field that identifies the group that the item should be connected to. - Similar to the `owner` argument of `@owned`. - - -## Other Prisma Attributes - -`@node` - -Indicates that a relation field should be set to the current node. -This could be done manually, -but `@node` allows `node_id` fields to be resolved from the `node_id` field of a `CRDTOperation`, -saving on bandwidth +Built in Rust on top of Prisma, it uses the schema file to determine these sync rules. diff --git a/packages/client/src/stores/explorerStore.ts b/packages/client/src/stores/explorerStore.ts index c37a1a5a8..13b0ef404 100644 --- a/packages/client/src/stores/explorerStore.ts +++ b/packages/client/src/stores/explorerStore.ts @@ -16,6 +16,7 @@ const state = { gridItemSize: 100, listItemSize: 40, selectedRowIndex: 1, + tagAssignMode: false, showInspector: true, multiSelectIndexes: [] as number[], contextMenuObjectId: null as number | null, diff --git a/packages/interface/package.json b/packages/interface/package.json index 1500c3741..7557c9cac 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -46,6 +46,7 @@ "react-router": "6.4.2", "react-router-dom": "6.4.2", "rooks": "^5.14.0", + "tailwind-styled-components": "2.1.7", "tailwindcss": "^3.1.8", "use-count-up": "^3.0.1", "use-debounce": "^8.0.4", diff --git a/packages/interface/src/AppLayout.tsx b/packages/interface/src/AppLayout.tsx index 6e21274c7..b8a571070 100644 --- a/packages/interface/src/AppLayout.tsx +++ b/packages/interface/src/AppLayout.tsx @@ -21,7 +21,7 @@ export function AppLayout() { className={clsx( // App level styles 'flex flex-row overflow-hidden text-ink select-none cursor-default', - os === 'macOS' && 'rounded-xl', + os === 'macOS' && 'rounded-xl has-blur-effects', os !== 'browser' && os !== 'windows' && 'border border-app-divider' )} onContextMenu={(e) => { diff --git a/packages/interface/src/components/explorer/Explorer.tsx b/packages/interface/src/components/explorer/Explorer.tsx index fc4d29f70..bf8a811b5 100644 --- a/packages/interface/src/components/explorer/Explorer.tsx +++ b/packages/interface/src/components/explorer/Explorer.tsx @@ -1,4 +1,5 @@ import { ExplorerData, rspc, useCurrentLibrary, useExplorerStore } from '@sd/client'; +import { useEffect, useState } from 'react'; import { Inspector } from '../explorer/Inspector'; import ExplorerContextMenu from './ExplorerContextMenu'; @@ -13,6 +14,18 @@ export default function Explorer(props: Props) { const expStore = useExplorerStore(); const { library } = useCurrentLibrary(); + const [scrollSegments, setScrollSegments] = useState<{ [key: string]: number }>({}); + const [separateTopBar, setSeparateTopBar] = useState(false); + + useEffect(() => { + setSeparateTopBar((oldValue) => { + const newValue = Object.values(scrollSegments).some((val) => val >= 5); + + if (newValue !== oldValue) return newValue; + return oldValue; + }); + }, [scrollSegments]); + rspc.useSubscription(['jobs.newThumbnail', { library_id: library!.uuid, arg: null }], { onData: (cas_id) => { expStore.addNewThumbnail(cas_id); @@ -23,15 +36,36 @@ export default function Explorer(props: Props) {
- + -
+
{props.data && ( - + { + setScrollSegments((old) => { + return { + ...old, + mainList: y + }; + }); + }} + /> )} {expStore.showInspector && (
{ + const y = (e.target as HTMLElement).scrollTop; + + setScrollSegments((old) => { + return { + ...old, + inspector: y + }; + }); + }} key={props.data?.items[expStore.selectedRowIndex]?.id} data={props.data?.items[expStore.selectedRowIndex]} /> diff --git a/packages/interface/src/components/explorer/ExplorerContextMenu.tsx b/packages/interface/src/components/explorer/ExplorerContextMenu.tsx index 5d5c8f260..dbb3432fd 100644 --- a/packages/interface/src/components/explorer/ExplorerContextMenu.tsx +++ b/packages/interface/src/components/explorer/ExplorerContextMenu.tsx @@ -1,14 +1,7 @@ -import { - getExplorerStore, - useExplorerStore, - useLibraryMutation, - useLibraryQuery -} from '@sd/client'; +import { getExplorerStore, useLibraryMutation, useLibraryQuery } from '@sd/client'; import { ContextMenu as CM } from '@sd/ui'; import { ArrowBendUpRight, - FilePlus, - FileX, LockSimple, Package, Plus, @@ -18,7 +11,6 @@ import { TrashSimple } from 'phosphor-react'; import { PropsWithChildren } from 'react'; -import { useSnapshot } from 'valtio'; const AssignTagMenuItems = (props: { objectId: number }) => { const tags = useLibraryQuery(['tags.list'], { suspense: true }); @@ -28,12 +20,13 @@ const AssignTagMenuItems = (props: { objectId: number }) => { return ( <> - {tags.data?.map((tag) => { + {tags.data?.map((tag, index) => { const active = !!tagsForObject.data?.find((t) => t.id === tag.id); return ( { e.preventDefault(); if (props.objectId === null) return; @@ -66,18 +59,18 @@ export default function ExplorerContextMenu(props: PropsWithChildren) { return (
- + - - + + - + @@ -103,8 +96,8 @@ export default function ExplorerContextMenu(props: PropsWithChildren) { )} - - + + @@ -114,7 +107,7 @@ export default function ExplorerContextMenu(props: PropsWithChildren) { - +
); diff --git a/packages/interface/src/components/explorer/ExplorerTopBar.tsx b/packages/interface/src/components/explorer/ExplorerTopBar.tsx index 6abbced7a..bb9d51160 100644 --- a/packages/interface/src/components/explorer/ExplorerTopBar.tsx +++ b/packages/interface/src/components/explorer/ExplorerTopBar.tsx @@ -1,4 +1,5 @@ import { ChevronLeftIcon, ChevronRightIcon, TagIcon } from '@heroicons/react/24/outline'; +import { KeyIcon as KeyIconSolid, TagIcon as TagIconSolid } from '@heroicons/react/24/solid'; import { OperatingSystem, getExplorerStore, @@ -55,7 +56,7 @@ const TopBarButton = forwardRef( 'rounded-r-none rounded-l-none': group && !left && !right, 'rounded-r-none': group && left, 'rounded-l-none': group && right, - 'dark:bg-gray-500': active + 'dark:bg-gray-550': active }, className )} @@ -94,7 +95,7 @@ const SearchBar = forwardRef((props, forwardedRe else if (forwardedRef) forwardedRef.current = el; }} placeholder="Search" - 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" + 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 dark:shadow-gray-900/30 dark:bg-gray-600/70 dark:border-gray-550 focus:ring-gray-100 dark:focus:ring-gray-550 dark:focus:bg-gray-800 transition-all" {...searchField} /> @@ -117,7 +118,9 @@ const SearchBar = forwardRef((props, forwardedRe ); }); -export type TopBarProps = DefaultProps; +export type TopBarProps = DefaultProps & { + showSeparator?: boolean; +}; export const TopBar: React.FC = (props) => { const platform = useOperatingSystem(false); @@ -211,9 +214,16 @@ export const TopBar: React.FC = (props) => { <>
-
+
navigate(-1)} /> @@ -275,7 +285,11 @@ export const TopBar: React.FC = (props) => {
- + (getExplorerStore().tagAssignMode = !store.tagAssignMode)} + active={store.tagAssignMode} + icon={store.tagAssignMode ? TagIconSolid : TagIcon} + /> diff --git a/packages/interface/src/components/explorer/FileItem.tsx b/packages/interface/src/components/explorer/FileItem.tsx index a9822ab81..b6372c02a 100644 --- a/packages/interface/src/components/explorer/FileItem.tsx +++ b/packages/interface/src/components/explorer/FileItem.tsx @@ -40,7 +40,7 @@ function FileItem({ data, selected, index, ...rest }: Props) { className={clsx( 'border-2 border-transparent rounded-lg text-center mb-1 active:translate-y-[1px]', { - 'bg-gray-50 dark:bg-gray-750': selected + 'bg-gray-50 dark:bg-gray-750/50': selected } )} > @@ -51,7 +51,7 @@ function FileItem({ data, selected, index, ...rest }: Props) { > { context?: ExplorerContext; data?: ExplorerItem; } export const Inspector = (props: Props) => { + const { context, data, ...elementProps } = props; + const { data: types } = useQuery( ['_file-types'], () => import('../../constants/file-types.json') @@ -49,7 +52,10 @@ export const Inspector = (props: Props) => { const isVid = isVideo(props.data?.extension || ''); return ( -
+
{!!props.data && ( <>
diff --git a/packages/interface/src/components/explorer/VirtualizedList.tsx b/packages/interface/src/components/explorer/VirtualizedList.tsx index a424fa871..8407ec291 100644 --- a/packages/interface/src/components/explorer/VirtualizedList.tsx +++ b/packages/interface/src/components/explorer/VirtualizedList.tsx @@ -1,7 +1,7 @@ import { ExplorerLayoutMode, getExplorerStore, useExplorerStore } from '@sd/client'; import { ExplorerContext, ExplorerItem } from '@sd/client'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { UIEventHandler, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useKey, useOnWindowResize } from 'rooks'; @@ -15,9 +15,10 @@ const GRID_TEXT_AREA_HEIGHT = 25; interface Props { context: ExplorerContext; data: ExplorerItem[]; + onScroll?: (posY: number) => void; } -export const VirtualizedList: React.FC = ({ data, context }) => { +export const VirtualizedList: React.FC = ({ data, context, onScroll }) => { const scrollRef = useRef(null); const innerRef = useRef(null); @@ -45,6 +46,19 @@ export const VirtualizedList: React.FC = ({ data, context }) => { ? explorerStore.gridItemSize + GRID_TEXT_AREA_HEIGHT : explorerStore.listItemSize; + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + + const onElementScroll = (event: Event) => { + onScroll?.((event.target as HTMLElement).scrollTop); + }; + + el.addEventListener('scroll', onElementScroll); + + return () => el.removeEventListener('scroll', onElementScroll); + }, [scrollRef, onScroll]); + const rowVirtualizer = useVirtualizer({ count: amountOfRows, getScrollElement: () => scrollRef.current, @@ -109,7 +123,7 @@ export const VirtualizedList: React.FC = ({ data, context }) => { ref={innerRef} style={{ height: `${rowVirtualizer.getTotalSize()}px`, - marginTop: `${TOP_BAR_HEIGHT}px` + marginTop: `${TOP_BAR_HEIGHT + 10}px` }} className="relative w-full" > diff --git a/packages/interface/src/components/jobs/JobManager.tsx b/packages/interface/src/components/jobs/JobManager.tsx index 7ca2977b8..b240409bf 100644 --- a/packages/interface/src/components/jobs/JobManager.tsx +++ b/packages/interface/src/components/jobs/JobManager.tsx @@ -1,7 +1,14 @@ -import { EyeIcon, FolderIcon, PhotoIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { + EyeIcon, + FingerPrintIcon, + FolderIcon, + PhotoIcon, + XMarkIcon +} from '@heroicons/react/24/solid'; +import { QuestionMarkCircleIcon } from '@heroicons/react/24/solid'; import { useLibraryQuery } from '@sd/client'; import { JobReport } from '@sd/client'; -import { Button } from '@sd/ui'; +import { Button, CategoryHeading } from '@sd/ui'; import clsx from 'clsx'; import dayjs from 'dayjs'; import { ArrowsClockwise } from 'phosphor-react'; @@ -13,20 +20,26 @@ interface JobNiceData { icon: React.FC>; } -const NiceData: Record = { +const getNiceData = (job: JobReport): Record => ({ indexer: { - name: 'Indexed location', + name: `Indexed ${numberWithCommas(job.metadata?.data?.total_paths || 0)} paths at "${ + job.metadata?.data?.location_path || '?' + }"`, icon: FolderIcon }, thumbnailer: { - name: 'Generated thumbnails', + name: `Generated ${numberWithCommas(job.task_count)} thumbnails`, icon: PhotoIcon }, file_identifier: { - name: 'Identified unique files', + name: `Extracted metadata for ${numberWithCommas(job.task_count)} files`, icon: EyeIcon + }, + object_validator: { + name: `Generated ${numberWithCommas(job.task_count)} full object hashes`, + icon: FingerPrintIcon } -}; +}); const StatusColors: Record = { Running: 'text-blue-500', @@ -44,55 +57,66 @@ function elapsed(seconds: number) { export function JobsManager() { const jobs = useLibraryQuery(['jobs.getHistory']); return ( -
+
{/*
*/} +
+ Recent Jobs +
-
-
-

Recent Jobs

-
-
- {jobs.data?.map((job) => { - const color = StatusColors[job.status]; - const niceData = NiceData[job.name]; +
+
+ {jobs.data?.map((job) => { + // const color = StatusColors[job.status]; + const niceData = getNiceData(job)[job.name] || { + name: job.name, + icon: QuestionMarkCircleIcon + }; - return ( -
- - - -
- {niceData.name} -
- - {job.status === 'Failed' ? 'Failed after' : 'Took'}{' '} - {job.seconds_elapsed - ? dayjs.duration({ seconds: job.seconds_elapsed }).humanize() - : 'less than a second'} + return ( +
+ + + +
+ + {niceData.name} - - {dayjs(job.date_created).toNow(true)} ago +
+ + {job.status === 'Failed' ? 'Failed after' : 'Took'}{' '} + {job.seconds_elapsed + ? dayjs.duration({ seconds: job.seconds_elapsed }).humanize() + : 'less than a second'} + + + {dayjs(job.date_created).toNow(true)} ago +
+ {job.data}
- {job.data} -
-
- {job.status === 'Failed' && ( - + )} + - )} - +
-
- ); - })} + ); + })} +
); } + +function numberWithCommas(x: number) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} diff --git a/packages/interface/src/screens/Debug.tsx b/packages/interface/src/screens/Debug.tsx index cb1c33dea..b43e4ce97 100644 --- a/packages/interface/src/screens/Debug.tsx +++ b/packages/interface/src/screens/Debug.tsx @@ -16,7 +16,7 @@ export default function DebugScreen() { // }); const { mutate: identifyFiles } = useLibraryMutation('jobs.identifyUniqueFiles'); return ( -
+

Developer Debugger

diff --git a/packages/interface/src/screens/Overview.tsx b/packages/interface/src/screens/Overview.tsx index 00c3416be..48ddb216b 100644 --- a/packages/interface/src/screens/Overview.tsx +++ b/packages/interface/src/screens/Overview.tsx @@ -139,7 +139,7 @@ export default function OverviewScreen() { console.log(overviewStats); return ( -
+
{/* PAGE */} diff --git a/packages/interface/src/screens/Photos.tsx b/packages/interface/src/screens/Photos.tsx index 264677b64..164e682f1 100644 --- a/packages/interface/src/screens/Photos.tsx +++ b/packages/interface/src/screens/Photos.tsx @@ -1,6 +1,6 @@ export default function PhotosScreen() { return ( -
+

Note: This is a pre-alpha build of Spacedrive, many features are yet to be diff --git a/packages/interface/src/screens/settings/Settings.tsx b/packages/interface/src/screens/settings/Settings.tsx index e860b208c..85d79c299 100644 --- a/packages/interface/src/screens/settings/Settings.tsx +++ b/packages/interface/src/screens/settings/Settings.tsx @@ -5,7 +5,7 @@ import { SettingsSidebar } from '../../components/settings/SettingsSidebar'; export default function SettingsScreen() { return ( -

+
diff --git a/packages/interface/src/screens/settings/client/GeneralSettings.tsx b/packages/interface/src/screens/settings/client/GeneralSettings.tsx index 3f6ccee06..10eb8df31 100644 --- a/packages/interface/src/screens/settings/client/GeneralSettings.tsx +++ b/packages/interface/src/screens/settings/client/GeneralSettings.tsx @@ -1,12 +1,16 @@ import { useBridgeQuery, usePlatform } from '@sd/client'; import { Input } from '@sd/ui'; import { Database } from 'phosphor-react'; +import tw from 'tailwind-styled-components'; import Card from '../../../components/layout/Card'; import { Toggle } from '../../../components/primitive'; import { SettingsContainer } from '../../../components/settings/SettingsContainer'; import { SettingsHeader } from '../../../components/settings/SettingsHeader'; +const NodePill = tw.div`px-1.5 py-[2px] rounded text-xs font-medium bg-gray-500`; +const NodeSettingLabel = tw.div`mb-1 text-xs font-medium text-gray-700 dark:text-gray-100`; + export default function GeneralSettings() { const { data: node } = useBridgeQuery(['getNode']); @@ -20,55 +24,43 @@ export default function GeneralSettings() { />
-
+
Connected Node -
-
- 0 Peers - - Running - +
+ 0 Peers + Running

-
+
- - Node Name - + Node Name
-
- - Node Port - +
+ Node Port
-
- - Node ID - - -
Run daemon when app closed
- { if (node && platform?.openLink) { platform.openLink(node.data_path); } }} - className="text-xs font-medium text-gray-700 dark:text-gray-400" + className="text-xs font-medium leading-relaxed text-gray-700 dark:text-gray-400" > - - Data Folder + + Data Folder + {node?.data_path} - +
diff --git a/packages/interface/src/style.scss b/packages/interface/src/style.scss index 6b5527033..2bde52fa2 100644 --- a/packages/interface/src/style.scss +++ b/packages/interface/src/style.scss @@ -7,6 +7,16 @@ body { font-family: 'InterVariable', sans-serif; } +.app-bg { + @apply bg-app; +} + +.has-blur-effects { + .app-bg { + @apply bg-app/90; + } +} + .no-scrollbar { -ms-overflow-style: none; /* for Internet Explorer, Edge */ scrollbar-width: none; /* for Firefox */ diff --git a/packages/ui/package.json b/packages/ui/package.json index cd0337b09..500d4da22 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -38,8 +38,6 @@ "tailwindcss-radix": "^2.6.0" }, "devDependencies": { - "tailwindcss": "^3.1.8", - "storybook": "^6.5.12", "@babel/core": "^7.19.3", "@sd/config": "workspace:*", "@storybook/addon-actions": "^6.5.12", @@ -62,8 +60,11 @@ "postcss-loader": "^7.0.1", "sass": "^1.55.0", "sass-loader": "^13.0.2", + "storybook": "^6.5.12", "storybook-tailwind-dark-mode": "^1.0.15", "style-loader": "^3.3.1", + "tailwind-styled-components": "2.1.7", + "tailwindcss": "^3.1.8", "typescript": "^4.8.4" } } diff --git a/packages/ui/src/ContextMenu.tsx b/packages/ui/src/ContextMenu.tsx index 044ac7784..f8ad1e601 100644 --- a/packages/ui/src/ContextMenu.tsx +++ b/packages/ui/src/ContextMenu.tsx @@ -10,12 +10,12 @@ interface Props extends RadixCM.MenuContentProps { const MENU_CLASSES = ` flex flex-col - min-w-[11rem] p-2 space-y-1 + min-w-[8rem] p-1 text-left text-sm dark:text-gray-100 text-gray-800 - bg-gray-50 border-gray-200 dark:bg-gray-750 dark:bg-opacity-70 backdrop-blur + bg-gray-50 border-gray-200 dark:bg-gray-650 dark:bg-opacity-80 backdrop-blur border border-transparent dark:border-gray-550 shadow-md shadow-gray-300 dark:shadow-gray-750 - select-none cursor-default rounded-lg + select-none cursor-default rounded-md `; export const ContextMenu = ({ @@ -37,7 +37,7 @@ export const ContextMenu = ({ }; export const Separator = () => ( - + ); export const SubMenu = ({ @@ -48,7 +48,7 @@ export const SubMenu = ({ }: RadixCM.MenuSubContentProps & ItemProps) => { return ( - + @@ -88,6 +88,7 @@ interface ItemProps extends VariantProps { icon?: Icon; rightArrow?: boolean; label?: string; + keybind?: string; } export const Item = ({ @@ -95,11 +96,17 @@ export const Item = ({ label, rightArrow, children, + keybind, variant, ...props }: ItemProps & RadixCM.MenuItemProps) => ( - - {children ? children : } + +
+ {children ? children : } +
); @@ -109,13 +116,18 @@ const DivItem = ({ variant, ...props }: ItemProps) => (
); -const ItemInternals = ({ icon, label, rightArrow }: ItemProps) => { +const ItemInternals = ({ icon, label, rightArrow, keybind }: ItemProps) => { const ItemIcon = icon; return ( <> {ItemIcon && } {label &&

{label}

} + {keybind && ( + + {keybind} + + )} {rightArrow && ( <>
diff --git a/packages/ui/src/OverlayPanel.tsx b/packages/ui/src/OverlayPanel.tsx index 77a12d189..e8bbfc23f 100644 --- a/packages/ui/src/OverlayPanel.tsx +++ b/packages/ui/src/OverlayPanel.tsx @@ -17,7 +17,7 @@ const MENU_CLASSES = ` border border-gray-300 dark:border-gray-500 shadow-2xl shadow-gray-300 dark:shadow-gray-950 select-none cursor-default rounded-lg - !bg-opacity-80 backdrop-blur + !bg-opacity-90 backdrop-blur `; export const OverlayPanel = ({ diff --git a/packages/ui/style/style.scss b/packages/ui/style/style.scss index bfd8e9732..e824cde28 100644 --- a/packages/ui/style/style.scss +++ b/packages/ui/style/style.scss @@ -14,6 +14,11 @@ -webkit-backdrop-filter: blur(18px); } +.top-bar-blur { + @apply border-app-divider; + backdrop-filter: saturate(120%) blur(18px); +} + .inset-center { position: absolute; top: 50%; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 474497b73..b03575afb 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ