mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-05 22:03:16 -04:00
Merge branch 'eng-259-identify-objectkind'
Conflicts: apps/landing/package.json apps/landing/vite.config.ts packages/interface/src/AppLayout.tsx packages/interface/src/components/explorer/Explorer.tsx packages/interface/src/components/explorer/ExplorerTopBar.tsx packages/ui/src/ContextMenu.tsx pnpm-lock.yaml
This commit is contained in:
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
@@ -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",
|
||||
|
||||
BIN
apps/mobile/pnpm-lock.yaml
generated
Normal file
BIN
apps/mobile/pnpm-lock.yaml
generated
Normal file
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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<i32, CreateFile> = HashMap::new();
|
||||
// link file_path ids to a CreateObject struct containing unique file data
|
||||
let mut chunk: HashMap<i32, CreateObject> = HashMap::new();
|
||||
let mut cas_lookup: HashMap<String, i32> = 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<FileCreated> = 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<FixedOffset>,
|
||||
pub kind: ObjectKind,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
@@ -375,7 +378,7 @@ struct FileCreated {
|
||||
async fn assemble_object_metadata(
|
||||
location_path: impl AsRef<Path>,
|
||||
file_path: &file_path::Data,
|
||||
) -> Result<CreateFile, io::Error> {
|
||||
) -> Result<CreateObject, io::Error> {
|
||||
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<Utc> = 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
73
crates/sync/docs/HLC.md
Normal file
73
crates/sync/docs/HLC.md
Normal file
@@ -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());
|
||||
};
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
@@ -16,6 +16,7 @@ const state = {
|
||||
gridItemSize: 100,
|
||||
listItemSize: 40,
|
||||
selectedRowIndex: 1,
|
||||
tagAssignMode: false,
|
||||
showInspector: true,
|
||||
multiSelectIndexes: [] as number[],
|
||||
contextMenuObjectId: null as number | null,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<boolean>(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) {
|
||||
<div className="relative">
|
||||
<ExplorerContextMenu>
|
||||
<div className="relative flex flex-col w-full bg-app">
|
||||
<TopBar />
|
||||
<TopBar showSeparator={separateTopBar} />
|
||||
|
||||
<div className="relative flex flex-row w-full max-h-full ">
|
||||
<div className="relative flex flex-row w-full max-h-full app-bg">
|
||||
{props.data && (
|
||||
<VirtualizedList data={props.data.items || []} context={props.data.context} />
|
||||
<VirtualizedList
|
||||
data={props.data.items || []}
|
||||
context={props.data.context}
|
||||
onScroll={(y) => {
|
||||
setScrollSegments((old) => {
|
||||
return {
|
||||
...old,
|
||||
mainList: y
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{expStore.showInspector && (
|
||||
<div className="flex min-w-[260px] max-w-[260px]">
|
||||
<Inspector
|
||||
onScroll={(e) => {
|
||||
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]}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<CM.Item
|
||||
key={tag.id}
|
||||
keybind={`${index + 1}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.objectId === null) return;
|
||||
@@ -66,18 +59,18 @@ export default function ExplorerContextMenu(props: PropsWithChildren) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<CM.ContextMenu trigger={props.children}>
|
||||
<CM.Item label="Open" />
|
||||
<CM.Item label="Open" keybind="⌘O" />
|
||||
<CM.Item label="Open with..." />
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
<CM.Item label="Quick view" />
|
||||
<CM.Item label="Open in Finder" />
|
||||
<CM.Item label="Quick view" keybind="␣" />
|
||||
<CM.Item label="Open in Finder" keybind="⌘Y" />
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
<CM.Item label="Rename" />
|
||||
<CM.Item label="Duplicate" />
|
||||
<CM.Item label="Duplicate" keybind="⌘D" />
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
@@ -103,8 +96,8 @@ export default function ExplorerContextMenu(props: PropsWithChildren) {
|
||||
</CM.SubMenu>
|
||||
)}
|
||||
<CM.SubMenu label="More actions..." icon={Plus}>
|
||||
<CM.Item label="Encrypt" icon={LockSimple} />
|
||||
<CM.Item label="Compress" icon={Package} />
|
||||
<CM.Item label="Encrypt" icon={LockSimple} keybind="⌘E" />
|
||||
<CM.Item label="Compress" icon={Package} keybind="⌘B" />
|
||||
<CM.SubMenu label="Convert to" icon={ArrowBendUpRight}>
|
||||
<CM.Item label="PNG" />
|
||||
<CM.Item label="WebP" />
|
||||
@@ -114,7 +107,7 @@ export default function ExplorerContextMenu(props: PropsWithChildren) {
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
<CM.Item icon={Trash} label="Delete" variant="danger" />
|
||||
<CM.Item icon={Trash} label="Delete" variant="danger" keybind="⌘DEL" />
|
||||
</CM.ContextMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<HTMLButtonElement, TopBarButtonProps>(
|
||||
'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<HTMLInputElement, DefaultProps>((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<HTMLInputElement, DefaultProps>((props, forwardedRe
|
||||
);
|
||||
});
|
||||
|
||||
export type TopBarProps = DefaultProps;
|
||||
export type TopBarProps = DefaultProps & {
|
||||
showSeparator?: boolean;
|
||||
};
|
||||
|
||||
export const TopBar: React.FC<TopBarProps> = (props) => {
|
||||
const platform = useOperatingSystem(false);
|
||||
@@ -211,9 +214,16 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
||||
<>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-[2.95rem] -mt-0.5 max-w z-10 pl-3 flex-shrink-0 items-center overflow-hidden rounded-tl-md"
|
||||
// Backdrop blur was removed
|
||||
// but the explorer still resides under the top bar
|
||||
// in case you wanna turn it back on
|
||||
// honestly its just work to revert
|
||||
className={clsx(
|
||||
'flex h-[2.95rem] -mt-0.5 max-w z-10 pl-3 flex-shrink-0 items-center border-transparent border-b app-bg overflow-hidden rounded-tl-md transition-[background-color] transition-[border-color] duration-250 ease-out',
|
||||
props.showSeparator && 'top-bar-blur'
|
||||
)}
|
||||
>
|
||||
<div className="flex ">
|
||||
<div className="flex">
|
||||
<Tooltip label="Navigate back">
|
||||
<TopBarButton icon={ChevronLeftIcon} onClick={() => navigate(-1)} />
|
||||
</Tooltip>
|
||||
@@ -275,7 +285,11 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
<Tooltip label="Tag Assign Mode">
|
||||
<TopBarButton icon={TagIcon} />
|
||||
<TopBarButton
|
||||
onClick={() => (getExplorerStore().tagAssignMode = !store.tagAssignMode)}
|
||||
active={store.tagAssignMode}
|
||||
icon={store.tagAssignMode ? TagIconSolid : TagIcon}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Refresh">
|
||||
<TopBarButton icon={ArrowsClockwise} />
|
||||
|
||||
@@ -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) {
|
||||
>
|
||||
<FileThumb
|
||||
className={clsx(
|
||||
'border-4 border-gray-250 shadow-md shadow-gray-750 object-cover max-w-full max-h-full w-auto overflow-hidden',
|
||||
'border-4 border-gray-250 shadow shadow-gray-750 object-cover max-w-full max-h-full w-auto overflow-hidden',
|
||||
isVid && 'border-gray-950 rounded border-x-0 border-y-[9px]'
|
||||
)}
|
||||
data={data}
|
||||
|
||||
@@ -9,6 +9,7 @@ import dayjs from 'dayjs';
|
||||
import { Link } from 'phosphor-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { DefaultProps } from '../primitive/types';
|
||||
import { Tooltip } from '../tooltip/Tooltip';
|
||||
import FileThumb from './FileThumb';
|
||||
import { Divider } from './inspector/Divider';
|
||||
@@ -17,12 +18,14 @@ import { MetaItem } from './inspector/MetaItem';
|
||||
import Note from './inspector/Note';
|
||||
import { isObject } from './utils';
|
||||
|
||||
interface Props {
|
||||
interface Props extends DefaultProps<HTMLDivElement> {
|
||||
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 (
|
||||
<div className="-mt-[50px] pt-[55px] pl-1.5 pr-1 w-full h-screen overflow-x-hidden custom-scroll inspector-scroll pb-[55px]">
|
||||
<div
|
||||
{...elementProps}
|
||||
className="-mt-[50px] pt-[55px] pl-1.5 pr-1 w-full h-screen overflow-x-hidden custom-scroll inspector-scroll pb-[55px]"
|
||||
>
|
||||
{!!props.data && (
|
||||
<>
|
||||
<div className="flex bg-black items-center justify-center w-full h-64 mb-[10px] overflow-hidden rounded-lg ">
|
||||
|
||||
@@ -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<Props> = ({ data, context }) => {
|
||||
export const VirtualizedList: React.FC<Props> = ({ data, context, onScroll }) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -45,6 +46,19 @@ export const VirtualizedList: React.FC<Props> = ({ 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<Props> = ({ data, context }) => {
|
||||
ref={innerRef}
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
marginTop: `${TOP_BAR_HEIGHT}px`
|
||||
marginTop: `${TOP_BAR_HEIGHT + 10}px`
|
||||
}}
|
||||
className="relative w-full"
|
||||
>
|
||||
|
||||
@@ -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<React.ComponentProps<'svg'>>;
|
||||
}
|
||||
|
||||
const NiceData: Record<string, JobNiceData> = {
|
||||
const getNiceData = (job: JobReport): Record<string, JobNiceData> => ({
|
||||
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<JobReport['status'], string> = {
|
||||
Running: 'text-blue-500',
|
||||
@@ -44,55 +57,66 @@ function elapsed(seconds: number) {
|
||||
export function JobsManager() {
|
||||
const jobs = useLibraryQuery(['jobs.getHistory']);
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="h-full pb-10 overflow-hidden">
|
||||
{/* <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="z-20 flex items-center w-full h-10 px-4 border-b border-gray-500 rounded-t-md">
|
||||
<CategoryHeading className="mt-1 ">Recent Jobs</CategoryHeading>
|
||||
</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];
|
||||
<div className="">
|
||||
<div className="py-1">
|
||||
{jobs.data?.map((job) => {
|
||||
// const color = StatusColors[job.status];
|
||||
const niceData = getNiceData(job)[job.name] || {
|
||||
name: job.name,
|
||||
icon: QuestionMarkCircleIcon
|
||||
};
|
||||
|
||||
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-1 flex-col">
|
||||
<span className="mt-0.5 font-semibold">{niceData.name}</span>
|
||||
<div className="flex items-center opacity-60">
|
||||
<span className="text-xs">
|
||||
{job.status === 'Failed' ? 'Failed after' : 'Took'}{' '}
|
||||
{job.seconds_elapsed
|
||||
? dayjs.duration({ seconds: job.seconds_elapsed }).humanize()
|
||||
: 'less than a second'}
|
||||
return (
|
||||
<div
|
||||
className="flex items-center px-2 py-2 pl-4 border-b border-gray-500 bg-opacity-60"
|
||||
key={job.id}
|
||||
>
|
||||
<Tooltip label={job.status}>
|
||||
<niceData.icon className={clsx('w-5 mr-3')} />
|
||||
</Tooltip>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex mt-0.5 items-center font-semibold truncate">
|
||||
{niceData.name}
|
||||
</span>
|
||||
<span className="mx-1 opacity-50">•</span>
|
||||
<span className="text-xs">{dayjs(job.date_created).toNow(true)} ago</span>
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs opacity-60">
|
||||
{job.status === 'Failed' ? 'Failed after' : 'Took'}{' '}
|
||||
{job.seconds_elapsed
|
||||
? dayjs.duration({ seconds: job.seconds_elapsed }).humanize()
|
||||
: 'less than a second'}
|
||||
</span>
|
||||
<span className="mx-1 opacity-50">•</span>
|
||||
<span className="text-xs">{dayjs(job.date_created).toNow(true)} ago</span>
|
||||
</div>
|
||||
<span className="text-xs">{job.data}</span>
|
||||
</div>
|
||||
<span className="text-xs">{job.data}</span>
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
{job.status === 'Failed' && (
|
||||
<Button padding="thin" variant="gray">
|
||||
<ArrowsClockwise className="w-4 h-4" />
|
||||
<div className="flex-grow" />
|
||||
<div className="flex flex-row space-x-2">
|
||||
{job.status === 'Failed' && (
|
||||
<Button className="!p-1">
|
||||
<ArrowsClockwise className="w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button className="!p-1">
|
||||
<XMarkIcon className="w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button padding="thin" variant="gray">
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function numberWithCommas(x: number) {
|
||||
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function DebugScreen() {
|
||||
// });
|
||||
const { mutate: identifyFiles } = useLibraryMutation('jobs.identifyUniqueFiles');
|
||||
return (
|
||||
<div className="flex flex-col w-full h-screen custom-scroll page-scroll">
|
||||
<div className="flex flex-col w-full h-screen custom-scroll page-scroll app-bg">
|
||||
<div data-tauri-drag-region className="flex flex-shrink-0 w-full h-5" />
|
||||
<div className="flex flex-col p-5 pt-2 space-y-5 pb-7">
|
||||
<h1 className="text-lg font-bold ">Developer Debugger</h1>
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function OverviewScreen() {
|
||||
console.log(overviewStats);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-screen overflow-x-hidden custom-scroll page-scroll">
|
||||
<div className="flex flex-col w-full h-screen overflow-x-hidden custom-scroll page-scroll app-bg">
|
||||
<div data-tauri-drag-region className="flex flex-shrink-0 w-full h-5" />
|
||||
{/* PAGE */}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default function PhotosScreen() {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-screen p-5 custom-scroll page-scroll">
|
||||
<div className="flex flex-col w-full h-screen p-5 custom-scroll page-scroll app-bg">
|
||||
<div className="flex flex-col space-y-5 pb-7">
|
||||
<p className="px-5 py-3 mb-3 text-sm text-gray-400 rounded-md bg-gray-50 dark:text-gray-400 dark:bg-gray-600">
|
||||
<b>Note: </b>This is a pre-alpha build of Spacedrive, many features are yet to be
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SettingsSidebar } from '../../components/settings/SettingsSidebar';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
return (
|
||||
<div className="flex flex-row w-full">
|
||||
<div className="flex flex-row w-full app-bg">
|
||||
<SettingsSidebar />
|
||||
<div className="w-full">
|
||||
<div data-tauri-drag-region className="w-full h-7" />
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
<Card className="px-5 dark:bg-gray-600">
|
||||
<div className="flex flex-col w-full my-2">
|
||||
<div className="flex">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<span className="font-semibold">Connected Node</span>
|
||||
<div className="flex-grow" />
|
||||
<div className="space-x-2">
|
||||
<span className="px-2 py-[2px] rounded text-xs font-medium bg-gray-500">0 Peers</span>
|
||||
<span className="px-1.5 py-[2px] rounded text-xs font-medium bg-primary-600">
|
||||
Running
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<NodePill>0 Peers</NodePill>
|
||||
<NodePill className="bg-primary-600">Running</NodePill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="mt-2 mb-4 border-gray-500 " />
|
||||
<div className="flex flex-row space-x-4">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="mb-1 text-xs font-medium text-gray-700 dark:text-gray-100">
|
||||
Node Name
|
||||
</span>
|
||||
<NodeSettingLabel>Node Name</NodeSettingLabel>
|
||||
<Input value={node?.name} />
|
||||
</div>
|
||||
<div className="flex flex-col w-[100px]">
|
||||
<span className="mb-1 text-xs font-medium text-gray-700 dark:text-gray-100">
|
||||
Node Port
|
||||
</span>
|
||||
<div className="flex flex-col ">
|
||||
<NodeSettingLabel>Node Port</NodeSettingLabel>
|
||||
<Input contentEditable={false} value={node?.p2p_port || 5795} />
|
||||
</div>
|
||||
<div className="flex flex-col w-[295px]">
|
||||
<span className="mb-1 text-xs font-medium text-gray-700 dark:text-gray-100">
|
||||
Node ID
|
||||
</span>
|
||||
<Input contentEditable={false} value={node?.id} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-5 space-x-3">
|
||||
<Toggle size="sm" value />
|
||||
<span className="text-sm text-gray-200">Run daemon when app closed</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span
|
||||
<div
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<Database className="inline w-4 h-4 mr-2 -mt-[2px]" />
|
||||
<b className="mr-2">Data Folder</b>
|
||||
<b className="inline mr-2 truncate ">
|
||||
<Database className="inline w-4 h-4 mr-1 -mt-[2px]" /> Data Folder
|
||||
</b>
|
||||
<span className="select-text">{node?.data_path}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = () => (
|
||||
<RadixCM.Separator className="mx-2 border-0 border-b pointer-events-none border-b-gray-300 dark:border-b-gray-500" />
|
||||
<RadixCM.Separator className="mx-2 border-0 border-b pointer-events-none border-b-gray-300 dark:border-b-gray-550" />
|
||||
);
|
||||
|
||||
export const SubMenu = ({
|
||||
@@ -48,7 +48,7 @@ export const SubMenu = ({
|
||||
}: RadixCM.MenuSubContentProps & ItemProps) => {
|
||||
return (
|
||||
<RadixCM.Sub>
|
||||
<RadixCM.SubTrigger className="[&[data-state='open']_div]:bg-primary focus:outline-none">
|
||||
<RadixCM.SubTrigger className="[&[data-state='open']_div]:bg-primary focus:outline-none py-0.5">
|
||||
<DivItem rightArrow {...{ label, icon }} />
|
||||
</RadixCM.SubTrigger>
|
||||
<RadixCM.Portal>
|
||||
@@ -88,6 +88,7 @@ interface ItemProps extends VariantProps<typeof itemStyles> {
|
||||
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) => (
|
||||
<RadixCM.Item {...props} className={itemStyles({ variant })}>
|
||||
{children ? children : <ItemInternals {...{ icon, label, rightArrow }} />}
|
||||
<RadixCM.Item
|
||||
{...props}
|
||||
className="!cursor-default select-none group focus:outline-none py-0.5 active:opacity-80"
|
||||
>
|
||||
<div className={itemStyles({ variant })}>
|
||||
{children ? children : <ItemInternals {...{ icon, label, rightArrow, keybind }} />}
|
||||
</div>
|
||||
</RadixCM.Item>
|
||||
);
|
||||
|
||||
@@ -109,13 +116,18 @@ const DivItem = ({ variant, ...props }: ItemProps) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const ItemInternals = ({ icon, label, rightArrow }: ItemProps) => {
|
||||
const ItemInternals = ({ icon, label, rightArrow, keybind }: ItemProps) => {
|
||||
const ItemIcon = icon;
|
||||
return (
|
||||
<>
|
||||
{ItemIcon && <ItemIcon size={18} />}
|
||||
{label && <p>{label}</p>}
|
||||
|
||||
{keybind && (
|
||||
<span className="absolute text-xs font-medium text-gray-500 right-3 flex-end dark:text-gray-400 group-hover:dark:text-white">
|
||||
{keybind}
|
||||
</span>
|
||||
)}
|
||||
{rightArrow && (
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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%;
|
||||
|
||||
BIN
pnpm-lock.yaml
generated
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user