This commit is contained in:
Jamie Pine
2026-01-07 21:58:17 -08:00
parent 6cae8b390e
commit 14dbd8da61
461 changed files with 45109 additions and 40620 deletions

123
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,123 @@
# Ultracite Code Standards
This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting.
## Quick Reference
- **Format code**: `bun x ultracite fix`
- **Check for issues**: `bun x ultracite check`
- **Diagnose setup**: `bun x ultracite doctor`
Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable.
---
## Core Principles
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
### Type Safety & Explicitness
- Use explicit types for function parameters and return values when they enhance clarity
- Prefer `unknown` over `any` when the type is genuinely unknown
- Use const assertions (`as const`) for immutable values and literal types
- Leverage TypeScript's type narrowing instead of type assertions
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
### Modern JavaScript/TypeScript
- Use arrow functions for callbacks and short functions
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
- Prefer template literals over string concatenation
- Use destructuring for object and array assignments
- Use `const` by default, `let` only when reassignment is needed, never `var`
### Async & Promises
- Always `await` promises in async functions - don't forget to use the return value
- Use `async/await` syntax instead of promise chains for better readability
- Handle errors appropriately in async code with try-catch blocks
- Don't use async functions as Promise executors
### React & JSX
- Use function components over class components
- Call hooks at the top level only, never conditionally
- Specify all dependencies in hook dependency arrays correctly
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
- Nest children between opening and closing tags instead of passing as props
- Don't define components inside other components
- Use semantic HTML and ARIA attributes for accessibility:
- Provide meaningful alt text for images
- Use proper heading hierarchy
- Add labels for form inputs
- Include keyboard event handlers alongside mouse events
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
### Error Handling & Debugging
- Remove `console.log`, `debugger`, and `alert` statements from production code
- Throw `Error` objects with descriptive messages, not strings or other values
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
- Prefer early returns over nested conditionals for error cases
### Code Organization
- Keep functions focused and under reasonable cognitive complexity limits
- Extract complex conditions into well-named boolean variables
- Use early returns to reduce nesting
- Prefer simple conditionals over nested ternary operators
- Group related code together and separate concerns
### Security
- Add `rel="noopener"` when using `target="_blank"` on links
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
- Don't use `eval()` or assign directly to `document.cookie`
- Validate and sanitize user input
### Performance
- Avoid spread syntax in accumulators within loops
- Use top-level regex literals instead of creating them in loops
- Prefer specific imports over namespace imports
- Avoid barrel files (index files that re-export everything)
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
### Framework-Specific Guidance
**Next.js:**
- Use Next.js `<Image>` component for images
- Use `next/head` or App Router metadata API for head elements
- Use Server Components for async data fetching instead of async Client Components
**React 19+:**
- Use ref as a prop instead of `React.forwardRef`
**Solid/Svelte/Vue/Qwik:**
- Use `class` and `for` attributes (not `className` or `htmlFor`)
---
## Testing
- Write assertions inside `it()` or `test()` blocks
- Avoid done callbacks in async tests - use async/await instead
- Don't use `.only` or `.skip` in committed code
- Keep test suites reasonably flat - avoid excessive `describe` nesting
## When Biome Can't Help
Biome's linter will catch most issues automatically. Focus your attention on:
1. **Business logic correctness** - Biome can't validate your algorithms
2. **Meaningful naming** - Use descriptive names for functions, variables, and types
3. **Architecture decisions** - Component structure, data flow, and API design
4. **Edge cases** - Handle boundary conditions and error states
5. **User experience** - Accessibility, performance, and usability considerations
6. **Documentation** - Add comments for complex logic, but prefer self-documenting code
---
Most formatting and common issues are automatically fixed by Biome. Run `bun x ultracite fix` before committing to ensure compliance.

10
.cursor/hooks.json Normal file
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"hooks": {
"afterFileEdit": [
{
"command": "bun x ultracite fix"
}
]
}
}

View File

@@ -1,23 +1,24 @@
"use strict";
module.exports = {
root: true,
env: {
'node': true,
'es2022': true,
'browser': false,
'commonjs': false,
'shared-node-browser': false
},
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'standard',
'plugin:@typescript-eslint/strict-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'plugin:prettier/recommended'
],
plugins: ['@typescript-eslint'],
parserOptions: {
project: true
},
ignorePatterns: ['node_modules/**/*', 'dist/**/*']
root: true,
env: {
node: true,
es2022: true,
browser: false,
commonjs: false,
"shared-node-browser": false,
},
parser: "@typescript-eslint/parser",
extends: [
"eslint:recommended",
"standard",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
"plugin:prettier/recommended",
],
plugins: ["@typescript-eslint"],
parserOptions: {
project: true,
},
ignorePatterns: ["node_modules/**/*", "dist/**/*"],
};

View File

@@ -1,74 +1,74 @@
import client from '@actions/artifact';
import * as core from '@actions/core';
import * as glob from '@actions/glob';
import * as io from '@actions/io';
import { exists } from '@actions/io/lib/io-util';
import client from "@actions/artifact";
import * as core from "@actions/core";
import * as glob from "@actions/glob";
import * as io from "@actions/io";
import { exists } from "@actions/io/lib/io-util";
type OS = 'darwin' | 'windows' | 'linux';
type Arch = 'x64' | 'arm64';
type OS = "darwin" | "windows" | "linux";
type Arch = "x64" | "arm64";
interface TargetConfig {
ext: string;
bundle: string;
ext: string;
bundle: string;
}
interface BuildTarget {
updater: false | { bundle: string; bundleExt: string; archiveExt: string };
standalone: TargetConfig[];
updater: false | { bundle: string; bundleExt: string; archiveExt: string };
standalone: TargetConfig[];
}
const OS_TARGETS = {
darwin: {
updater: {
bundle: 'macos',
bundleExt: 'app',
archiveExt: 'tar.gz'
},
standalone: [{ ext: 'dmg', bundle: 'dmg' }]
},
windows: {
updater: {
bundle: 'msi',
bundleExt: 'msi',
archiveExt: 'zip'
},
standalone: [{ ext: 'msi', bundle: 'msi' }]
},
linux: {
updater: false,
standalone: [{ ext: 'deb', bundle: 'deb' }]
}
darwin: {
updater: {
bundle: "macos",
bundleExt: "app",
archiveExt: "tar.gz",
},
standalone: [{ ext: "dmg", bundle: "dmg" }],
},
windows: {
updater: {
bundle: "msi",
bundleExt: "msi",
archiveExt: "zip",
},
standalone: [{ ext: "msi", bundle: "msi" }],
},
linux: {
updater: false,
standalone: [{ ext: "deb", bundle: "deb" }],
},
} satisfies Record<OS, BuildTarget>;
// Workflow inputs
const OS = core.getInput('os') as OS;
const ARCH = core.getInput('arch') as Arch;
const TARGET = core.getInput('target');
const PROFILE = core.getInput('profile');
const OS = core.getInput("os") as OS;
const ARCH = core.getInput("arch") as Arch;
const TARGET = core.getInput("target");
const PROFILE = core.getInput("profile");
const BUNDLE_DIR = `target/${TARGET}/${PROFILE}/bundle`;
const ARTIFACTS_DIR = '.artifacts';
const ARTIFACTS_DIR = ".artifacts";
const ARTIFACT_BASE = `Spacedrive-${OS}-${ARCH}`;
const FRONT_END_BUNDLE = 'apps/desktop/dist.tar.xz';
const FRONT_END_BUNDLE = "apps/desktop/dist.tar.xz";
// const UPDATER_ARTIFACT_NAME = `Spacedrive-Updater-${OS}-${ARCH}`;
const FRONTEND_ARCHIVE_NAME = `Spacedrive-frontend-${OS}-${ARCH}`;
async function globFiles(pattern: string) {
const globber = await glob.create(pattern);
return await globber.glob();
const globber = await glob.create(pattern);
return await globber.glob();
}
async function uploadFrontend() {
if (!(await exists(FRONT_END_BUNDLE))) {
console.error(`Frontend archive not found`);
return;
}
if (!(await exists(FRONT_END_BUNDLE))) {
console.error("Frontend archive not found");
return;
}
const artifactName = `${FRONTEND_ARCHIVE_NAME}.tar.xz`;
const artifactPath = `${ARTIFACTS_DIR}/${artifactName}`;
const artifactName = `${FRONTEND_ARCHIVE_NAME}.tar.xz`;
const artifactPath = `${ARTIFACTS_DIR}/${artifactName}`;
await io.cp(FRONT_END_BUNDLE, artifactPath);
await client.uploadArtifact(artifactName, [artifactPath], ARTIFACTS_DIR);
await io.cp(FRONT_END_BUNDLE, artifactPath);
await client.uploadArtifact(artifactName, [artifactPath], ARTIFACTS_DIR);
}
// TODO: Re-enable when updater is configured for v2
@@ -95,30 +95,31 @@ async function uploadFrontend() {
// }
async function uploadStandalone({ bundle, ext }: TargetConfig) {
const files = await globFiles(`${BUNDLE_DIR}/${bundle}/*.${ext}*`);
const files = await globFiles(`${BUNDLE_DIR}/${bundle}/*.${ext}*`);
const standalonePath = files.find((file) => file.endsWith(ext));
if (!standalonePath) throw new Error(`Standalone path not found. Files: ${files.join(',')}`);
const standalonePath = files.find((file) => file.endsWith(ext));
if (!standalonePath)
throw new Error(`Standalone path not found. Files: ${files.join(",")}`);
const artifactName = `${ARTIFACT_BASE}.${ext}`;
const artifactPath = `${ARTIFACTS_DIR}/${artifactName}`;
const artifactName = `${ARTIFACT_BASE}.${ext}`;
const artifactPath = `${ARTIFACTS_DIR}/${artifactName}`;
await io.cp(standalonePath, artifactPath, { recursive: true });
await client.uploadArtifact(artifactName, [artifactPath], ARTIFACTS_DIR);
await io.cp(standalonePath, artifactPath, { recursive: true });
await client.uploadArtifact(artifactName, [artifactPath], ARTIFACTS_DIR);
}
async function run() {
await io.mkdirP(ARTIFACTS_DIR);
await io.mkdirP(ARTIFACTS_DIR);
const { standalone } = OS_TARGETS[OS];
const { standalone } = OS_TARGETS[OS];
await Promise.all([
uploadFrontend(),
...standalone.map((config) => uploadStandalone(config))
]);
await Promise.all([
uploadFrontend(),
...standalone.map((config) => uploadStandalone(config)),
]);
}
run().catch((error: unknown) => {
console.error(error);
process.exit(1);
console.error(error);
process.exit(1);
});

View File

@@ -1,20 +1,20 @@
{
"name": "@sd/publish-artifacts",
"private": true,
"scripts": {
"build": "ncc build index.ts --minify",
"typecheck": "tsc -b",
"lint": "eslint . --cache"
},
"dependencies": {
"@actions/artifact": "^2.1.9",
"@actions/core": "^1.10.1",
"@actions/glob": "^0.5.0",
"@actions/io": "^1.1.3"
},
"devDependencies": {
"@types/node": "^25.0.0",
"@vercel/ncc": "^0.38.1",
"typescript": "^5.9.3"
}
"name": "@sd/publish-artifacts",
"private": true,
"scripts": {
"build": "ncc build index.ts --minify",
"typecheck": "tsc -b",
"lint": "eslint . --cache"
},
"dependencies": {
"@actions/artifact": "^2.1.9",
"@actions/core": "^1.10.1",
"@actions/glob": "^0.5.0",
"@actions/io": "^1.1.3"
},
"devDependencies": {
"@types/node": "^25.0.0",
"@vercel/ncc": "^0.38.1",
"typescript": "^5.9.3"
}
}

View File

@@ -1,27 +1,27 @@
{
"compilerOptions": {
"lib": ["esnext"],
"noEmit": true,
"strict": true,
"module": "esnext",
"target": "esnext",
"declaration": false,
"incremental": true,
"skipLibCheck": true,
"removeComments": false,
"noUnusedLocals": true,
"isolatedModules": true,
"esModuleInterop": true,
"disableSizeLimit": true,
"moduleResolution": "node",
"noImplicitReturns": true,
"resolveJsonModule": true,
"noUnusedParameters": true,
"experimentalDecorators": true,
"useDefineForClassFields": true,
"forceConsistentCasingInFileNames": true
},
"include": ["./**/*.ts"],
"exclude": ["dist", "node_modules"],
"$schema": "https://json.schemastore.org/tsconfig"
"compilerOptions": {
"lib": ["esnext"],
"noEmit": true,
"strict": true,
"module": "esnext",
"target": "esnext",
"declaration": false,
"incremental": true,
"skipLibCheck": true,
"removeComments": false,
"noUnusedLocals": true,
"isolatedModules": true,
"esModuleInterop": true,
"disableSizeLimit": true,
"moduleResolution": "node",
"noImplicitReturns": true,
"resolveJsonModule": true,
"noUnusedParameters": true,
"experimentalDecorators": true,
"useDefineForClassFields": true,
"forceConsistentCasingInFileNames": true
},
"include": ["./**/*.ts"],
"exclude": ["dist", "node_modules"],
"$schema": "https://json.schemastore.org/tsconfig"
}

View File

@@ -1,54 +1,40 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Spacedrive Task",
"type": "object",
"required": [
"id",
"title",
"status",
"assignee",
"priority"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[A-Z]+-[0-9]+$"
},
"title": {
"type": "string",
"minLength": 5
},
"status": {
"type": "string",
"enum": [
"To Do",
"In Progress",
"Done"
]
},
"assignee": {
"type": "string"
},
"parent": {
"type": "string",
"pattern": "^[A-Z]+-[0-9]+$"
},
"priority": {
"type": "string",
"enum": [
"High",
"Medium",
"Low"
]
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"whitepaper": {
"type": "string"
}
}
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Spacedrive Task",
"type": "object",
"required": ["id", "title", "status", "assignee", "priority"],
"properties": {
"id": {
"type": "string",
"pattern": "^[A-Z]+-[0-9]+$"
},
"title": {
"type": "string",
"minLength": 5
},
"status": {
"type": "string",
"enum": ["To Do", "In Progress", "Done"]
},
"assignee": {
"type": "string"
},
"parent": {
"type": "string",
"pattern": "^[A-Z]+-[0-9]+$"
},
"priority": {
"type": "string",
"enum": ["High", "Medium", "Low"]
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"whitepaper": {
"type": "string"
}
}
}

View File

@@ -1,10 +1,10 @@
{
"recommendations": [
"rust-lang.rust-analyzer", // Provides Rust language support
"editorconfig.editorconfig", // EditorConfig helps maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs
"bradlc.vscode-tailwindcss", // Provides Tailwind CSS IntelliSense
"dbaeumer.vscode-eslint", // Integrates ESLint JavaScript into VS Code
"esbenp.prettier-vscode", // Code formatter using prettier,
"lokalise.i18n-ally" // i18n-ally is an all-in-one i18n (internationalization) extension for VS Code
]
"recommendations": [
"rust-lang.rust-analyzer", // Provides Rust language support
"editorconfig.editorconfig", // EditorConfig helps maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs
"bradlc.vscode-tailwindcss", // Provides Tailwind CSS IntelliSense
"dbaeumer.vscode-eslint", // Integrates ESLint JavaScript into VS Code
"esbenp.prettier-vscode", // Code formatter using prettier,
"lokalise.i18n-ally" // i18n-ally is an all-in-one i18n (internationalization) extension for VS Code
]
}

258
.vscode/launch.json vendored
View File

@@ -1,138 +1,124 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Tauri Development Debug",
"cargo": {
"args": [
"build",
"--profile=dev-debug",
"--manifest-path=./apps/desktop/src-tauri/Cargo.toml",
"--no-default-features"
],
"problemMatcher": "$rustc"
},
"env": {
"RUST_BACKTRACE": "short"
},
"sourceLanguages": [
"rust"
],
"preLaunchTask": "ui:dev"
},
{
"type": "lldb",
"request": "launch",
"name": "Tauri Production Debug",
"cargo": {
"args": [
"build",
"--release",
"--manifest-path=./apps/desktop/src-tauri/Cargo.toml"
],
"problemMatcher": "$rustc"
},
"sourceLanguages": [
"rust"
],
"preLaunchTask": "ui:build"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'sd-core'",
"cargo": {
"args": [
"test",
"--no-run",
"--lib",
"--package=sd-core"
],
"filter": {
"name": "sd-core",
"kind": "lib"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'sd-crypto'",
"cargo": {
"args": [
"test",
"--no-run",
"--lib",
"--package=sd-crypto"
],
"filter": {
"name": "sd-crypto",
"kind": "lib"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/apps/spacedrive-companion",
"name": "Debug SpacedriveCompanion (apps/spacedrive-companion)",
"program": "${workspaceFolder:spacedrive}/apps/spacedrive-companion/.build/debug/SpacedriveCompanion",
"preLaunchTask": "swift: Build Debug SpacedriveCompanion (apps/spacedrive-companion)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/apps/spacedrive-companion",
"name": "Release SpacedriveCompanion (apps/spacedrive-companion)",
"program": "${workspaceFolder:spacedrive}/apps/spacedrive-companion/.build/release/SpacedriveCompanion",
"preLaunchTask": "swift: Build Release SpacedriveCompanion (apps/spacedrive-companion)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/packages/rust-swift/swift-ui",
"name": "Debug SwiftUIDemo (packages/rust-swift/swift-ui)",
"program": "${workspaceFolder:spacedrive}/packages/rust-swift/swift-ui/.build/debug/SwiftUIDemo",
"preLaunchTask": "swift: Build Debug SwiftUIDemo (packages/rust-swift/swift-ui)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/packages/rust-swift/swift-ui",
"name": "Release SwiftUIDemo (packages/rust-swift/swift-ui)",
"program": "${workspaceFolder:spacedrive}/packages/rust-swift/swift-ui/.build/release/SwiftUIDemo",
"preLaunchTask": "swift: Build Release SwiftUIDemo (packages/rust-swift/swift-ui)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/apps/macos",
"name": "Debug Spacedrive (apps/macos)",
"program": "${workspaceFolder:spacedrive}/apps/macos/.build/debug/Spacedrive",
"preLaunchTask": "swift: Build Debug Spacedrive (apps/macos)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/apps/macos",
"name": "Release Spacedrive (apps/macos)",
"program": "${workspaceFolder:spacedrive}/apps/macos/.build/release/Spacedrive",
"preLaunchTask": "swift: Build Release Spacedrive (apps/macos)"
}
]
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Tauri Development Debug",
"cargo": {
"args": [
"build",
"--profile=dev-debug",
"--manifest-path=./apps/desktop/src-tauri/Cargo.toml",
"--no-default-features"
],
"problemMatcher": "$rustc"
},
"env": {
"RUST_BACKTRACE": "short"
},
"sourceLanguages": ["rust"],
"preLaunchTask": "ui:dev"
},
{
"type": "lldb",
"request": "launch",
"name": "Tauri Production Debug",
"cargo": {
"args": [
"build",
"--release",
"--manifest-path=./apps/desktop/src-tauri/Cargo.toml"
],
"problemMatcher": "$rustc"
},
"sourceLanguages": ["rust"],
"preLaunchTask": "ui:build"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'sd-core'",
"cargo": {
"args": ["test", "--no-run", "--lib", "--package=sd-core"],
"filter": {
"name": "sd-core",
"kind": "lib"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'sd-crypto'",
"cargo": {
"args": ["test", "--no-run", "--lib", "--package=sd-crypto"],
"filter": {
"name": "sd-crypto",
"kind": "lib"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/apps/spacedrive-companion",
"name": "Debug SpacedriveCompanion (apps/spacedrive-companion)",
"program": "${workspaceFolder:spacedrive}/apps/spacedrive-companion/.build/debug/SpacedriveCompanion",
"preLaunchTask": "swift: Build Debug SpacedriveCompanion (apps/spacedrive-companion)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/apps/spacedrive-companion",
"name": "Release SpacedriveCompanion (apps/spacedrive-companion)",
"program": "${workspaceFolder:spacedrive}/apps/spacedrive-companion/.build/release/SpacedriveCompanion",
"preLaunchTask": "swift: Build Release SpacedriveCompanion (apps/spacedrive-companion)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/packages/rust-swift/swift-ui",
"name": "Debug SwiftUIDemo (packages/rust-swift/swift-ui)",
"program": "${workspaceFolder:spacedrive}/packages/rust-swift/swift-ui/.build/debug/SwiftUIDemo",
"preLaunchTask": "swift: Build Debug SwiftUIDemo (packages/rust-swift/swift-ui)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/packages/rust-swift/swift-ui",
"name": "Release SwiftUIDemo (packages/rust-swift/swift-ui)",
"program": "${workspaceFolder:spacedrive}/packages/rust-swift/swift-ui/.build/release/SwiftUIDemo",
"preLaunchTask": "swift: Build Release SwiftUIDemo (packages/rust-swift/swift-ui)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/apps/macos",
"name": "Debug Spacedrive (apps/macos)",
"program": "${workspaceFolder:spacedrive}/apps/macos/.build/debug/Spacedrive",
"preLaunchTask": "swift: Build Debug Spacedrive (apps/macos)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:spacedrive}/apps/macos",
"name": "Release Spacedrive (apps/macos)",
"program": "${workspaceFolder:spacedrive}/apps/macos/.build/release/Spacedrive",
"preLaunchTask": "swift: Build Release Spacedrive (apps/macos)"
}
]
}

67
.vscode/settings.json vendored
View File

@@ -1,16 +1,53 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnPaste": true,
"emmet.showExpandedAbbreviation": "never",
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[html]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[vue]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[svelte]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[yaml]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[graphql]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[markdown]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[mdx]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
}
}

165
.vscode/tasks.json vendored
View File

@@ -1,83 +1,86 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "cargo",
"command": "clippy",
"problemMatcher": ["$rustc"],
"group": {
"kind": "build",
"isDefault": true
},
"label": "rust: cargo clippy",
"args": ["--all-targets", "--all-features", "--all"]
},
{
"type": "npm",
"script": "prep",
"label": "pnpm: prep",
"group": "none",
"problemMatcher": ["$rustc"]
},
{
"type": "shell",
"label": "start",
"command": "sh",
"args": ["-c", "'pnpm i && pnpm prep'"],
"problemMatcher": ["$tsc-watch", "$rustc"]
},
{
"type": "shell",
"label": "ui:dev",
"problemMatcher": {
"base": "$tsc-watch",
"background": {
"activeOnStart": true,
"beginsPattern": "VITE v",
"endsPattern": "http://localhost:8001/"
}
},
"isBackground": true,
"command": "pnpm",
"args": ["desktop", "vite", "--clearScreen=false", "--mode=development"],
"runOptions": {
"instanceLimit": 1
}
},
{
"type": "shell",
"label": "ui:build",
"problemMatcher": "$tsc",
"command": "pnpm",
"args": ["desktop", "vite", "build"]
},
{
"type": "cargo",
"command": "run",
"args": [
"--manifest-path=./apps/desktop/src-tauri/Cargo.toml",
"--no-default-features"
],
"env": {
"RUST_BACKTRACE": "short"
// "RUST_LOG": "sd_core::invalidate-query=trace"
},
"problemMatcher": ["$rustc"],
"group": "build",
"label": "rust: run spacedrive",
"dependsOn": ["ui:dev"]
},
{
"type": "cargo",
"command": "run",
"args": ["--manifest-path=./apps/desktop/src-tauri/Cargo.toml", "--release"],
"env": {
"RUST_BACKTRACE": "short"
},
"problemMatcher": ["$rustc"],
"group": "build",
"label": "rust: run spacedrive release",
"dependsOn": ["ui:build"]
}
]
"version": "2.0.0",
"tasks": [
{
"type": "cargo",
"command": "clippy",
"problemMatcher": ["$rustc"],
"group": {
"kind": "build",
"isDefault": true
},
"label": "rust: cargo clippy",
"args": ["--all-targets", "--all-features", "--all"]
},
{
"type": "npm",
"script": "prep",
"label": "pnpm: prep",
"group": "none",
"problemMatcher": ["$rustc"]
},
{
"type": "shell",
"label": "start",
"command": "sh",
"args": ["-c", "'pnpm i && pnpm prep'"],
"problemMatcher": ["$tsc-watch", "$rustc"]
},
{
"type": "shell",
"label": "ui:dev",
"problemMatcher": {
"base": "$tsc-watch",
"background": {
"activeOnStart": true,
"beginsPattern": "VITE v",
"endsPattern": "http://localhost:8001/"
}
},
"isBackground": true,
"command": "pnpm",
"args": ["desktop", "vite", "--clearScreen=false", "--mode=development"],
"runOptions": {
"instanceLimit": 1
}
},
{
"type": "shell",
"label": "ui:build",
"problemMatcher": "$tsc",
"command": "pnpm",
"args": ["desktop", "vite", "build"]
},
{
"type": "cargo",
"command": "run",
"args": [
"--manifest-path=./apps/desktop/src-tauri/Cargo.toml",
"--no-default-features"
],
"env": {
"RUST_BACKTRACE": "short"
// "RUST_LOG": "sd_core::invalidate-query=trace"
},
"problemMatcher": ["$rustc"],
"group": "build",
"label": "rust: run spacedrive",
"dependsOn": ["ui:dev"]
},
{
"type": "cargo",
"command": "run",
"args": [
"--manifest-path=./apps/desktop/src-tauri/Cargo.toml",
"--release"
],
"env": {
"RUST_BACKTRACE": "short"
},
"problemMatcher": ["$rustc"],
"group": "build",
"label": "rust: run spacedrive release",
"dependsOn": ["ui:build"]
}
]
}

View File

@@ -7,10 +7,7 @@
"icon": "./icon.png",
"userInterfaceStyle": "dark",
"scheme": "spacedrive",
"platforms": [
"ios",
"android"
],
"platforms": ["ios", "android"],
"newArchEnabled": true,
"experiments": {
"typedRoutes": true

View File

@@ -1,10 +1,10 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [["babel-preset-expo", { jsxImportSource: "nativewind" }]],
plugins: [
["@babel/plugin-transform-runtime", { helpers: true }],
"react-native-reanimated/plugin"
],
};
module.exports = (api) => {
api.cache(true);
return {
presets: [["babel-preset-expo", { jsxImportSource: "nativewind" }]],
plugins: [
["@babel/plugin-transform-runtime", { helpers: true }],
"react-native-reanimated/plugin",
],
};
};

View File

@@ -11,4 +11,4 @@
"version": 1,
"author": "expo"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "expo"
"info": {
"version": 1,
"author": "expo"
}
}

View File

@@ -17,4 +17,4 @@
"version": 1,
"author": "expo"
}
}
}

View File

@@ -2,7 +2,7 @@ const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const path = require("path");
const projectRoot = __dirname;
const projectRoot = import.meta.dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
@@ -12,43 +12,43 @@ config.watchFolders = [workspaceRoot];
// Configure resolver for monorepo and SVG support
config.resolver = {
...config.resolver,
...config.resolver,
// Treat SVG as source files (not assets)
sourceExts: [...config.resolver.sourceExts, "svg"],
assetExts: config.resolver.assetExts.filter((ext) => ext !== "svg"),
// Treat SVG as source files (not assets)
sourceExts: [...config.resolver.sourceExts, "svg"],
assetExts: config.resolver.assetExts.filter((ext) => ext !== "svg"),
// Critical for Bun monorepo - resolve node_modules from local and workspace root
// Local node_modules takes priority to ensure correct React version
nodeModulesPaths: [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
],
// Critical for Bun monorepo - resolve node_modules from local and workspace root
// Local node_modules takes priority to ensure correct React version
nodeModulesPaths: [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
],
// Exclude build outputs and prevent loading wrong React version from root
blockList: [
/\/apps\/mobile\/ios\/build\/.*/,
/\/apps\/mobile\/android\/build\/.*/,
// Block React from workspace root to force local version
new RegExp(`^${workspaceRoot}/node_modules/react/.*`),
],
// Exclude build outputs and prevent loading wrong React version from root
blockList: [
/\/apps\/mobile\/ios\/build\/.*/,
/\/apps\/mobile\/android\/build\/.*/,
// Block React from workspace root to force local version
new RegExp(`^${workspaceRoot}/node_modules/react/.*`),
],
// Force React resolution from mobile app's node_modules
extraNodeModules: {
react: path.resolve(projectRoot, "node_modules/react"),
},
// Force React resolution from mobile app's node_modules
extraNodeModules: {
react: path.resolve(projectRoot, "node_modules/react"),
},
};
// SVG transformer for @sd/assets SVGs
config.transformer = {
...config.transformer,
babelTransformerPath: require.resolve("react-native-svg-transformer"),
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
...config.transformer,
babelTransformerPath: require.resolve("react-native-svg-transformer"),
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
};
// Add NativeWind support

View File

@@ -1,16 +1,9 @@
{
"platforms": [
"ios",
"android"
],
"platforms": ["ios", "android"],
"ios": {
"modules": [
"SDMobileCoreModule"
]
"modules": ["SDMobileCoreModule"]
},
"android": {
"modules": [
"com.spacedrive.core.SDMobileCoreModule"
]
"modules": ["com.spacedrive.core.SDMobileCoreModule"]
}
}
}

View File

@@ -1,7 +1,7 @@
import {
NativeModule,
requireNativeModule,
EventEmitter,
type NativeModule,
requireNativeModule,
} from "expo-modules-core";
export interface CoreEvent {
@@ -35,7 +35,7 @@ export interface CoreModule {
interface SDMobileCoreNativeModule extends NativeModule<SDMobileCoreEvents> {
initialize(
dataDir: string | null,
deviceName: string | null,
deviceName: string | null
): Promise<number>;
sendMessage(query: string): Promise<string>;
shutdown(): void;
@@ -47,7 +47,9 @@ const SDMobileCoreModule =
requireNativeModule<SDMobileCoreNativeModule>("SDMobileCore");
if (!SDMobileCoreModule) {
throw new Error("SDMobileCoreModule has not been initialized. Did you run 'cargo xtask build-mobile' and rebuild the app?")
throw new Error(
"SDMobileCoreModule has not been initialized. Did you run 'cargo xtask build-mobile' and rebuild the app?"
);
}
const emitter = new EventEmitter<SDMobileCoreEvents>(SDMobileCoreModule as any);

View File

@@ -1,9 +1,9 @@
module.exports = {
dependencies: {
// Ensure DoubleConversion is properly linked
},
project: {
ios: {},
android: {},
},
dependencies: {
// Ensure DoubleConversion is properly linked
},
project: {
ios: {},
android: {},
},
};

View File

@@ -1,35 +1,36 @@
import React, { useState } from "react";
import { View, ViewProps } from "react-native";
import { StatusBar } from "expo-status-bar";
import type React from "react";
import { useState } from "react";
import type { ViewProps } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import { SpacedriveProvider } from "./client";
import { RootNavigator } from "./navigation";
import { AppResetContext } from "./contexts";
import { RootNavigator } from "./navigation";
import "./global.css";
// Type workaround for GestureHandlerRootView children prop
const GestureRoot = GestureHandlerRootView as React.ComponentType<
ViewProps & { children?: React.ReactNode }
ViewProps & { children?: React.ReactNode }
>;
export default function App() {
const [resetKey, setResetKey] = useState(0);
const [resetKey, setResetKey] = useState(0);
const resetApp = () => {
setResetKey((prev) => prev + 1);
};
const resetApp = () => {
setResetKey((prev) => prev + 1);
};
return (
<GestureRoot style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar style="light" />
<AppResetContext.Provider value={{ resetApp }}>
<SpacedriveProvider key={resetKey} deviceName="Spacedrive Mobile">
<RootNavigator />
</SpacedriveProvider>
</AppResetContext.Provider>
</SafeAreaProvider>
</GestureRoot>
);
return (
<GestureRoot style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar style="light" />
<AppResetContext.Provider value={{ resetApp }}>
<SpacedriveProvider deviceName="Spacedrive Mobile" key={resetKey}>
<RootNavigator />
</SpacedriveProvider>
</AppResetContext.Provider>
</SafeAreaProvider>
</GestureRoot>
);
}

View File

@@ -1,20 +1,24 @@
import { Platform } from 'react-native';
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs";
import { Platform } from "react-native";
export default function TabLayout() {
return (
<NativeTabs
// CRITICAL: null background enables liquid glass on iOS!
backgroundColor={Platform.OS === 'ios' ? null : 'hsl(235, 10%, 6%)'}
backgroundColor={Platform.OS === "ios" ? null : "hsl(235, 10%, 6%)"}
disableTransparentOnScrollEdge={true}
iconColor={Platform.OS === 'android' ? 'hsl(235, 10%, 55%)' : undefined}
labelStyle={Platform.OS === 'android' ? {
color: 'hsl(235, 10%, 55%)'
} : undefined}
iconColor={Platform.OS === "android" ? "hsl(235, 10%, 55%)" : undefined}
labelStyle={
Platform.OS === "android"
? {
color: "hsl(235, 10%, 55%)",
}
: undefined
}
>
<NativeTabs.Trigger name="overview">
<Label>Overview</Label>
{Platform.OS === 'ios' ? (
{Platform.OS === "ios" ? (
<Icon sf="square.grid.2x2" />
) : (
<Icon name="grid" />
@@ -23,25 +27,17 @@ export default function TabLayout() {
<NativeTabs.Trigger name="browse">
<Label>Browse</Label>
{Platform.OS === 'ios' ? (
<Icon sf="folder" />
) : (
<Icon name="folder" />
)}
{Platform.OS === "ios" ? <Icon sf="folder" /> : <Icon name="folder" />}
</NativeTabs.Trigger>
<NativeTabs.Trigger name="network">
<Label>Network</Label>
{Platform.OS === 'ios' ? (
<Icon sf="network" />
) : (
<Icon name="wifi" />
)}
{Platform.OS === "ios" ? <Icon sf="network" /> : <Icon name="wifi" />}
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<Label>Settings</Label>
{Platform.OS === 'ios' ? (
{Platform.OS === "ios" ? (
<Icon sf="gearshape" />
) : (
<Icon name="settings" />

View File

@@ -1,3 +1,3 @@
import { BrowseScreen } from '../../../screens/browse/BrowseScreen';
import { BrowseScreen } from "../../../screens/browse/BrowseScreen";
export default BrowseScreen;

View File

@@ -1,11 +1,10 @@
import React from 'react';
import { View, Text } from 'react-native';
import { Text, View } from "react-native";
export default function NetworkScreen() {
return (
<View className="flex-1 bg-sidebar items-center justify-center">
<View className="flex-1 items-center justify-center bg-sidebar">
<Text className="text-ink text-xl">Network</Text>
<Text className="text-ink-dull text-sm mt-2">Coming soon</Text>
<Text className="mt-2 text-ink-dull text-sm">Coming soon</Text>
</View>
);
}

View File

@@ -1,3 +1,3 @@
import { OverviewScreen } from '../../../screens/overview/OverviewScreen';
import { OverviewScreen } from "../../../screens/overview/OverviewScreen";
export default OverviewScreen;

View File

@@ -1,3 +1,3 @@
import { SettingsScreen } from '../../../screens/settings/SettingsScreen';
import { SettingsScreen } from "../../../screens/settings/SettingsScreen";
export default SettingsScreen;

View File

@@ -1,4 +1,4 @@
import { Stack } from 'expo-router';
import { Stack } from "expo-router";
export default function DrawerLayout() {
return (

View File

@@ -1,10 +1,10 @@
import { useState } from 'react';
import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { SpacedriveProvider } from '../client';
import { AppResetContext } from '../contexts';
import '../global.css';
import { Stack } from "expo-router";
import { useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { SpacedriveProvider } from "../client";
import { AppResetContext } from "../contexts";
import "../global.css";
export default function RootLayout() {
const [resetKey, setResetKey] = useState(0);
@@ -14,7 +14,7 @@ export default function RootLayout() {
};
return (
<GestureHandlerRootView style={{ flex: 1 }} className="bg-sidebar">
<GestureHandlerRootView className="bg-sidebar" style={{ flex: 1 }}>
<SafeAreaProvider>
<AppResetContext.Provider value={{ resetApp }}>
<SpacedriveProvider key={resetKey}>
@@ -23,8 +23,8 @@ export default function RootLayout() {
<Stack.Screen
name="search"
options={{
presentation: 'modal',
animation: 'slide_from_bottom'
presentation: "modal",
animation: "slide_from_bottom",
}}
/>
</Stack>

View File

@@ -1,4 +1,4 @@
import { Redirect } from 'expo-router';
import { Redirect } from "expo-router";
export default function Index() {
// TODO: Check if user is onboarded, if not redirect to onboarding

View File

@@ -1,11 +1,10 @@
import React from 'react';
import { View, Text } from 'react-native';
import { Text, View } from "react-native";
export default function SearchScreen() {
return (
<View className="flex-1 bg-app-modal items-center justify-center">
<View className="flex-1 items-center justify-center bg-app-modal">
<Text className="text-ink text-xl">Search</Text>
<Text className="text-ink-dull text-sm mt-2">Coming soon</Text>
<Text className="mt-2 text-ink-dull text-sm">Coming soon</Text>
</View>
);
}

View File

@@ -1,8 +1,8 @@
import { SDMobileCore } from "sd-mobile-core";
import { ReactNativeTransport } from "./transport";
import { WIRE_METHODS } from "@sd/ts-client";
import type { Event } from "@sd/ts-client/generated/types";
import { SDMobileCore } from "sd-mobile-core";
import { SubscriptionManager } from "./subscriptionManager";
import { ReactNativeTransport } from "./transport";
/**
* Simple event emitter for browser compatibility
@@ -82,7 +82,7 @@ export class SpacedriveClient extends SimpleEventEmitter {
* Set the current library context for queries.
* @param emitEvent - Whether to emit library-changed event (default: true)
*/
setCurrentLibrary(libraryId: string | null, emitEvent: boolean = true) {
setCurrentLibrary(libraryId: string | null, emitEvent = true) {
this.currentLibraryId = libraryId;
if (emitEvent && libraryId) {
@@ -105,7 +105,7 @@ export class SpacedriveClient extends SimpleEventEmitter {
const isQuery = wireMethod.startsWith("query:");
const isAction = wireMethod.startsWith("action:");
if (!isQuery && !isAction) {
if (!(isQuery || isAction)) {
throw new Error(`Invalid wire method: ${wireMethod}`);
}
@@ -120,7 +120,7 @@ export class SpacedriveClient extends SimpleEventEmitter {
*/
async coreQuery<T = unknown>(
method: string,
input: unknown = {},
input: unknown = {}
): Promise<T> {
const wireMethod = (WIRE_METHODS.coreQueries as any)[method];
if (!wireMethod) {
@@ -134,7 +134,7 @@ export class SpacedriveClient extends SimpleEventEmitter {
*/
async libraryQuery<T = unknown>(
method: string,
input: unknown = {},
input: unknown = {}
): Promise<T> {
if (!this.currentLibraryId) {
throw new Error("No library selected");
@@ -156,7 +156,7 @@ export class SpacedriveClient extends SimpleEventEmitter {
*/
async coreAction<T = unknown>(
method: string,
input: unknown = {},
input: unknown = {}
): Promise<T> {
const wireMethod = (WIRE_METHODS.coreActions as any)[method];
if (!wireMethod) {
@@ -170,7 +170,7 @@ export class SpacedriveClient extends SimpleEventEmitter {
*/
async libraryAction<T = unknown>(
method: string,
input: unknown = {},
input: unknown = {}
): Promise<T> {
if (!this.currentLibraryId) {
throw new Error("No library selected");
@@ -212,7 +212,7 @@ export class SpacedriveClient extends SimpleEventEmitter {
include_descendants?: boolean;
event_types?: string[];
},
callback: (event: Event) => void,
callback: (event: Event) => void
): Promise<() => void> {
return this.subscriptionManager.subscribe(filter, callback);
}

View File

@@ -1,17 +1,16 @@
import React, { useEffect, useState, ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { Event } from "@sd/ts-client/src/generated/types";
import {
SpacedriveClientContext,
queryClient,
SpacedriveClientContext,
useSpacedriveClient,
} from "@sd/ts-client/src/hooks/useClient";
import type { Event } from "@sd/ts-client/src/generated/types";
import { SpacedriveClient } from "../SpacedriveClient";
import { View, Text, ActivityIndicator, StyleSheet } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { SDMobileCore } from "sd-mobile-core";
import { QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useEffect, useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { usePreferencesStore } from "../../stores/preferences";
import { useSidebarStore } from "../../stores/sidebar";
import { SpacedriveClient } from "../SpacedriveClient";
// Re-export the shared hook
export { useSpacedriveClient };
@@ -35,7 +34,7 @@ export function SpacedriveProvider({
useEffect(() => {
let mounted = true;
let unsubscribeLogs: (() => void) | null = null;
const unsubscribeLogs: (() => void) | null = null;
async function init() {
try {
@@ -63,7 +62,7 @@ export function SpacedriveProvider({
if (parsed.state?.currentLibraryId) {
console.log(
"[SpacedriveProvider] Restoring library ID:",
parsed.state.currentLibraryId,
parsed.state.currentLibraryId
);
client.setCurrentLibrary(parsed.state.currentLibraryId);
libraryIdSet = true;
@@ -81,7 +80,7 @@ export function SpacedriveProvider({
console.log(
"[SpacedriveProvider] Auto-selecting first library:",
firstLibrary.name,
firstLibrary.id,
firstLibrary.id
);
client.setCurrentLibrary(firstLibrary.id);
@@ -93,17 +92,17 @@ export function SpacedriveProvider({
currentLibraryId: firstLibrary.id,
collapsedGroups: [],
},
}),
})
);
} else {
console.warn(
"[SpacedriveProvider] No libraries available to auto-select",
"[SpacedriveProvider] No libraries available to auto-select"
);
}
} catch (error) {
console.error(
"[SpacedriveProvider] Failed to auto-select library:",
error,
error
);
}
}
@@ -124,7 +123,7 @@ export function SpacedriveProvider({
if (autoSwitchEnabled) {
console.log(
`[Auto-Switch] Received synced library "${name}", switching...`,
`[Auto-Switch] Received synced library "${name}", switching...`
);
// Update client state
@@ -134,7 +133,7 @@ export function SpacedriveProvider({
useSidebarStore.getState().setCurrentLibrary(id);
} else {
console.log(
`[Auto-Switch] Received synced library "${name}", but auto-switch is disabled`,
`[Auto-Switch] Received synced library "${name}", but auto-switch is disabled`
);
}
}
@@ -181,7 +180,7 @@ export function SpacedriveProvider({
if (!initialized) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#2599FF" />
<ActivityIndicator color="#2599FF" size="large" />
<Text style={styles.loadingText}>Initializing Spacedrive...</Text>
</View>
);

View File

@@ -1,8 +1,8 @@
import {
useQuery,
useMutation,
UseQueryOptions,
UseMutationOptions,
type UseMutationOptions,
type UseQueryOptions,
useMutation,
useQuery,
} from "@tanstack/react-query";
import { useSpacedriveClient } from "./useClient";
@@ -10,17 +10,17 @@ import { useSpacedriveClient } from "./useClient";
* Hook for executing core-level queries (no library context).
*/
export function useCoreQuery<T = unknown>(
method: string,
input: unknown = {},
options?: Omit<UseQueryOptions<T, Error>, "queryKey" | "queryFn">,
method: string,
input: unknown = {},
options?: Omit<UseQueryOptions<T, Error>, "queryKey" | "queryFn">
) {
const client = useSpacedriveClient();
const client = useSpacedriveClient();
return useQuery<T, Error>({
queryKey: ["core", method, input],
queryFn: () => client.coreQuery<T>(method, input),
...options,
});
return useQuery<T, Error>({
queryKey: ["core", method, input],
queryFn: () => client.coreQuery<T>(method, input),
...options,
});
}
/**
@@ -28,49 +28,47 @@ export function useCoreQuery<T = unknown>(
* Automatically uses the current library context.
*/
export function useLibraryQuery<T = unknown>(
method: string,
input: unknown = {},
options?: Omit<UseQueryOptions<T, Error>, "queryKey" | "queryFn">,
method: string,
input: unknown = {},
options?: Omit<UseQueryOptions<T, Error>, "queryKey" | "queryFn">
) {
const client = useSpacedriveClient();
const libraryId = client.getCurrentLibraryId();
const client = useSpacedriveClient();
const libraryId = client.getCurrentLibraryId();
return useQuery<T, Error>({
queryKey: ["library", libraryId, method, input],
queryFn: () => client.libraryQuery<T>(method, input),
enabled: !!libraryId && (options?.enabled ?? true),
...options,
});
return useQuery<T, Error>({
queryKey: ["library", libraryId, method, input],
queryFn: () => client.libraryQuery<T>(method, input),
enabled: !!libraryId && (options?.enabled ?? true),
...options,
});
}
/**
* Hook for executing core-level actions (mutations).
*/
export function useCoreAction<TInput = unknown, TOutput = unknown>(
method: string,
options?: UseMutationOptions<TOutput, Error, TInput>,
method: string,
options?: UseMutationOptions<TOutput, Error, TInput>
) {
const client = useSpacedriveClient();
const client = useSpacedriveClient();
return useMutation<TOutput, Error, TInput>({
mutationFn: (input: TInput) =>
client.coreAction<TOutput>(method, input),
...options,
});
return useMutation<TOutput, Error, TInput>({
mutationFn: (input: TInput) => client.coreAction<TOutput>(method, input),
...options,
});
}
/**
* Hook for executing library-level actions (mutations).
*/
export function useLibraryAction<TInput = unknown, TOutput = unknown>(
method: string,
options?: UseMutationOptions<TOutput, Error, TInput>,
method: string,
options?: UseMutationOptions<TOutput, Error, TInput>
) {
const client = useSpacedriveClient();
const client = useSpacedriveClient();
return useMutation<TOutput, Error, TInput>({
mutationFn: (input: TInput) =>
client.libraryAction<TOutput>(method, input),
...options,
});
return useMutation<TOutput, Error, TInput>({
mutationFn: (input: TInput) => client.libraryAction<TOutput>(method, input),
...options,
});
}

View File

@@ -1,15 +1,14 @@
// Core client
export { SpacedriveClient } from "./SpacedriveClient";
export { ReactNativeTransport } from "./transport";
// Provider and hooks
export { SpacedriveProvider, useSpacedriveClient } from "./hooks/useClient";
export {
useCoreQuery,
useLibraryQuery,
useCoreAction,
useLibraryAction,
} from "./hooks/useQuery";
// Re-export shared hooks from ts-client
export { useNormalizedQuery } from "@sd/ts-client/src/hooks/useNormalizedQuery";
// Provider and hooks
export { SpacedriveProvider, useSpacedriveClient } from "./hooks/useClient";
export {
useCoreAction,
useCoreQuery,
useLibraryAction,
useLibraryQuery,
} from "./hooks/useQuery";
export { SpacedriveClient } from "./SpacedriveClient";
export { ReactNativeTransport } from "./transport";

View File

@@ -12,172 +12,180 @@
* - Automatic cleanup when last listener unsubscribes
*/
import type { ReactNativeTransport } from "./transport";
import type { Event } from "@sd/ts-client/src/generated/types";
import type { ReactNativeTransport } from "./transport";
interface EventFilter {
library_id?: string;
resource_type?: string;
path_scope?: any;
include_descendants?: boolean;
event_types?: string[];
library_id?: string;
resource_type?: string;
path_scope?: any;
include_descendants?: boolean;
event_types?: string[];
}
interface SubscriptionEntry {
unsubscribe: () => void;
listeners: Set<(event: Event) => void>;
refCount: number;
filter: EventFilter;
unsubscribe: () => void;
listeners: Set<(event: Event) => void>;
refCount: number;
filter: EventFilter;
}
export class SubscriptionManager {
private subscriptions = new Map<string, SubscriptionEntry>();
private pendingSubscriptions = new Map<string, Promise<SubscriptionEntry>>();
private transport: ReactNativeTransport;
private subscriptions = new Map<string, SubscriptionEntry>();
private pendingSubscriptions = new Map<string, Promise<SubscriptionEntry>>();
private transport: ReactNativeTransport;
constructor(transport: ReactNativeTransport) {
this.transport = transport;
}
constructor(transport: ReactNativeTransport) {
this.transport = transport;
}
/**
* Generate stable key for filter
* Same filters = same key = shared subscription
*/
private getFilterKey(filter: EventFilter): string {
return JSON.stringify({
library_id: filter.library_id ?? null,
resource_type: filter.resource_type ?? null,
path_scope: filter.path_scope ?? null,
include_descendants: filter.include_descendants ?? false,
event_types: filter.event_types ?? [],
});
}
/**
* Generate stable key for filter
* Same filters = same key = shared subscription
*/
private getFilterKey(filter: EventFilter): string {
return JSON.stringify({
library_id: filter.library_id ?? null,
resource_type: filter.resource_type ?? null,
path_scope: filter.path_scope ?? null,
include_descendants: filter.include_descendants ?? false,
event_types: filter.event_types ?? [],
});
}
/**
* Check if an event matches the filter
* Since mobile transport doesn't support server-side filtering,
* we filter events client-side
*/
private matchesFilter(event: Event, filter: EventFilter): boolean {
if (filter.event_types && filter.event_types.length > 0) {
const eventType = typeof event === "string" ? event : Object.keys(event)[0];
if (!filter.event_types.includes(eventType)) {
return false;
}
}
/**
* Check if an event matches the filter
* Since mobile transport doesn't support server-side filtering,
* we filter events client-side
*/
private matchesFilter(event: Event, filter: EventFilter): boolean {
if (filter.event_types && filter.event_types.length > 0) {
const eventType =
typeof event === "string" ? event : Object.keys(event)[0];
if (!filter.event_types.includes(eventType)) {
return false;
}
}
return true;
}
return true;
}
/**
* Subscribe to filtered events
* Reuses existing subscription if filter matches
* Handles concurrent subscription requests for the same filter
*/
async subscribe(
filter: EventFilter,
callback: (event: Event) => void,
): Promise<() => void> {
const key = this.getFilterKey(filter);
/**
* Subscribe to filtered events
* Reuses existing subscription if filter matches
* Handles concurrent subscription requests for the same filter
*/
async subscribe(
filter: EventFilter,
callback: (event: Event) => void
): Promise<() => void> {
const key = this.getFilterKey(filter);
let entry = this.subscriptions.get(key);
if (entry) {
entry.listeners.add(callback);
entry.refCount++;
return this.createCleanup(key, callback);
}
let entry = this.subscriptions.get(key);
if (entry) {
entry.listeners.add(callback);
entry.refCount++;
return this.createCleanup(key, callback);
}
const pending = this.pendingSubscriptions.get(key);
if (pending) {
entry = await pending;
entry.listeners.add(callback);
entry.refCount++;
return this.createCleanup(key, callback);
}
const pending = this.pendingSubscriptions.get(key);
if (pending) {
entry = await pending;
entry.listeners.add(callback);
entry.refCount++;
return this.createCleanup(key, callback);
}
const subscriptionPromise = this.createSubscription(key, filter);
this.pendingSubscriptions.set(key, subscriptionPromise);
const subscriptionPromise = this.createSubscription(key, filter);
this.pendingSubscriptions.set(key, subscriptionPromise);
try {
entry = await subscriptionPromise;
entry.listeners.add(callback);
entry.refCount++;
return this.createCleanup(key, callback);
} finally {
this.pendingSubscriptions.delete(key);
}
}
try {
entry = await subscriptionPromise;
entry.listeners.add(callback);
entry.refCount++;
return this.createCleanup(key, callback);
} finally {
this.pendingSubscriptions.delete(key);
}
}
private async createSubscription(
key: string,
filter: EventFilter,
): Promise<SubscriptionEntry> {
console.log("[SubscriptionManager] Creating subscription for:", filter);
private async createSubscription(
key: string,
filter: EventFilter
): Promise<SubscriptionEntry> {
console.log("[SubscriptionManager] Creating subscription for:", filter);
const unsubscribe = await this.transport.subscribe((event) => {
console.log("[SubscriptionManager] Received event:", typeof event === "string" ? event : Object.keys(event)[0]);
const currentEntry = this.subscriptions.get(key);
if (currentEntry && this.matchesFilter(event, filter)) {
console.log("[SubscriptionManager] Event matches filter, notifying", currentEntry.listeners.size, "listeners");
currentEntry.listeners.forEach((listener) => listener(event));
} else {
console.log("[SubscriptionManager] Event filtered out");
}
});
const unsubscribe = await this.transport.subscribe((event) => {
console.log(
"[SubscriptionManager] Received event:",
typeof event === "string" ? event : Object.keys(event)[0]
);
const currentEntry = this.subscriptions.get(key);
if (currentEntry && this.matchesFilter(event, filter)) {
console.log(
"[SubscriptionManager] Event matches filter, notifying",
currentEntry.listeners.size,
"listeners"
);
currentEntry.listeners.forEach((listener) => listener(event));
} else {
console.log("[SubscriptionManager] Event filtered out");
}
});
const entry: SubscriptionEntry = {
unsubscribe,
listeners: new Set(),
refCount: 0,
filter,
};
const entry: SubscriptionEntry = {
unsubscribe,
listeners: new Set(),
refCount: 0,
filter,
};
this.subscriptions.set(key, entry);
return entry;
}
this.subscriptions.set(key, entry);
return entry;
}
private createCleanup(
key: string,
callback: (event: Event) => void,
): () => void {
return () => {
const currentEntry = this.subscriptions.get(key);
if (!currentEntry) return;
private createCleanup(
key: string,
callback: (event: Event) => void
): () => void {
return () => {
const currentEntry = this.subscriptions.get(key);
if (!currentEntry) return;
currentEntry.listeners.delete(callback);
currentEntry.refCount--;
currentEntry.listeners.delete(callback);
currentEntry.refCount--;
if (currentEntry.refCount === 0) {
currentEntry.unsubscribe();
this.subscriptions.delete(key);
}
};
}
if (currentEntry.refCount === 0) {
currentEntry.unsubscribe();
this.subscriptions.delete(key);
}
};
}
/**
* Get stats for debugging
*/
getStats() {
return {
activeSubscriptions: this.subscriptions.size,
subscriptions: Array.from(this.subscriptions.entries()).map(
([key, entry]) => ({
key,
refCount: entry.refCount,
listenerCount: entry.listeners.size,
}),
),
};
}
/**
* Get stats for debugging
*/
getStats() {
return {
activeSubscriptions: this.subscriptions.size,
subscriptions: Array.from(this.subscriptions.entries()).map(
([key, entry]) => ({
key,
refCount: entry.refCount,
listenerCount: entry.listeners.size,
})
),
};
}
/**
* Force cleanup all subscriptions (for testing/cleanup)
*/
destroy() {
console.log(
`[SubscriptionManager] Destroying ${this.subscriptions.size} subscriptions`,
);
this.subscriptions.forEach((entry) => entry.unsubscribe());
this.subscriptions.clear();
}
/**
* Force cleanup all subscriptions (for testing/cleanup)
*/
destroy() {
console.log(
`[SubscriptionManager] Destroying ${this.subscriptions.size} subscriptions`
);
this.subscriptions.forEach((entry) => entry.unsubscribe());
this.subscriptions.clear();
}
}

View File

@@ -1,40 +1,40 @@
import { SDMobileCore, CoreEvent } from "sd-mobile-core";
import type { Event } from "@sd/ts-client/src/generated/types";
import { type CoreEvent, SDMobileCore } from "sd-mobile-core";
export interface EventFilter {
library_id?: string;
job_id?: string;
device_id?: string;
resource_type?: string;
path_scope?: any;
include_descendants?: boolean;
library_id?: string;
job_id?: string;
device_id?: string;
resource_type?: string;
path_scope?: any;
include_descendants?: boolean;
}
export interface SubscriptionOptions {
event_types?: string[];
filter?: EventFilter;
event_types?: string[];
filter?: EventFilter;
}
export interface JsonRpcRequest {
jsonrpc: "2.0";
id: string;
method: string;
params: {
input: unknown;
library_id?: string;
};
jsonrpc: "2.0";
id: string;
method: string;
params: {
input: unknown;
library_id?: string;
};
}
export interface JsonRpcResponse {
jsonrpc: "2.0";
id: string;
result?: unknown;
error?: { code: number; message: string };
jsonrpc: "2.0";
id: string;
result?: unknown;
error?: { code: number; message: string };
}
type PendingRequest = {
resolve: (result: unknown) => void;
reject: (error: Error) => void;
resolve: (result: unknown) => void;
reject: (error: Error) => void;
};
let requestCounter = 0;
@@ -44,130 +44,139 @@ let requestCounter = 0;
* Batches requests for efficiency and handles JSON-RPC protocol.
*/
export class ReactNativeTransport {
private pendingRequests = new Map<string, PendingRequest>();
private batch: JsonRpcRequest[] = [];
private batchQueued = false;
private pendingRequests = new Map<string, PendingRequest>();
private batch: JsonRpcRequest[] = [];
private batchQueued = false;
constructor() {
// No event listener needed - responses come through sendMessage promise
}
constructor() {
// No event listener needed - responses come through sendMessage promise
}
private processResponse = (response: JsonRpcResponse) => {
console.log("[Transport] 🔄 Processing response for ID:", response.id);
const pending = this.pendingRequests.get(response.id);
if (!pending) {
console.warn("[Transport] ⚠️ No pending request for ID:", response.id);
return;
}
private processResponse = (response: JsonRpcResponse) => {
console.log("[Transport] 🔄 Processing response for ID:", response.id);
const pending = this.pendingRequests.get(response.id);
if (!pending) {
console.warn("[Transport] ⚠️ No pending request for ID:", response.id);
return;
}
if (response.error) {
console.error("[Transport] ❌ Response error:", response.error);
pending.reject(new Error(response.error.message));
} else {
console.log("[Transport] ✅ Response success");
pending.resolve(response.result);
}
if (response.error) {
console.error("[Transport] ❌ Response error:", response.error);
pending.reject(new Error(response.error.message));
} else {
console.log("[Transport] ✅ Response success");
pending.resolve(response.result);
}
this.pendingRequests.delete(response.id);
};
this.pendingRequests.delete(response.id);
};
private queueBatch() {
if (this.batchQueued) return;
this.batchQueued = true;
private queueBatch() {
if (this.batchQueued) return;
this.batchQueued = true;
// Use setImmediate-like behavior for batching
setTimeout(async () => {
const currentBatch = [...this.batch];
this.batch = [];
this.batchQueued = false;
// Use setImmediate-like behavior for batching
setTimeout(async () => {
const currentBatch = [...this.batch];
this.batch = [];
this.batchQueued = false;
if (currentBatch.length === 0) return;
if (currentBatch.length === 0) return;
try {
const query = JSON.stringify(
currentBatch.length === 1 ? currentBatch[0] : currentBatch,
);
console.log("[Transport] 📤 Sending request:", query.substring(0, 200));
const resultStr = await SDMobileCore.sendMessage(query);
console.log("[Transport] 📥 Got response:", resultStr.substring(0, 200));
const result = JSON.parse(resultStr);
try {
const query = JSON.stringify(
currentBatch.length === 1 ? currentBatch[0] : currentBatch
);
console.log("[Transport] 📤 Sending request:", query.substring(0, 200));
const resultStr = await SDMobileCore.sendMessage(query);
console.log(
"[Transport] 📥 Got response:",
resultStr.substring(0, 200)
);
const result = JSON.parse(resultStr);
if (Array.isArray(result)) {
result.forEach(this.processResponse);
} else {
this.processResponse(result);
}
} catch (e) {
console.error("[Transport] ❌ Batch request failed:", e);
// Reject all pending requests in batch
for (const req of currentBatch) {
const pending = this.pendingRequests.get(req.id);
if (pending) {
pending.reject(new Error("Batch request failed"));
this.pendingRequests.delete(req.id);
}
}
}
}, 0);
}
if (Array.isArray(result)) {
result.forEach(this.processResponse);
} else {
this.processResponse(result);
}
} catch (e) {
console.error("[Transport] ❌ Batch request failed:", e);
// Reject all pending requests in batch
for (const req of currentBatch) {
const pending = this.pendingRequests.get(req.id);
if (pending) {
pending.reject(new Error("Batch request failed"));
this.pendingRequests.delete(req.id);
}
}
}
}, 0);
}
/**
* Send a request to the core and return a promise with the result.
*/
async request<T = unknown>(
method: string,
params: { input: unknown; library_id?: string },
): Promise<T> {
return new Promise((resolve, reject) => {
const id = `${++requestCounter}`;
/**
* Send a request to the core and return a promise with the result.
*/
async request<T = unknown>(
method: string,
params: { input: unknown; library_id?: string }
): Promise<T> {
return new Promise((resolve, reject) => {
const id = `${++requestCounter}`;
this.pendingRequests.set(id, {
resolve: resolve as (result: unknown) => void,
reject,
});
this.pendingRequests.set(id, {
resolve: resolve as (result: unknown) => void,
reject,
});
this.batch.push({
jsonrpc: "2.0",
id,
method,
params,
});
this.batch.push({
jsonrpc: "2.0",
id,
method,
params,
});
this.queueBatch();
});
}
this.queueBatch();
});
}
/**
* Subscribe to events from the embedded core.
* Note: Mobile core doesn't support per-subscription filtering yet.
* All filtering happens client-side via SubscriptionManager.
*/
async subscribe(
callback: (event: Event) => void,
_options?: SubscriptionOptions,
): Promise<() => void> {
console.log("[Transport] 🎧 Subscribing to core events...");
/**
* Subscribe to events from the embedded core.
* Note: Mobile core doesn't support per-subscription filtering yet.
* All filtering happens client-side via SubscriptionManager.
*/
async subscribe(
callback: (event: Event) => void,
_options?: SubscriptionOptions
): Promise<() => void> {
console.log("[Transport] 🎧 Subscribing to core events...");
const unlisten = SDMobileCore.addListener((coreEvent: CoreEvent) => {
console.log("[Transport] 📨 Raw event received:", coreEvent.body.substring(0, 100));
try {
const event = JSON.parse(coreEvent.body) as Event;
console.log("[Transport] ✅ Event parsed:", typeof event === "string" ? event : Object.keys(event)[0]);
callback(event);
} catch (e) {
console.error("[Transport] ❌ Failed to parse event:", e);
}
});
const unlisten = SDMobileCore.addListener((coreEvent: CoreEvent) => {
console.log(
"[Transport] 📨 Raw event received:",
coreEvent.body.substring(0, 100)
);
try {
const event = JSON.parse(coreEvent.body) as Event;
console.log(
"[Transport] ✅ Event parsed:",
typeof event === "string" ? event : Object.keys(event)[0]
);
callback(event);
} catch (e) {
console.error("[Transport] ❌ Failed to parse event:", e);
}
});
console.log("[Transport] ✅ Event listener registered");
return unlisten;
}
console.log("[Transport] ✅ Event listener registered");
return unlisten;
}
/**
* Clean up resources.
*/
destroy() {
this.pendingRequests.clear();
this.batch = [];
}
/**
* Clean up resources.
*/
destroy() {
this.pendingRequests.clear();
this.batch = [];
}
}

View File

@@ -1,281 +1,263 @@
import React, { useState } from "react";
import { useState } from "react";
import {
View,
Text,
Modal,
Pressable,
ScrollView,
TextInput,
Alert,
ActivityIndicator,
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCoreQuery, useCoreAction, useSpacedriveClient } from "../client";
import { useCoreAction, useCoreQuery, useSpacedriveClient } from "../client";
import { useSidebarStore } from "../stores";
interface LibrarySwitcherPanelProps {
isOpen: boolean;
onClose: () => void;
isOpen: boolean;
onClose: () => void;
}
export function LibrarySwitcherPanel({
isOpen,
onClose,
isOpen,
onClose,
}: LibrarySwitcherPanelProps) {
const insets = useSafeAreaInsets();
const client = useSpacedriveClient();
const { currentLibraryId, setCurrentLibrary: setStoreLibrary } =
useSidebarStore();
const [showCreateForm, setShowCreateForm] = useState(false);
const [newLibraryName, setNewLibraryName] = useState("");
const insets = useSafeAreaInsets();
const client = useSpacedriveClient();
const { currentLibraryId, setCurrentLibrary: setStoreLibrary } =
useSidebarStore();
const [showCreateForm, setShowCreateForm] = useState(false);
const [newLibraryName, setNewLibraryName] = useState("");
const { data: libraries, refetch } = useCoreQuery("libraries.list", {
include_stats: false,
});
const { data: libraries, refetch } = useCoreQuery("libraries.list", {
include_stats: false,
});
const createLibrary = useCoreAction("libraries.create");
const deleteLibrary = useCoreAction("libraries.delete");
const createLibrary = useCoreAction("libraries.create");
const deleteLibrary = useCoreAction("libraries.delete");
const handleSelectLibrary = (libraryId: string) => {
console.log("[LibrarySwitcher] Selecting library:", libraryId);
setStoreLibrary(libraryId);
client.setCurrentLibrary(libraryId);
onClose();
};
const handleSelectLibrary = (libraryId: string) => {
console.log("[LibrarySwitcher] Selecting library:", libraryId);
setStoreLibrary(libraryId);
client.setCurrentLibrary(libraryId);
onClose();
};
const handleCreateLibrary = async () => {
if (!newLibraryName.trim()) return;
const handleCreateLibrary = async () => {
if (!newLibraryName.trim()) return;
try {
const result = await createLibrary.mutateAsync({
name: newLibraryName,
path: null,
});
try {
const result = await createLibrary.mutateAsync({
name: newLibraryName,
path: null,
});
setNewLibraryName("");
setShowCreateForm(false);
refetch();
setNewLibraryName("");
setShowCreateForm(false);
refetch();
// Auto-select the new library
if (result?.id) {
handleSelectLibrary(result.id);
}
} catch (error) {
Alert.alert("Error", "Failed to create library");
}
};
// Auto-select the new library
if (result?.id) {
handleSelectLibrary(result.id);
}
} catch (error) {
Alert.alert("Error", "Failed to create library");
}
};
const handleDeleteLibrary = (libraryId: string, libraryName: string) => {
Alert.alert(
"Delete Library",
`Are you sure you want to delete "${libraryName}"? This action cannot be undone.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
await deleteLibrary.mutateAsync({ id: libraryId });
refetch();
const handleDeleteLibrary = (libraryId: string, libraryName: string) => {
Alert.alert(
"Delete Library",
`Are you sure you want to delete "${libraryName}"? This action cannot be undone.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
await deleteLibrary.mutateAsync({ id: libraryId });
refetch();
// If deleted library was selected, switch to first available
if (libraryId === currentLibraryId) {
const remaining = (libraries || []).filter(
(lib: any) => lib.id !== libraryId
);
if (remaining.length > 0) {
handleSelectLibrary(remaining[0].id);
} else {
setStoreLibrary(null);
client.setCurrentLibrary(null);
}
}
} catch (error) {
Alert.alert("Error", "Failed to delete library");
}
},
},
]
);
};
// If deleted library was selected, switch to first available
if (libraryId === currentLibraryId) {
const remaining = (libraries || []).filter(
(lib: any) => lib.id !== libraryId
);
if (remaining.length > 0) {
handleSelectLibrary(remaining[0].id);
} else {
setStoreLibrary(null);
client.setCurrentLibrary(null);
}
}
} catch (error) {
Alert.alert("Error", "Failed to delete library");
}
},
},
]
);
};
if (!isOpen) return null;
if (!isOpen) return null;
return (
<Modal
visible={isOpen}
animationType="slide"
transparent
onRequestClose={onClose}
>
<View className="flex-1 bg-black/50">
<View
className="flex-1 bg-app-box rounded-t-3xl overflow-hidden"
style={{ marginTop: insets.top + 40 }}
>
{/* Header */}
<View className="px-6 py-4 border-b border-app-line">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-lg font-semibold text-ink">
Libraries
</Text>
<Text className="text-xs text-ink-dull mt-0.5">
Switch or manage your libraries
</Text>
</View>
<Pressable
onPress={onClose}
className="p-2 active:bg-app-hover rounded-lg"
>
<Text className="text-ink-dull text-xl"></Text>
</Pressable>
</View>
</View>
return (
<Modal
animationType="slide"
onRequestClose={onClose}
transparent
visible={isOpen}
>
<View className="flex-1 bg-black/50">
<View
className="flex-1 overflow-hidden rounded-t-3xl bg-app-box"
style={{ marginTop: insets.top + 40 }}
>
{/* Header */}
<View className="border-app-line border-b px-6 py-4">
<View className="flex-row items-center justify-between">
<View>
<Text className="font-semibold text-ink text-lg">
Libraries
</Text>
<Text className="mt-0.5 text-ink-dull text-xs">
Switch or manage your libraries
</Text>
</View>
<Pressable
className="rounded-lg p-2 active:bg-app-hover"
onPress={onClose}
>
<Text className="text-ink-dull text-xl"></Text>
</Pressable>
</View>
</View>
{/* Content */}
<ScrollView
className="flex-1"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
>
{/* Libraries List */}
<View className="gap-2 mb-4">
{libraries &&
Array.isArray(libraries) &&
libraries.map((lib: any) => (
<View
key={lib.id}
className={`p-4 rounded-lg border ${
currentLibraryId === lib.id
? "bg-accent/10 border-accent/30"
: "bg-app-darkBox border-app-line"
}`}
>
<Pressable
onPress={() =>
handleSelectLibrary(lib.id)
}
className="flex-1"
>
<View className="flex-row items-center justify-between">
<View className="flex-1">
<Text
className={`font-semibold text-base ${
currentLibraryId ===
lib.id
? "text-accent"
: "text-ink"
}`}
>
{lib.name}
</Text>
{lib.description && (
<Text className="text-xs text-ink-dull mt-0.5">
{lib.description}
</Text>
)}
{currentLibraryId ===
lib.id && (
<Text className="text-xs text-accent mt-1">
Currently active
</Text>
)}
</View>
{currentLibraryId !== lib.id && (
<Pressable
onPress={() =>
handleDeleteLibrary(
lib.id,
lib.name
)
}
className="p-2 active:bg-app-hover rounded-lg ml-2"
>
<Text className="text-red-500 text-lg">
🗑
</Text>
</Pressable>
)}
</View>
</Pressable>
</View>
))}
</View>
{/* Content */}
<ScrollView
className="flex-1"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
>
{/* Libraries List */}
<View className="mb-4 gap-2">
{libraries &&
Array.isArray(libraries) &&
libraries.map((lib: any) => (
<View
className={`rounded-lg border p-4 ${
currentLibraryId === lib.id
? "border-accent/30 bg-accent/10"
: "border-app-line bg-app-darkBox"
}`}
key={lib.id}
>
<Pressable
className="flex-1"
onPress={() => handleSelectLibrary(lib.id)}
>
<View className="flex-row items-center justify-between">
<View className="flex-1">
<Text
className={`font-semibold text-base ${
currentLibraryId === lib.id
? "text-accent"
: "text-ink"
}`}
>
{lib.name}
</Text>
{lib.description && (
<Text className="mt-0.5 text-ink-dull text-xs">
{lib.description}
</Text>
)}
{currentLibraryId === lib.id && (
<Text className="mt-1 text-accent text-xs">
Currently active
</Text>
)}
</View>
{currentLibraryId !== lib.id && (
<Pressable
className="ml-2 rounded-lg p-2 active:bg-app-hover"
onPress={() =>
handleDeleteLibrary(lib.id, lib.name)
}
>
<Text className="text-lg text-red-500">🗑</Text>
</Pressable>
)}
</View>
</Pressable>
</View>
))}
</View>
{/* Create Library Section */}
{!showCreateForm ? (
<Pressable
onPress={() => setShowCreateForm(true)}
className="flex-row items-center justify-center gap-2 p-4 border-2 border-dashed border-accent rounded-lg bg-accent/5 active:bg-accent/10"
>
<Text className="text-accent text-xl">+</Text>
<Text className="text-accent font-medium">
Create New Library
</Text>
</Pressable>
) : (
<View className="p-4 bg-app-darkBox border border-app-line rounded-lg gap-4">
<View>
<Text className="text-sm font-medium text-ink mb-2">
Library Name
</Text>
<TextInput
value={newLibraryName}
onChangeText={setNewLibraryName}
placeholder="My Photos"
placeholderTextColor="hsl(235, 10%, 55%)"
className="px-4 py-3 bg-sidebar-box border border-sidebar-line rounded-lg text-ink"
autoFocus
/>
</View>
{/* Create Library Section */}
{showCreateForm ? (
<View className="gap-4 rounded-lg border border-app-line bg-app-darkBox p-4">
<View>
<Text className="mb-2 font-medium text-ink text-sm">
Library Name
</Text>
<TextInput
autoFocus
className="rounded-lg border border-sidebar-line bg-sidebar-box px-4 py-3 text-ink"
onChangeText={setNewLibraryName}
placeholder="My Photos"
placeholderTextColor="hsl(235, 10%, 55%)"
value={newLibraryName}
/>
</View>
<View className="flex-row gap-3">
<Pressable
onPress={() => {
setShowCreateForm(false);
setNewLibraryName("");
}}
className="flex-1 px-4 py-2.5 bg-app-box border border-app-line rounded-lg active:bg-app-hover"
>
<Text className="text-ink-dull font-medium text-center">
Cancel
</Text>
</Pressable>
<Pressable
onPress={handleCreateLibrary}
disabled={
!newLibraryName.trim() ||
createLibrary.isPending
}
className={`flex-1 flex-row items-center justify-center gap-2 px-4 py-2.5 rounded-lg ${
!newLibraryName.trim() ||
createLibrary.isPending
? "bg-accent/50"
: "bg-accent active:bg-accent/90"
}`}
>
{createLibrary.isPending && (
<ActivityIndicator
size="small"
color="white"
/>
)}
<Text className="text-white font-medium">
{createLibrary.isPending
? "Creating..."
: "Create"}
</Text>
</Pressable>
</View>
</View>
)}
</ScrollView>
</View>
</View>
</Modal>
);
<View className="flex-row gap-3">
<Pressable
className="flex-1 rounded-lg border border-app-line bg-app-box px-4 py-2.5 active:bg-app-hover"
onPress={() => {
setShowCreateForm(false);
setNewLibraryName("");
}}
>
<Text className="text-center font-medium text-ink-dull">
Cancel
</Text>
</Pressable>
<Pressable
className={`flex-1 flex-row items-center justify-center gap-2 rounded-lg px-4 py-2.5 ${
!newLibraryName.trim() || createLibrary.isPending
? "bg-accent/50"
: "bg-accent active:bg-accent/90"
}`}
disabled={!newLibraryName.trim() || createLibrary.isPending}
onPress={handleCreateLibrary}
>
{createLibrary.isPending && (
<ActivityIndicator color="white" size="small" />
)}
<Text className="font-medium text-white">
{createLibrary.isPending ? "Creating..." : "Create"}
</Text>
</Pressable>
</View>
</View>
) : (
<Pressable
className="flex-row items-center justify-center gap-2 rounded-lg border-2 border-accent border-dashed bg-accent/5 p-4 active:bg-accent/10"
onPress={() => setShowCreateForm(true)}
>
<Text className="text-accent text-xl">+</Text>
<Text className="font-medium text-accent">
Create New Library
</Text>
</Pressable>
)}
</ScrollView>
</View>
</View>
</Modal>
);
}

View File

@@ -1,18 +1,18 @@
import React, { useState, useEffect } from "react";
import Clipboard from "@react-native-clipboard/clipboard";
import { CameraView, useCameraPermissions } from "expo-camera";
import React, { useEffect, useState } from "react";
import {
View,
Text,
Modal,
Pressable,
TextInput,
ScrollView,
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CameraView, useCameraPermissions } from "expo-camera";
import QRCode from "react-native-qrcode-svg";
import Clipboard from "@react-native-clipboard/clipboard";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useCoreAction, useCoreQuery } from "../client";
interface PairingPanelProps {
@@ -39,7 +39,7 @@ export function PairingPanel({
const { data: pairingStatus, refetch: refetchStatus } = useCoreQuery(
"network.pair.status",
null,
null
);
// Poll status when in active pairing
@@ -96,7 +96,7 @@ export function PairingPanel({
// QR code contains: { version, words, node_id, relay_url, session_id }
if (parsed.words || parsed.code) {
console.log(
"[PairingPanel] QR scanned, auto-joining with full QR data",
"[PairingPanel] QR scanned, auto-joining with full QR data"
);
const words = parsed.words || parsed.code;
setJoinCode(words);
@@ -111,7 +111,7 @@ export function PairingPanel({
// If not JSON, treat as plain word code (local pairing)
console.log(
"[PairingPanel] QR scanned, auto-joining with plain code:",
data,
data
);
setJoinCode(data);
setShowScanner(false);
@@ -129,7 +129,7 @@ export function PairingPanel({
if (!result.granted) {
Alert.alert(
"Camera Permission",
"Camera access is required to scan QR codes",
"Camera access is required to scan QR codes"
);
return;
}
@@ -153,30 +153,30 @@ export function PairingPanel({
return (
<Modal
visible={isOpen}
animationType="slide"
transparent
onRequestClose={handleClose}
transparent
visible={isOpen}
>
<View className="flex-1 bg-black/50">
<View
className="flex-1 bg-app-box rounded-t-3xl overflow-hidden"
className="flex-1 overflow-hidden rounded-t-3xl bg-app-box"
style={{ marginTop: insets.top + 40 }}
>
{/* Header */}
<View className="px-6 py-4 border-b border-app-line">
<View className="border-app-line border-b px-6 py-4">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-lg font-semibold text-ink">
<Text className="font-semibold text-ink text-lg">
Device Pairing
</Text>
<Text className="text-xs text-ink-dull mt-0.5">
<Text className="mt-0.5 text-ink-dull text-xs">
Connect another device to share files
</Text>
</View>
<Pressable
className="rounded-lg p-2 active:bg-app-hover"
onPress={handleClose}
className="p-2 active:bg-app-hover rounded-lg"
>
<Text className="text-ink-dull text-xl"></Text>
</Pressable>
@@ -184,15 +184,15 @@ export function PairingPanel({
</View>
{/* Mode Tabs */}
<View className="flex-row border-b border-app-line">
<View className="flex-row border-app-line border-b">
<Pressable
onPress={() => setMode("generate")}
className={`flex-1 px-6 py-3 ${
mode === "generate" ? "border-b-2 border-accent" : ""
mode === "generate" ? "border-accent border-b-2" : ""
}`}
onPress={() => setMode("generate")}
>
<Text
className={`text-sm font-medium text-center ${
className={`text-center font-medium text-sm ${
mode === "generate" ? "text-accent" : "text-ink-dull"
}`}
>
@@ -200,13 +200,13 @@ export function PairingPanel({
</Text>
</Pressable>
<Pressable
onPress={() => setMode("join")}
className={`flex-1 px-6 py-3 ${
mode === "join" ? "border-b-2 border-accent" : ""
mode === "join" ? "border-accent border-b-2" : ""
}`}
onPress={() => setMode("join")}
>
<Text
className={`text-sm font-medium text-center ${
className={`text-center font-medium text-sm ${
mode === "join" ? "text-accent" : "text-ink-dull"
}`}
>
@@ -225,40 +225,40 @@ export function PairingPanel({
>
{mode === "generate" ? (
<GenerateMode
generatePairing={generatePairing}
currentSession={currentSession}
onGenerate={handleGenerate}
generatePairing={generatePairing}
onCancel={handleCancel}
onCopyCode={copyCode}
onGenerate={handleGenerate}
/>
) : showScanner ? (
<ScannerMode
onScan={handleQRScan}
onClose={() => setShowScanner(false)}
onScan={handleQRScan}
/>
) : (
<JoinMode
joinCode={joinCode}
setJoinCode={setJoinCode}
joinNodeId={joinNodeId}
setJoinNodeId={setJoinNodeId}
joinPairing={joinPairing}
currentSession={currentSession}
onJoin={handleJoin}
joinCode={joinCode}
joinNodeId={joinNodeId}
joinPairing={joinPairing}
onCancel={handleCancel}
onJoin={handleJoin}
onOpenScanner={openScanner}
setJoinCode={setJoinCode}
setJoinNodeId={setJoinNodeId}
/>
)}
{/* Success State */}
{isCompleted && (
<View className="flex-row items-center gap-3 p-4 bg-accent/10 border border-accent/30 rounded-lg mt-6">
<View className="mt-6 flex-row items-center gap-3 rounded-lg border border-accent/30 bg-accent/10 p-4">
<Text className="text-accent text-xl"></Text>
<View className="flex-1">
<Text className="text-sm font-medium text-accent">
<Text className="font-medium text-accent text-sm">
Pairing successful!
</Text>
<Text className="text-xs text-ink-dull mt-0.5">
<Text className="mt-0.5 text-ink-dull text-xs">
{joinPairing.data
? `Connected to ${joinPairing.data.device_name}`
: "Device paired"}
@@ -286,63 +286,33 @@ function GenerateMode({
return (
<View className="gap-6">
{!hasCode ? (
<>
{/* Info */}
<View className="flex-row gap-3 p-4 bg-sidebar-box/40 rounded-lg border border-sidebar-line">
<View className="w-10 h-10 rounded-full bg-accent/10 items-center justify-center">
<Text className="text-accent text-xl"></Text>
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-ink">How it works</Text>
<Text className="text-xs text-ink-dull mt-1">
Generate a secure code to share with another device. They'll
scan or enter the code to connect.
</Text>
</View>
</View>
{/* Generate Button */}
<Pressable
onPress={onGenerate}
disabled={isLoading}
className={`flex-row items-center justify-center gap-2 px-4 py-3 rounded-lg ${
isLoading ? "bg-accent/50" : "bg-accent active:bg-accent/90"
}`}
>
{isLoading && <ActivityIndicator size="small" color="white" />}
<Text className="text-white font-medium">
{isLoading ? "Generating..." : "Generate Pairing Code"}
</Text>
</Pressable>
</>
) : (
{hasCode ? (
<>
{/* QR Code */}
<View className="items-center gap-3">
<Text className="text-sm text-ink-dull">
<Text className="text-ink-dull text-sm">
Scan with mobile device
</Text>
<View className="p-4 bg-white rounded-lg">
<QRCode value={generatePairing.data.qr_json} size={200} />
<View className="rounded-lg bg-white p-4">
<QRCode size={200} value={generatePairing.data.qr_json} />
</View>
</View>
{/* Word Code */}
<View>
<Text className="text-xs font-medium text-ink-dull uppercase tracking-wider mb-2">
<Text className="mb-2 font-medium text-ink-dull text-xs uppercase tracking-wider">
Or type manually:
</Text>
<View className="p-4 bg-sidebar-box border border-sidebar-line rounded-lg">
<Text className="font-mono text-sm text-ink">
<View className="rounded-lg border border-sidebar-line bg-sidebar-box p-4">
<Text className="font-mono text-ink text-sm">
{generatePairing.data.code}
</Text>
</View>
<Pressable
className="mt-2 rounded-lg border border-app-line bg-app-box p-2 active:bg-app-hover"
onPress={onCopyCode}
className="mt-2 p-2 bg-app-box border border-app-line rounded-lg active:bg-app-hover"
>
<Text className="text-sm text-ink-dull text-center">
<Text className="text-center text-ink-dull text-sm">
Copy Code
</Text>
</Pressable>
@@ -350,9 +320,9 @@ function GenerateMode({
{/* Status */}
{state && (
<View className="flex-row items-center gap-2 p-3 bg-app-box/40 rounded-lg border border-app-line">
<View className="w-2 h-2 rounded-full bg-accent" />
<Text className="text-sm text-ink-dull">
<View className="flex-row items-center gap-2 rounded-lg border border-app-line bg-app-box/40 p-3">
<View className="h-2 w-2 rounded-full bg-accent" />
<Text className="text-ink-dull text-sm">
{state === "Broadcasting" || state === "WaitingForConnection"
? "Waiting for device to connect..."
: state === "Authenticating"
@@ -366,25 +336,55 @@ function GenerateMode({
{/* Cancel Button */}
<Pressable
className="rounded-lg border border-app-line bg-app-box px-4 py-2.5 active:bg-app-hover"
onPress={onCancel}
className="px-4 py-2.5 bg-app-box border border-app-line rounded-lg active:bg-app-hover"
>
<Text className="text-sm font-medium text-ink-dull text-center">
<Text className="text-center font-medium text-ink-dull text-sm">
Cancel
</Text>
</Pressable>
</>
) : (
<>
{/* Info */}
<View className="flex-row gap-3 rounded-lg border border-sidebar-line bg-sidebar-box/40 p-4">
<View className="h-10 w-10 items-center justify-center rounded-full bg-accent/10">
<Text className="text-accent text-xl"></Text>
</View>
<View className="flex-1">
<Text className="font-medium text-ink text-sm">How it works</Text>
<Text className="mt-1 text-ink-dull text-xs">
Generate a secure code to share with another device. They'll
scan or enter the code to connect.
</Text>
</View>
</View>
{/* Generate Button */}
<Pressable
className={`flex-row items-center justify-center gap-2 rounded-lg px-4 py-3 ${
isLoading ? "bg-accent/50" : "bg-accent active:bg-accent/90"
}`}
disabled={isLoading}
onPress={onGenerate}
>
{isLoading && <ActivityIndicator color="white" size="small" />}
<Text className="font-medium text-white">
{isLoading ? "Generating..." : "Generate Pairing Code"}
</Text>
</Pressable>
</>
)}
{/* Error */}
{generatePairing.isError && (
<View className="flex-row gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<View className="flex-row gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3">
<Text className="text-red-500 text-xl"></Text>
<View className="flex-1">
<Text className="text-sm font-medium text-red-500">
<Text className="font-medium text-red-500 text-sm">
Failed to generate code
</Text>
<Text className="text-xs text-ink-dull mt-0.5">
<Text className="mt-0.5 text-ink-dull text-xs">
{String(generatePairing.error)}
</Text>
</View>
@@ -411,15 +411,15 @@ function JoinMode({
return (
<View className="gap-6">
{/* Instructions */}
<View className="flex-row gap-3 p-4 bg-sidebar-box/40 rounded-lg border border-sidebar-line">
<View className="w-10 h-10 rounded-full bg-accent/10 items-center justify-center">
<View className="flex-row gap-3 rounded-lg border border-sidebar-line bg-sidebar-box/40 p-4">
<View className="h-10 w-10 items-center justify-center rounded-full bg-accent/10">
<Text className="text-accent text-xl"></Text>
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-ink">
<Text className="font-medium text-ink text-sm">
Enter pairing code
</Text>
<Text className="text-xs text-ink-dull mt-1">
<Text className="mt-1 text-ink-dull text-xs">
Scan the QR code or enter the 12-word code from the other device.
</Text>
</View>
@@ -427,13 +427,13 @@ function JoinMode({
{/* QR Scanner Button */}
<Pressable
onPress={onOpenScanner}
disabled={isLoading || !!state}
className={`flex-row items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-dashed ${
className={`flex-row items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-3 ${
isLoading || state
? "border-app-line bg-app-box/50"
: "border-accent bg-accent/10 active:bg-accent/20"
}`}
disabled={isLoading || !!state}
onPress={onOpenScanner}
>
<Text
className={`text-xl ${
@@ -453,45 +453,45 @@ function JoinMode({
{/* Code Input */}
<View>
<Text className="text-xs font-medium text-ink-dull uppercase tracking-wider mb-2">
<Text className="mb-2 font-medium text-ink-dull text-xs uppercase tracking-wider">
Or Enter Code Manually
</Text>
<TextInput
value={joinCode}
className="rounded-lg border border-sidebar-line bg-sidebar-box px-4 py-3 text-ink text-sm"
editable={!(isLoading || state)}
multiline
numberOfLines={3}
onChangeText={setJoinCode}
placeholder="brave-lion-sunset-river-eagle-mountain..."
placeholderTextColor="hsl(235, 10%, 55%)"
editable={!isLoading && !state}
multiline
numberOfLines={3}
className="px-4 py-3 bg-sidebar-box border border-sidebar-line rounded-lg text-sm text-ink"
style={{ textAlignVertical: "top" }}
value={joinCode}
/>
<Text className="text-xs text-ink-dull mt-2">
<Text className="mt-2 text-ink-dull text-xs">
Paste the full code or type the 12 words separated by hyphens
</Text>
</View>
{/* Node ID Input */}
<View>
<Text className="text-xs font-medium text-ink-dull uppercase tracking-wider mb-2">
<Text className="mb-2 font-medium text-ink-dull text-xs uppercase tracking-wider">
Node ID <Text className="text-ink-faint">(optional)</Text>
</Text>
<TextInput
value={joinNodeId}
className="rounded-lg border border-sidebar-line bg-sidebar-box px-4 py-2.5 font-mono text-ink text-xs"
editable={!(isLoading || state)}
onChangeText={setJoinNodeId}
placeholder="For cross-network pairing"
placeholderTextColor="hsl(235, 10%, 55%)"
editable={!isLoading && !state}
className="px-4 py-2.5 bg-sidebar-box border border-sidebar-line rounded-lg text-xs text-ink font-mono"
value={joinNodeId}
/>
</View>
{/* Status */}
{state && (
<View className="flex-row items-center gap-2 p-3 bg-app-box/40 rounded-lg border border-app-line">
<View className="w-2 h-2 rounded-full bg-accent" />
<Text className="text-sm text-ink-dull">
<View className="flex-row items-center gap-2 rounded-lg border border-app-line bg-app-box/40 p-3">
<View className="h-2 w-2 rounded-full bg-accent" />
<Text className="text-ink-dull text-sm">
{state === "Scanning" || state === "Connecting"
? "Finding device..."
: state === "Authenticating"
@@ -509,34 +509,34 @@ function JoinMode({
<View className="flex-row gap-3">
{state ? (
<Pressable
className="flex-1 rounded-lg border border-app-line bg-app-box px-4 py-2.5 active:bg-app-hover"
onPress={onCancel}
className="flex-1 px-4 py-2.5 bg-app-box border border-app-line rounded-lg active:bg-app-hover"
>
<Text className="text-sm font-medium text-ink-dull text-center">
<Text className="text-center font-medium text-ink-dull text-sm">
Cancel
</Text>
</Pressable>
) : (
<>
<Pressable
className="rounded-lg border border-app-line bg-app-box px-6 py-2.5 active:bg-app-hover"
onPress={onCancel}
className="px-6 py-2.5 bg-app-box border border-app-line rounded-lg active:bg-app-hover"
>
<Text className="text-sm font-medium text-ink-dull text-center">
<Text className="text-center font-medium text-ink-dull text-sm">
Clear
</Text>
</Pressable>
<Pressable
onPress={onJoin}
disabled={isLoading || !joinCode.trim()}
className={`flex-1 flex-row items-center justify-center gap-2 px-4 py-2.5 rounded-lg ${
className={`flex-1 flex-row items-center justify-center gap-2 rounded-lg px-4 py-2.5 ${
isLoading || !joinCode.trim()
? "bg-accent/50"
: "bg-accent active:bg-accent/90"
}`}
disabled={isLoading || !joinCode.trim()}
onPress={onJoin}
>
{isLoading && <ActivityIndicator size="small" color="white" />}
<Text className="text-white font-medium">
{isLoading && <ActivityIndicator color="white" size="small" />}
<Text className="font-medium text-white">
{isLoading ? "Joining..." : "Join"}
</Text>
</Pressable>
@@ -546,13 +546,13 @@ function JoinMode({
{/* Error */}
{joinPairing.isError && (
<View className="flex-row gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<View className="flex-row gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3">
<Text className="text-red-500 text-xl"></Text>
<View className="flex-1">
<Text className="text-sm font-medium text-red-500">
<Text className="font-medium text-red-500 text-sm">
Failed to join
</Text>
<Text className="text-xs text-ink-dull mt-0.5">
<Text className="mt-0.5 text-ink-dull text-xs">
{String(joinPairing.error)}
</Text>
</View>
@@ -579,26 +579,26 @@ function ScannerMode({
};
return (
<View className="flex-1 -mx-6 -my-6">
<View className="-mx-6 -my-6 flex-1">
<CameraView
style={{ flex: 1 }}
facing="back"
barcodeScannerSettings={{
barcodeTypes: ["qr"],
}}
facing="back"
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
style={{ flex: 1 }}
>
<View className="flex-1 items-center justify-center">
<View className="w-64 h-64 border-2 border-accent rounded-lg" />
<Text className="text-white text-center mt-4 px-6">
<View className="h-64 w-64 rounded-lg border-2 border-accent" />
<Text className="mt-4 px-6 text-center text-white">
{scanned ? "Scanned! Processing..." : "Point camera at QR code"}
</Text>
</View>
</CameraView>
<Pressable
className="absolute top-4 right-4 rounded-full bg-black/50 p-3"
onPress={onClose}
className="absolute top-4 right-4 p-3 bg-black/50 rounded-full"
>
<Text className="text-white text-xl"></Text>
</Pressable>

View File

@@ -1,6 +1,7 @@
import { cva, VariantProps } from "class-variance-authority";
import React, { FC } from "react";
import { Pressable, PressableProps, View, Text } from "react-native";
import { cva, type VariantProps } from "class-variance-authority";
import type React from "react";
import type { FC } from "react";
import { Pressable, type PressableProps, Text, View } from "react-native";
import { cn } from "~/utils/cn";
/**
@@ -9,142 +10,123 @@ import { cn } from "~/utils/cn";
*/
const button = cva(
"items-center justify-center rounded-xl border font-medium transition-opacity",
{
variants: {
variant: {
default: [
"bg-transparent border-app-line/80",
// Pressed state handled by Pressable render prop
],
subtle: [
"border-transparent bg-transparent",
],
outline: [
"border-sidebar-line/60 bg-transparent",
],
dotted: [
"border-dashed border-sidebar-line/70 bg-transparent",
],
gray: [
"bg-app-button border-app-line/80",
],
accent: [
"bg-accent border-accent shadow-md",
],
colored: [
"shadow-sm",
// Custom background color should be passed via style prop
],
bare: "border-transparent bg-transparent",
},
size: {
icon: "p-1",
xs: "px-2 py-1",
sm: "px-2.5 py-1.5",
md: "px-3 py-2",
lg: "px-4 py-2.5",
},
disabled: {
true: "opacity-50",
},
},
defaultVariants: {
variant: "default",
size: "sm",
},
},
"items-center justify-center rounded-xl border font-medium transition-opacity",
{
variants: {
variant: {
default: [
"border-app-line/80 bg-transparent",
// Pressed state handled by Pressable render prop
],
subtle: ["border-transparent bg-transparent"],
outline: ["border-sidebar-line/60 bg-transparent"],
dotted: ["border-sidebar-line/70 border-dashed bg-transparent"],
gray: ["border-app-line/80 bg-app-button"],
accent: ["border-accent bg-accent shadow-md"],
colored: [
"shadow-sm",
// Custom background color should be passed via style prop
],
bare: "border-transparent bg-transparent",
},
size: {
icon: "p-1",
xs: "px-2 py-1",
sm: "px-2.5 py-1.5",
md: "px-3 py-2",
lg: "px-4 py-2.5",
},
disabled: {
true: "opacity-50",
},
},
defaultVariants: {
variant: "default",
size: "sm",
},
}
);
const buttonText = cva("font-medium text-center", {
variants: {
variant: {
default: "text-ink",
subtle: "text-ink",
outline: "text-ink",
dotted: "text-ink-faint",
gray: "text-ink",
accent: "text-white",
colored: "text-white",
bare: "text-ink",
},
size: {
icon: "text-sm",
xs: "text-sm",
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
},
defaultVariants: {
variant: "default",
size: "sm",
},
const buttonText = cva("text-center font-medium", {
variants: {
variant: {
default: "text-ink",
subtle: "text-ink",
outline: "text-ink",
dotted: "text-ink-faint",
gray: "text-ink",
accent: "text-white",
colored: "text-white",
bare: "text-ink",
},
size: {
icon: "text-sm",
xs: "text-sm",
sm: "text-sm",
md: "text-base",
lg: "text-lg",
},
},
defaultVariants: {
variant: "default",
size: "sm",
},
});
export type ButtonVariant = NonNullable<VariantProps<typeof button>["variant"]>;
export type ButtonSize = NonNullable<VariantProps<typeof button>["size"]>;
type ButtonProps = VariantProps<typeof button> &
PressableProps & {
children?: React.ReactNode;
};
PressableProps & {
children?: React.ReactNode;
};
export const Button: FC<ButtonProps> = ({
variant,
size,
disabled,
children,
className,
...props
variant,
size,
disabled,
children,
className,
...props
}) => {
return (
<Pressable
disabled={disabled ?? false}
className={cn(button({ variant, size, disabled }), className)}
{...props}
>
{({ pressed }) => (
<View className={cn(pressed && "opacity-70")}>
{typeof children === "string" ? (
<Text className={buttonText({ variant, size })}>
{children}
</Text>
) : (
children
)}
</View>
)}
</Pressable>
);
return (
<Pressable
className={cn(button({ variant, size, disabled }), className)}
disabled={disabled ?? false}
{...props}
>
{({ pressed }) => (
<View className={cn(pressed && "opacity-70")}>
{typeof children === "string" ? (
<Text className={buttonText({ variant, size })}>{children}</Text>
) : (
children
)}
</View>
)}
</Pressable>
);
};
// Fake button for layout purposes (no press handling)
type FakeButtonProps = VariantProps<typeof button> & {
children?: React.ReactNode;
className?: string;
children?: React.ReactNode;
className?: string;
};
export const FakeButton: FC<FakeButtonProps> = ({
variant,
size,
children,
className,
variant,
size,
children,
className,
}) => {
return (
<View
className={cn(
button({ variant, size, disabled: false }),
className,
)}
>
{typeof children === "string" ? (
<Text className={buttonText({ variant, size })}>
{children}
</Text>
) : (
children
)}
</View>
);
return (
<View className={cn(button({ variant, size, disabled: false }), className)}>
{typeof children === "string" ? (
<Text className={buttonText({ variant, size })}>{children}</Text>
) : (
children
)}
</View>
);
};

View File

@@ -1,45 +1,45 @@
import React, { FC, ReactNode } from "react";
import { View, ViewProps } from "react-native";
import type { FC, ReactNode } from "react";
import { View, type ViewProps } from "react-native";
import { cn } from "~/utils/cn";
interface CardProps extends ViewProps {
children: ReactNode;
children: ReactNode;
}
export const Card: FC<CardProps> = ({ children, className, ...props }) => {
return (
<View
className={cn(
"rounded-lg bg-app-card border border-app-divider p-4",
className,
)}
{...props}
>
{children}
</View>
);
return (
<View
className={cn(
"rounded-lg border border-app-divider bg-app-card p-4",
className
)}
{...props}
>
{children}
</View>
);
};
export const CardHeader: FC<CardProps> = ({
children,
className,
...props
children,
className,
...props
}) => {
return (
<View className={cn("mb-3", className)} {...props}>
{children}
</View>
);
return (
<View className={cn("mb-3", className)} {...props}>
{children}
</View>
);
};
export const CardContent: FC<CardProps> = ({
children,
className,
...props
children,
className,
...props
}) => {
return (
<View className={cn("", className)} {...props}>
{children}
</View>
);
return (
<View className={cn("", className)} {...props}>
{children}
</View>
);
};

View File

@@ -1,14 +1,13 @@
import React from "react";
import { View, ViewProps } from "react-native";
import { View, type ViewProps } from "react-native";
import { cn } from "~/utils/cn";
interface DividerProps extends ViewProps {}
export function Divider({ className, ...props }: DividerProps) {
return (
<View
className={cn("bg-app-divider my-2 h-px w-full", className)}
{...props}
/>
);
return (
<View
className={cn("my-2 h-px w-full bg-app-divider", className)}
{...props}
/>
);
}

View File

@@ -1,62 +1,52 @@
import React, { ReactNode } from "react";
import { Text, View, ViewStyle, TextStyle } from "react-native";
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import { cn } from "~/utils/cn";
interface InfoPillProps {
text: string;
className?: string;
textClassName?: string;
icon?: ReactNode;
text: string;
className?: string;
textClassName?: string;
icon?: ReactNode;
}
export function InfoPill({
text,
className,
textClassName,
icon,
text,
className,
textClassName,
icon,
}: InfoPillProps) {
return (
<View
className={cn(
"flex-row items-center rounded-md bg-app-box px-1.5 py-0.5",
className,
)}
>
{icon && <View className="mr-1">{icon}</View>}
<Text
className={cn(
"text-xs font-medium text-ink-dull",
textClassName,
)}
>
{text}
</Text>
</View>
);
return (
<View
className={cn(
"flex-row items-center rounded-md bg-app-box px-1.5 py-0.5",
className
)}
>
{icon && <View className="mr-1">{icon}</View>}
<Text className={cn("font-medium text-ink-dull text-xs", textClassName)}>
{text}
</Text>
</View>
);
}
export function PlaceholderPill({
text,
className,
textClassName,
icon,
text,
className,
textClassName,
icon,
}: InfoPillProps) {
return (
<View
className={cn(
"flex-row items-center gap-1 rounded-md border border-dashed border-app-divider bg-transparent px-1.5 py-0.5",
className,
)}
>
{icon && icon}
<Text
className={cn(
"text-xs font-medium text-ink-faint",
textClassName,
)}
>
{text}
</Text>
</View>
);
return (
<View
className={cn(
"flex-row items-center gap-1 rounded-md border border-app-divider border-dashed bg-transparent px-1.5 py-0.5",
className
)}
>
{icon && icon}
<Text className={cn("font-medium text-ink-faint text-xs", textClassName)}>
{text}
</Text>
</View>
);
}

View File

@@ -1,99 +1,110 @@
import { cva, VariantProps } from "class-variance-authority";
import React, { forwardRef, useState } from "react";
import { TextInputProps, TextInput, View, Pressable } from "react-native";
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef, useState } from "react";
import { Pressable, TextInput, type TextInputProps, View } from "react-native";
import { cn } from "~/utils/cn";
const input = cva("rounded-lg border text-ink bg-app-box", {
variants: {
variant: {
default: "border-app-line",
outline: "border-sidebar-line bg-transparent",
filled: "border-transparent bg-app-button",
},
size: {
sm: "h-10 px-3",
default: "h-12 px-4",
lg: "h-14 px-5",
},
disabled: {
true: "opacity-50",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
const input = cva("rounded-lg border bg-app-box text-ink", {
variants: {
variant: {
default: "border-app-line",
outline: "border-sidebar-line bg-transparent",
filled: "border-transparent bg-app-button",
},
size: {
sm: "h-10 px-3",
default: "h-12 px-4",
lg: "h-14 px-5",
},
disabled: {
true: "opacity-50",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
type InputProps = VariantProps<typeof input> & TextInputProps;
export const Input = forwardRef<TextInput, InputProps>(
({ variant, size, disabled, className, editable, style, ...props }, ref) => {
// Get proper font size and line height based on size
const fontSize = size === 'sm' ? 14 : size === 'lg' ? 18 : 16;
const lineHeight = size === 'sm' ? 20 : size === 'lg' ? 24 : 22;
({ variant, size, disabled, className, editable, style, ...props }, ref) => {
// Get proper font size and line height based on size
const fontSize = size === "sm" ? 14 : size === "lg" ? 18 : 16;
const lineHeight = size === "sm" ? 20 : size === "lg" ? 24 : 22;
return (
<TextInput
ref={ref}
editable={editable ?? !disabled}
cursorColor="hsl(220, 90%, 56%)" // accent color
placeholderTextColor="hsl(235, 10%, 55%)" // ink-faint
style={[style, { fontSize, lineHeight }]}
className={cn(input({ variant, size, disabled }), className)}
{...props}
/>
);
},
return (
<TextInput
className={cn(input({ variant, size, disabled }), className)}
cursorColor="hsl(220, 90%, 56%)"
editable={editable ?? !disabled} // accent color
placeholderTextColor="hsl(235, 10%, 55%)" // ink-faint
ref={ref}
style={[style, { fontSize, lineHeight }]}
{...props}
/>
);
}
);
Input.displayName = "Input";
// Password input with show/hide toggle
type PasswordInputProps = InputProps & {
isNewPassword?: boolean;
isNewPassword?: boolean;
};
export const PasswordInput = forwardRef<TextInput, PasswordInputProps>(
({ variant, size, disabled, isNewPassword = false, className, style, ...props }, ref) => {
const [showPassword, setShowPassword] = useState(false);
(
{
variant,
size,
disabled,
isNewPassword = false,
className,
style,
...props
},
ref
) => {
const [showPassword, setShowPassword] = useState(false);
// Get proper font size and line height based on size
const fontSize = size === 'sm' ? 14 : size === 'lg' ? 18 : 16;
const lineHeight = size === 'sm' ? 20 : size === 'lg' ? 24 : 22;
// Get proper font size and line height based on size
const fontSize = size === "sm" ? 14 : size === "lg" ? 18 : 16;
const lineHeight = size === "sm" ? 20 : size === "lg" ? 24 : 22;
return (
<View className="relative">
<TextInput
ref={ref}
autoComplete={isNewPassword ? "password-new" : "password"}
textContentType={isNewPassword ? "newPassword" : "password"}
placeholder="Password"
secureTextEntry={!showPassword}
autoCorrect={false}
autoCapitalize="none"
cursorColor="hsl(220, 90%, 56%)" // accent color
placeholderTextColor="hsl(235, 10%, 55%)" // ink-faint
style={[style, { fontSize, lineHeight }]}
className={cn(input({ variant, size, disabled }), "pr-12", className)}
{...props}
/>
<Pressable
className="absolute right-4 top-0 bottom-0 justify-center"
onPress={() => setShowPassword((v) => !v)}
disabled={disabled}
>
<View className="h-5 w-5 items-center justify-center">
{showPassword ? (
<View className="h-0.5 w-4 bg-ink-dull rotate-45" />
) : (
<View className="h-3 w-3 rounded-full border-2 border-ink-dull" />
)}
</View>
</Pressable>
</View>
);
},
return (
<View className="relative">
<TextInput
autoCapitalize="none"
autoComplete={isNewPassword ? "password-new" : "password"}
autoCorrect={false}
className={cn(input({ variant, size, disabled }), "pr-12", className)}
cursorColor="hsl(220, 90%, 56%)"
placeholder="Password"
placeholderTextColor="hsl(235, 10%, 55%)"
ref={ref} // accent color
secureTextEntry={!showPassword} // ink-faint
style={[style, { fontSize, lineHeight }]}
textContentType={isNewPassword ? "newPassword" : "password"}
{...props}
/>
<Pressable
className="absolute top-0 right-4 bottom-0 justify-center"
disabled={disabled}
onPress={() => setShowPassword((v) => !v)}
>
<View className="h-5 w-5 items-center justify-center">
{showPassword ? (
<View className="h-0.5 w-4 rotate-45 bg-ink-dull" />
) : (
<View className="h-3 w-3 rounded-full border-2 border-ink-dull" />
)}
</View>
</Pressable>
</View>
);
}
);
PasswordInput.displayName = "PasswordInput";

View File

@@ -1,50 +1,55 @@
import React, { FC, ReactNode } from "react";
import { View, ScrollView, ScrollViewProps, ViewProps } from "react-native";
import type { FC, ReactNode } from "react";
import {
ScrollView,
type ScrollViewProps,
View,
type ViewProps,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { cn } from "~/utils/cn";
interface ScreenContainerProps extends ViewProps {
children: ReactNode;
scrollable?: boolean;
scrollViewProps?: ScrollViewProps;
children: ReactNode;
scrollable?: boolean;
scrollViewProps?: ScrollViewProps;
}
export const ScreenContainer: FC<ScreenContainerProps> = ({
children,
scrollable = false,
scrollViewProps,
className,
...props
children,
scrollable = false,
scrollViewProps,
className,
...props
}) => {
const insets = useSafeAreaInsets();
const insets = useSafeAreaInsets();
if (scrollable) {
return (
<ScrollView
className={cn("flex-1 bg-app", className)}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: insets.bottom + 16,
paddingHorizontal: 16,
}}
showsVerticalScrollIndicator={false}
{...scrollViewProps}
>
{children}
</ScrollView>
);
}
if (scrollable) {
return (
<ScrollView
className={cn("flex-1 bg-app", className)}
contentContainerStyle={{
paddingTop: insets.top,
paddingBottom: insets.bottom + 16,
paddingHorizontal: 16,
}}
showsVerticalScrollIndicator={false}
{...scrollViewProps}
>
{children}
</ScrollView>
);
}
return (
<View
className={cn("flex-1 bg-app px-4", className)}
style={{
paddingTop: insets.top,
paddingBottom: insets.bottom,
}}
{...props}
>
{children}
</View>
);
return (
<View
className={cn("flex-1 bg-app px-4", className)}
style={{
paddingTop: insets.top,
paddingBottom: insets.bottom,
}}
{...props}
>
{children}
</View>
);
};

View File

@@ -1,51 +1,49 @@
import React, { Children, ReactElement, cloneElement } from "react";
import { View, Text } from "react-native";
import React, { Children, cloneElement, type ReactElement } from "react";
import { Text, View } from "react-native";
import { cn } from "~/utils/cn";
import { SettingsRowProps } from "./SettingsRow";
import type { SettingsRowProps } from "./SettingsRow";
interface SettingsGroupProps {
header?: string;
footer?: string;
children: ReactElement<SettingsRowProps> | ReactElement<SettingsRowProps>[];
className?: string;
header?: string;
footer?: string;
children: ReactElement<SettingsRowProps> | ReactElement<SettingsRowProps>[];
className?: string;
}
export function SettingsGroup({
header,
footer,
children,
className,
header,
footer,
children,
className,
}: SettingsGroupProps) {
const childArray = Children.toArray(children);
const totalChildren = childArray.length;
const childArray = Children.toArray(children);
const totalChildren = childArray.length;
return (
<View className={cn("mb-6", className)}>
{/* Header */}
{header && (
<Text className="text-xs font-semibold text-ink-dull uppercase tracking-wider mb-2 px-4">
{header}
</Text>
)}
return (
<View className={cn("mb-6", className)}>
{/* Header */}
{header && (
<Text className="mb-2 px-4 font-semibold text-ink-dull text-xs uppercase tracking-wider">
{header}
</Text>
)}
{/* Rows container */}
<View className="rounded-[32px] overflow-hidden">
{Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return child;
{/* Rows container */}
<View className="overflow-hidden rounded-[32px]">
{Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return child;
return cloneElement(child, {
isFirst: index === 0,
isLast: index === totalChildren - 1,
});
})}
</View>
return cloneElement(child, {
isFirst: index === 0,
isLast: index === totalChildren - 1,
});
})}
</View>
{/* Footer */}
{footer && (
<Text className="text-xs text-ink-faint mt-2 px-4">
{footer}
</Text>
)}
</View>
);
{/* Footer */}
{footer && (
<Text className="mt-2 px-4 text-ink-faint text-xs">{footer}</Text>
)}
</View>
);
}

View File

@@ -1,16 +1,15 @@
import React from "react";
import { View } from "react-native";
import { SettingsRow, SettingsRowProps } from "./SettingsRow";
import { SettingsRow, type SettingsRowProps } from "./SettingsRow";
type SettingsLinkProps = Omit<SettingsRowProps, "trailing">;
export function SettingsLink(props: SettingsLinkProps) {
return (
<SettingsRow
{...props}
trailing={
<View className="w-2 h-2 border-r-2 border-t-2 border-ink-dull rotate-45" />
}
/>
);
return (
<SettingsRow
{...props}
trailing={
<View className="h-2 w-2 rotate-45 border-ink-dull border-t-2 border-r-2" />
}
/>
);
}

View File

@@ -1,27 +1,27 @@
import React, { ReactNode } from "react";
import { ScrollView, ScrollViewProps } from "react-native";
import type { ReactNode } from "react";
import { ScrollView, type ScrollViewProps } from "react-native";
import { cn } from "~/utils/cn";
interface SettingsListProps extends ScrollViewProps {
children: ReactNode;
children: ReactNode;
}
export function SettingsList({
children,
className,
contentContainerStyle,
...props
children,
className,
contentContainerStyle,
...props
}: SettingsListProps) {
return (
<ScrollView
className={cn("flex-1", className)}
contentContainerStyle={[
{ paddingHorizontal: 16, paddingVertical: 16 },
contentContainerStyle,
]}
{...props}
>
{children}
</ScrollView>
);
return (
<ScrollView
className={cn("flex-1", className)}
contentContainerStyle={[
{ paddingHorizontal: 16, paddingVertical: 16 },
contentContainerStyle,
]}
{...props}
>
{children}
</ScrollView>
);
}

View File

@@ -1,20 +1,17 @@
import React from "react";
import { Text } from "react-native";
import { SettingsRow, SettingsRowProps } from "./SettingsRow";
import { SettingsRow, type SettingsRowProps } from "./SettingsRow";
interface SettingsOptionProps extends SettingsRowProps {
value?: string;
value?: string;
}
export function SettingsOption({ value, ...props }: SettingsOptionProps) {
return (
<SettingsRow
{...props}
trailing={
value ? (
<Text className="text-ink-dull mr-2">{value}</Text>
) : undefined
}
/>
);
return (
<SettingsRow
{...props}
trailing={
value ? <Text className="mr-2 text-ink-dull">{value}</Text> : undefined
}
/>
);
}

View File

@@ -1,69 +1,67 @@
import React, { ReactNode } from "react";
import { View, Text, Pressable, PressableProps } from "react-native";
import type { ReactNode } from "react";
import { Pressable, type PressableProps, Text, View } from "react-native";
import { cn } from "~/utils/cn";
export interface SettingsRowProps extends Omit<PressableProps, "children"> {
icon?: ReactNode;
label: string;
description?: string;
trailing?: ReactNode;
isFirst?: boolean;
isLast?: boolean;
onPress?: () => void;
icon?: ReactNode;
label: string;
description?: string;
trailing?: ReactNode;
isFirst?: boolean;
isLast?: boolean;
onPress?: () => void;
}
export function SettingsRow({
icon,
label,
description,
trailing,
isFirst,
isLast,
onPress,
className,
...props
icon,
label,
description,
trailing,
isFirst,
isLast,
onPress,
className,
...props
}: SettingsRowProps) {
const Component = onPress ? Pressable : View;
const Component = onPress ? Pressable : View;
return (
<>
<Component
onPress={onPress}
className={cn(
"flex-row items-center px-6 py-3 bg-app-box min-h-[56px]",
isFirst && "rounded-t-[32px]",
isLast && "rounded-b-[32px]",
onPress && "active:bg-app-hover",
className
)}
{...props}
>
{/* Icon */}
{icon && <View className="mr-3">{icon}</View>}
return (
<>
<Component
className={cn(
"min-h-[56px] flex-row items-center bg-app-box px-6 py-3",
isFirst && "rounded-t-[32px]",
isLast && "rounded-b-[32px]",
onPress && "active:bg-app-hover",
className
)}
onPress={onPress}
{...props}
>
{/* Icon */}
{icon && <View className="mr-3">{icon}</View>}
{/* Label & Description */}
<View className="flex-1">
<Text className="text-lg text-ink">{label}</Text>
{description && (
<Text className="text-sm text-ink-dull mt-0.5">
{description}
</Text>
)}
</View>
{/* Label & Description */}
<View className="flex-1">
<Text className="text-ink text-lg">{label}</Text>
{description && (
<Text className="mt-0.5 text-ink-dull text-sm">{description}</Text>
)}
</View>
{/* Trailing accessory */}
{trailing && <View className="ml-3">{trailing}</View>}
</Component>
{/* Trailing accessory */}
{trailing && <View className="ml-3">{trailing}</View>}
</Component>
{/* Divider (not after last item) */}
{!isLast && (
<View className="bg-app-box">
<View
className="h-px bg-app-line"
style={{ marginLeft: icon ? 60 : 24 }}
/>
</View>
)}
</>
);
{/* Divider (not after last item) */}
{!isLast && (
<View className="bg-app-box">
<View
className="h-px bg-app-line"
style={{ marginLeft: icon ? 60 : 24 }}
/>
</View>
)}
</>
);
}

View File

@@ -1,46 +1,45 @@
import React from "react";
import { View, Text } from "react-native";
import Slider from "@react-native-community/slider";
import { SettingsRow, SettingsRowProps } from "./SettingsRow";
import { Text, View } from "react-native";
import { SettingsRow, type SettingsRowProps } from "./SettingsRow";
interface SettingsSliderProps extends Omit<SettingsRowProps, "trailing"> {
value: number;
minimumValue?: number;
maximumValue?: number;
onValueChange: (value: number) => void;
showValue?: boolean;
value: number;
minimumValue?: number;
maximumValue?: number;
onValueChange: (value: number) => void;
showValue?: boolean;
}
export function SettingsSlider({
value,
minimumValue = 0,
maximumValue = 100,
onValueChange,
showValue = true,
...props
value,
minimumValue = 0,
maximumValue = 100,
onValueChange,
showValue = true,
...props
}: SettingsSliderProps) {
return (
<SettingsRow
{...props}
trailing={
<View className="flex-row items-center gap-3 flex-1 ml-4">
<Slider
value={value}
minimumValue={minimumValue}
maximumValue={maximumValue}
onValueChange={onValueChange}
minimumTrackTintColor="hsl(220, 90%, 56%)"
maximumTrackTintColor="hsl(235, 15%, 23%)"
thumbTintColor="hsl(220, 90%, 56%)"
style={{ flex: 1, height: 40 }}
/>
{showValue && (
<Text className="text-ink-dull w-10 text-right">
{Math.round(value)}
</Text>
)}
</View>
}
/>
);
return (
<SettingsRow
{...props}
trailing={
<View className="ml-4 flex-1 flex-row items-center gap-3">
<Slider
maximumTrackTintColor="hsl(235, 15%, 23%)"
maximumValue={maximumValue}
minimumTrackTintColor="hsl(220, 90%, 56%)"
minimumValue={minimumValue}
onValueChange={onValueChange}
style={{ flex: 1, height: 40 }}
thumbTintColor="hsl(220, 90%, 56%)"
value={value}
/>
{showValue && (
<Text className="w-10 text-right text-ink-dull">
{Math.round(value)}
</Text>
)}
</View>
}
/>
);
}

View File

@@ -1,21 +1,21 @@
import React from "react";
import { SettingsRow, type SettingsRowProps } from "./SettingsRow";
import { Switch } from "./Switch";
import { SettingsRow, SettingsRowProps } from "./SettingsRow";
interface SettingsToggleProps extends Omit<SettingsRowProps, "trailing" | "onPress"> {
value: boolean;
onValueChange: (value: boolean) => void;
interface SettingsToggleProps
extends Omit<SettingsRowProps, "trailing" | "onPress"> {
value: boolean;
onValueChange: (value: boolean) => void;
}
export function SettingsToggle({
value,
onValueChange,
...props
value,
onValueChange,
...props
}: SettingsToggleProps) {
return (
<SettingsRow
{...props}
trailing={<Switch value={value} onValueChange={onValueChange} />}
/>
);
return (
<SettingsRow
{...props}
trailing={<Switch onValueChange={onValueChange} value={value} />}
/>
);
}

View File

@@ -1,41 +1,39 @@
import React, { FC } from "react";
import { Switch as RNSwitch, SwitchProps, Text, View } from "react-native";
import type { FC } from "react";
import { Switch as RNSwitch, type SwitchProps, Text, View } from "react-native";
export const Switch: FC<SwitchProps> = (props) => {
return (
<RNSwitch
trackColor={{
false: "hsl(235, 10%, 16%)",
true: "hsl(208, 100%, 57%)",
}}
thumbColor="#fff"
ios_backgroundColor="hsl(235, 10%, 16%)"
{...props}
/>
);
return (
<RNSwitch
ios_backgroundColor="hsl(235, 10%, 16%)"
thumbColor="#fff"
trackColor={{
false: "hsl(235, 10%, 16%)",
true: "hsl(208, 100%, 57%)",
}}
{...props}
/>
);
};
interface SwitchContainerProps extends SwitchProps {
title: string;
description?: string;
title: string;
description?: string;
}
export const SwitchContainer: FC<SwitchContainerProps> = ({
title,
description,
...props
title,
description,
...props
}) => {
return (
<View className="flex-row items-center justify-between py-3">
<View className="flex-1 pr-4">
<Text className="text-sm font-medium text-ink">{title}</Text>
{description && (
<Text className="mt-1 text-sm text-ink-dull">
{description}
</Text>
)}
</View>
<Switch {...props} />
</View>
);
return (
<View className="flex-row items-center justify-between py-3">
<View className="flex-1 pr-4">
<Text className="font-medium text-ink text-sm">{title}</Text>
{description && (
<Text className="mt-1 text-ink-dull text-sm">{description}</Text>
)}
</View>
<Switch {...props} />
</View>
);
};

View File

@@ -1,14 +1,14 @@
export { Button, FakeButton, type ButtonVariant } from "./Button";
export { Input, PasswordInput } from "./Input";
export { Button, type ButtonVariant, FakeButton } from "./Button";
export { Card, CardContent, CardHeader } from "./Card";
export { Divider } from "./Divider";
export { InfoPill, PlaceholderPill } from "./InfoPill";
export { Switch, SwitchContainer } from "./Switch";
export { Card, CardHeader, CardContent } from "./Card";
export { Input, PasswordInput } from "./Input";
export { ScreenContainer } from "./ScreenContainer";
export { SettingsRow } from "./SettingsRow";
export { SettingsGroup } from "./SettingsGroup";
export { SettingsLink } from "./SettingsLink";
export { SettingsToggle } from "./SettingsToggle";
export { SettingsOption } from "./SettingsOption";
export { SettingsSlider } from "./SettingsSlider";
export { SettingsList } from "./SettingsList";
export { SettingsOption } from "./SettingsOption";
export { SettingsRow } from "./SettingsRow";
export { SettingsSlider } from "./SettingsSlider";
export { SettingsToggle } from "./SettingsToggle";
export { Switch, SwitchContainer } from "./Switch";

View File

@@ -1,170 +1,162 @@
import React from "react";
import { View, Text, ScrollView, Pressable } from "react-native";
import { DrawerContentComponentProps } from "@react-navigation/drawer";
import type { DrawerContentComponentProps } from "@react-navigation/drawer";
import type React from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useSidebarStore } from "../../stores";
import { useCoreQuery, useSpacedriveClient } from "../../client";
import { useSidebarStore } from "../../stores";
interface SidebarSectionProps {
title: string;
isCollapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
title: string;
isCollapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function SidebarSection({
title,
isCollapsed,
onToggle,
children,
title,
isCollapsed,
onToggle,
children,
}: SidebarSectionProps) {
return (
<View className="mb-4">
<Pressable
onPress={onToggle}
className="flex-row items-center justify-between py-2"
>
<Text className="text-ink-dull text-xs uppercase tracking-wide">
{title}
</Text>
<Text className="text-ink-faint text-xs">
{isCollapsed ? "▶" : "▼"}
</Text>
</Pressable>
{!isCollapsed && children}
</View>
);
return (
<View className="mb-4">
<Pressable
className="flex-row items-center justify-between py-2"
onPress={onToggle}
>
<Text className="text-ink-dull text-xs uppercase tracking-wide">
{title}
</Text>
<Text className="text-ink-faint text-xs">
{isCollapsed ? "▶" : "▼"}
</Text>
</Pressable>
{!isCollapsed && children}
</View>
);
}
export function SidebarContent({ navigation }: DrawerContentComponentProps) {
const insets = useSafeAreaInsets();
const client = useSpacedriveClient();
const {
currentLibraryId,
setCurrentLibrary: setStoreLibrary,
isGroupCollapsed,
toggleGroup,
} = useSidebarStore();
const insets = useSafeAreaInsets();
const client = useSpacedriveClient();
const {
currentLibraryId,
setCurrentLibrary: setStoreLibrary,
isGroupCollapsed,
toggleGroup,
} = useSidebarStore();
// Fetch libraries
const { data: libraries } = useCoreQuery("libraries.list", { include_stats: false });
// Fetch libraries
const { data: libraries } = useCoreQuery("libraries.list", {
include_stats: false,
});
// Handler that syncs library ID to both store and client
const handleSelectLibrary = (libraryId: string) => {
console.log("[SidebarContent] Selecting library:", libraryId);
setStoreLibrary(libraryId);
client.setCurrentLibrary(libraryId);
};
// Handler that syncs library ID to both store and client
const handleSelectLibrary = (libraryId: string) => {
console.log("[SidebarContent] Selecting library:", libraryId);
setStoreLibrary(libraryId);
client.setCurrentLibrary(libraryId);
};
const navigateAndClose = (screen: string) => {
navigation.navigate(screen);
navigation.closeDrawer();
};
const navigateAndClose = (screen: string) => {
navigation.navigate(screen);
navigation.closeDrawer();
};
return (
<ScrollView
className="flex-1 bg-sidebar-box"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 16,
paddingHorizontal: 16,
}}
>
{/* Logo/Title */}
<View className="mb-6">
<Text className="text-xl font-bold text-ink">Spacedrive</Text>
<Text className="text-ink-faint text-sm">Mobile V2</Text>
</View>
return (
<ScrollView
className="flex-1 bg-sidebar-box"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 16,
paddingHorizontal: 16,
}}
>
{/* Logo/Title */}
<View className="mb-6">
<Text className="font-bold text-ink text-xl">Spacedrive</Text>
<Text className="text-ink-faint text-sm">Mobile V2</Text>
</View>
{/* Libraries Section */}
<SidebarSection
title="Libraries"
isCollapsed={isGroupCollapsed("libraries")}
onToggle={() => toggleGroup("libraries")}
>
{libraries &&
Array.isArray(libraries) &&
libraries.length > 0 ? (
libraries.map((lib: any) => (
<Pressable
key={lib.id}
onPress={() => handleSelectLibrary(lib.id)}
className={`py-2.5 px-3 rounded-md mb-1 ${
currentLibraryId === lib.id
? "bg-sidebar-button"
: ""
}`}
>
<Text
className={`${
currentLibraryId === lib.id
? "text-ink"
: "text-ink-dull"
}`}
>
{lib.name}
</Text>
</Pressable>
))
) : (
<Text className="text-ink-faint text-sm py-2">
No libraries
</Text>
)}
{/* Libraries Section */}
<SidebarSection
isCollapsed={isGroupCollapsed("libraries")}
onToggle={() => toggleGroup("libraries")}
title="Libraries"
>
{libraries && Array.isArray(libraries) && libraries.length > 0 ? (
libraries.map((lib: any) => (
<Pressable
className={`mb-1 rounded-md px-3 py-2.5 ${
currentLibraryId === lib.id ? "bg-sidebar-button" : ""
}`}
key={lib.id}
onPress={() => handleSelectLibrary(lib.id)}
>
<Text
className={`${
currentLibraryId === lib.id ? "text-ink" : "text-ink-dull"
}`}
>
{lib.name}
</Text>
</Pressable>
))
) : (
<Text className="py-2 text-ink-faint text-sm">No libraries</Text>
)}
<Pressable className="py-2 px-3 rounded-md border border-dashed border-sidebar-line mt-2">
<Text className="text-ink-faint text-sm">
+ Create Library
</Text>
</Pressable>
</SidebarSection>
<Pressable className="mt-2 rounded-md border border-sidebar-line border-dashed px-3 py-2">
<Text className="text-ink-faint text-sm">+ Create Library</Text>
</Pressable>
</SidebarSection>
{/* Locations Section */}
<SidebarSection
title="Locations"
isCollapsed={isGroupCollapsed("locations")}
onToggle={() => toggleGroup("locations")}
>
<Text className="text-ink-faint text-sm py-2">
Select a library to view locations
</Text>
</SidebarSection>
{/* Locations Section */}
<SidebarSection
isCollapsed={isGroupCollapsed("locations")}
onToggle={() => toggleGroup("locations")}
title="Locations"
>
<Text className="py-2 text-ink-faint text-sm">
Select a library to view locations
</Text>
</SidebarSection>
{/* Tags Section */}
<SidebarSection
title="Tags"
isCollapsed={isGroupCollapsed("tags")}
onToggle={() => toggleGroup("tags")}
>
<Text className="text-ink-faint text-sm py-2">
Select a library to view tags
</Text>
</SidebarSection>
{/* Tags Section */}
<SidebarSection
isCollapsed={isGroupCollapsed("tags")}
onToggle={() => toggleGroup("tags")}
title="Tags"
>
<Text className="py-2 text-ink-faint text-sm">
Select a library to view tags
</Text>
</SidebarSection>
{/* Divider */}
<View className="h-px bg-sidebar-line my-4" />
{/* Divider */}
<View className="my-4 h-px bg-sidebar-line" />
{/* Quick Links */}
<View>
<Pressable
onPress={() => navigateAndClose("OverviewTab")}
className="py-2.5 px-3 rounded-md mb-1"
>
<Text className="text-ink-dull">Overview</Text>
</Pressable>
<Pressable
onPress={() => navigateAndClose("NetworkTab")}
className="py-2.5 px-3 rounded-md mb-1"
>
<Text className="text-ink-dull">Network</Text>
</Pressable>
<Pressable
onPress={() => navigateAndClose("SettingsTab")}
className="py-2.5 px-3 rounded-md mb-1"
>
<Text className="text-ink-dull">Settings</Text>
</Pressable>
</View>
</ScrollView>
);
{/* Quick Links */}
<View>
<Pressable
className="mb-1 rounded-md px-3 py-2.5"
onPress={() => navigateAndClose("OverviewTab")}
>
<Text className="text-ink-dull">Overview</Text>
</Pressable>
<Pressable
className="mb-1 rounded-md px-3 py-2.5"
onPress={() => navigateAndClose("NetworkTab")}
>
<Text className="text-ink-dull">Network</Text>
</Pressable>
<Pressable
className="mb-1 rounded-md px-3 py-2.5"
onPress={() => navigateAndClose("SettingsTab")}
>
<Text className="text-ink-dull">Settings</Text>
</Pressable>
</View>
</ScrollView>
);
}

View File

@@ -1,15 +1,15 @@
import { createContext, useContext } from "react";
interface AppResetContextType {
resetApp: () => void;
resetApp: () => void;
}
export const AppResetContext = createContext<AppResetContextType | null>(null);
export function useAppReset() {
const context = useContext(AppResetContext);
if (!context) {
throw new Error("useAppReset must be used within AppResetContext.Provider");
}
return context;
const context = useContext(AppResetContext);
if (!context) {
throw new Error("useAppReset must be used within AppResetContext.Provider");
}
return context;
}

View File

@@ -1,32 +1,31 @@
import React from "react";
import {
createDrawerNavigator,
DrawerContentComponentProps,
createDrawerNavigator,
type DrawerContentComponentProps,
} from "@react-navigation/drawer";
import { TabNavigator } from "./TabNavigator";
import { SidebarContent } from "../components/sidebar/SidebarContent";
import { TabNavigator } from "./TabNavigator";
import type { DrawerParamList } from "./types";
const Drawer = createDrawerNavigator<DrawerParamList>();
export function DrawerNavigator() {
return (
<Drawer.Navigator
drawerContent={(props: DrawerContentComponentProps) => (
<SidebarContent {...props} />
)}
screenOptions={{
headerShown: false,
drawerType: "slide",
drawerStyle: {
width: 280,
backgroundColor: "hsl(235, 15%, 16%)",
},
overlayColor: "rgba(0, 0, 0, 0.5)",
swipeEdgeWidth: 50,
}}
>
<Drawer.Screen name="Tabs" component={TabNavigator} />
</Drawer.Navigator>
);
return (
<Drawer.Navigator
drawerContent={(props: DrawerContentComponentProps) => (
<SidebarContent {...props} />
)}
screenOptions={{
headerShown: false,
drawerType: "slide",
drawerStyle: {
width: 280,
backgroundColor: "hsl(235, 15%, 16%)",
},
overlayColor: "rgba(0, 0, 0, 0.5)",
swipeEdgeWidth: 50,
}}
>
<Drawer.Screen component={TabNavigator} name="Tabs" />
</Drawer.Navigator>
);
}

View File

@@ -1,5 +1,4 @@
import React from "react";
import { NavigationContainer, DefaultTheme } from "@react-navigation/native";
import { DefaultTheme, NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { DrawerNavigator } from "./DrawerNavigator";
import type { RootStackParamList } from "./types";
@@ -8,31 +7,31 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
// Dark theme for navigation
const SpacedriveTheme = {
...DefaultTheme,
dark: true,
colors: {
...DefaultTheme.colors,
primary: "hsl(208, 100%, 57%)",
background: "hsl(235, 15%, 13%)",
card: "hsl(235, 10%, 6%)",
text: "hsl(235, 0%, 100%)",
border: "hsl(235, 15%, 23%)",
notification: "hsl(208, 100%, 57%)",
},
...DefaultTheme,
dark: true,
colors: {
...DefaultTheme.colors,
primary: "hsl(208, 100%, 57%)",
background: "hsl(235, 15%, 13%)",
card: "hsl(235, 10%, 6%)",
text: "hsl(235, 0%, 100%)",
border: "hsl(235, 15%, 23%)",
notification: "hsl(208, 100%, 57%)",
},
};
export function RootNavigator() {
return (
<NavigationContainer theme={SpacedriveTheme}>
<Stack.Navigator
screenOptions={{
headerShown: false,
animation: "fade",
}}
>
<Stack.Screen name="Main" component={DrawerNavigator} />
{/* Add Onboarding and Search screens later */}
</Stack.Navigator>
</NavigationContainer>
);
return (
<NavigationContainer theme={SpacedriveTheme}>
<Stack.Navigator
screenOptions={{
headerShown: false,
animation: "fade",
}}
>
<Stack.Screen component={DrawerNavigator} name="Main" />
{/* Add Onboarding and Search screens later */}
</Stack.Navigator>
</NavigationContainer>
);
}

View File

@@ -1,10 +1,9 @@
import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { View, Text, Platform } from "react-native";
import { Platform, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { OverviewStack } from "./stacks/OverviewStack";
import { BrowseStack } from "./stacks/BrowseStack";
import { NetworkStack } from "./stacks/NetworkStack";
import { OverviewStack } from "./stacks/OverviewStack";
import { SettingsStack } from "./stacks/SettingsStack";
import type { TabParamList } from "./types";
@@ -12,79 +11,77 @@ const Tab = createBottomTabNavigator<TabParamList>();
// Simple icon components (replace with phosphor-react-native later)
const TabIcon = ({ name, focused }: { name: string; focused: boolean }) => (
<View
className={`items-center justify-center ${focused ? "opacity-100" : "opacity-50"}`}
>
<View
className={`h-6 w-6 rounded-md ${focused ? "bg-accent" : "bg-ink-faint"}`}
/>
<Text
className={`text-[10px] mt-1 ${focused ? "text-accent" : "text-ink-faint"}`}
>
{name}
</Text>
</View>
<View
className={`items-center justify-center ${focused ? "opacity-100" : "opacity-50"}`}
>
<View
className={`h-6 w-6 rounded-md ${focused ? "bg-accent" : "bg-ink-faint"}`}
/>
<Text
className={`mt-1 text-[10px] ${focused ? "text-accent" : "text-ink-faint"}`}
>
{name}
</Text>
</View>
);
export function TabNavigator() {
const insets = useSafeAreaInsets();
const tabBarHeight = Platform.OS === "ios" ? 80 : 60;
const insets = useSafeAreaInsets();
const tabBarHeight = Platform.OS === "ios" ? 80 : 60;
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarStyle: {
height:
tabBarHeight +
(Platform.OS === "ios" ? 0 : insets.bottom),
paddingBottom: Platform.OS === "ios" ? insets.bottom : 8,
paddingTop: 8,
backgroundColor: "hsl(235, 10%, 6%)",
borderTopColor: "hsl(235, 15%, 23%)",
borderTopWidth: 1,
},
tabBarShowLabel: false,
tabBarActiveTintColor: "hsl(208, 100%, 57%)",
tabBarInactiveTintColor: "hsl(235, 10%, 55%)",
}}
>
<Tab.Screen
name="OverviewTab"
component={OverviewStack}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon name="Overview" focused={focused} />
),
}}
/>
<Tab.Screen
name="BrowseTab"
component={BrowseStack}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon name="Browse" focused={focused} />
),
}}
/>
<Tab.Screen
name="NetworkTab"
component={NetworkStack}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon name="Network" focused={focused} />
),
}}
/>
<Tab.Screen
name="SettingsTab"
component={SettingsStack}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon name="Settings" focused={focused} />
),
}}
/>
</Tab.Navigator>
);
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarStyle: {
height: tabBarHeight + (Platform.OS === "ios" ? 0 : insets.bottom),
paddingBottom: Platform.OS === "ios" ? insets.bottom : 8,
paddingTop: 8,
backgroundColor: "hsl(235, 10%, 6%)",
borderTopColor: "hsl(235, 15%, 23%)",
borderTopWidth: 1,
},
tabBarShowLabel: false,
tabBarActiveTintColor: "hsl(208, 100%, 57%)",
tabBarInactiveTintColor: "hsl(235, 10%, 55%)",
}}
>
<Tab.Screen
component={OverviewStack}
name="OverviewTab"
options={{
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} name="Overview" />
),
}}
/>
<Tab.Screen
component={BrowseStack}
name="BrowseTab"
options={{
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} name="Browse" />
),
}}
/>
<Tab.Screen
component={NetworkStack}
name="NetworkTab"
options={{
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} name="Network" />
),
}}
/>
<Tab.Screen
component={SettingsStack}
name="SettingsTab"
options={{
tabBarIcon: ({ focused }) => (
<TabIcon focused={focused} name="Settings" />
),
}}
/>
</Tab.Navigator>
);
}

View File

@@ -1,4 +1,3 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { BrowseScreen } from "../../screens/browse/BrowseScreen";
import type { BrowseStackParamList } from "../types";
@@ -6,10 +5,10 @@ import type { BrowseStackParamList } from "../types";
const Stack = createNativeStackNavigator<BrowseStackParamList>();
export function BrowseStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="BrowseHome" component={BrowseScreen} />
{/* Add Location, Tag, Explorer screens later */}
</Stack.Navigator>
);
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen component={BrowseScreen} name="BrowseHome" />
{/* Add Location, Tag, Explorer screens later */}
</Stack.Navigator>
);
}

View File

@@ -1,4 +1,3 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { NetworkScreen } from "../../screens/network/NetworkScreen";
import type { NetworkStackParamList } from "../types";
@@ -6,9 +5,9 @@ import type { NetworkStackParamList } from "../types";
const Stack = createNativeStackNavigator<NetworkStackParamList>();
export function NetworkStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Network" component={NetworkScreen} />
</Stack.Navigator>
);
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen component={NetworkScreen} name="Network" />
</Stack.Navigator>
);
}

View File

@@ -1,4 +1,3 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { OverviewScreen } from "../../screens/overview/OverviewScreen";
import type { OverviewStackParamList } from "../types";
@@ -6,9 +5,9 @@ import type { OverviewStackParamList } from "../types";
const Stack = createNativeStackNavigator<OverviewStackParamList>();
export function OverviewStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Overview" component={OverviewScreen} />
</Stack.Navigator>
);
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen component={OverviewScreen} name="Overview" />
</Stack.Navigator>
);
}

View File

@@ -1,4 +1,3 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { SettingsScreen } from "../../screens/settings/SettingsScreen";
import type { SettingsStackParamList } from "../types";
@@ -6,9 +5,9 @@ import type { SettingsStackParamList } from "../types";
const Stack = createNativeStackNavigator<SettingsStackParamList>();
export function SettingsStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="SettingsHome" component={SettingsScreen} />
</Stack.Navigator>
);
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen component={SettingsScreen} name="SettingsHome" />
</Stack.Navigator>
);
}

View File

@@ -1,58 +1,58 @@
import { NavigatorScreenParams } from "@react-navigation/native";
import type { NavigatorScreenParams } from "@react-navigation/native";
// Root stack contains main app and onboarding
export type RootStackParamList = {
Main: NavigatorScreenParams<DrawerParamList>;
Onboarding: undefined;
Search: undefined;
Main: NavigatorScreenParams<DrawerParamList>;
Onboarding: undefined;
Search: undefined;
};
// Drawer contains the sidebar and tab navigator
export type DrawerParamList = {
Tabs: NavigatorScreenParams<TabParamList>;
Tabs: NavigatorScreenParams<TabParamList>;
};
// Bottom tabs
export type TabParamList = {
OverviewTab: NavigatorScreenParams<OverviewStackParamList>;
BrowseTab: NavigatorScreenParams<BrowseStackParamList>;
NetworkTab: NavigatorScreenParams<NetworkStackParamList>;
SettingsTab: NavigatorScreenParams<SettingsStackParamList>;
OverviewTab: NavigatorScreenParams<OverviewStackParamList>;
BrowseTab: NavigatorScreenParams<BrowseStackParamList>;
NetworkTab: NavigatorScreenParams<NetworkStackParamList>;
SettingsTab: NavigatorScreenParams<SettingsStackParamList>;
};
// Overview stack
export type OverviewStackParamList = {
Overview: undefined;
Overview: undefined;
};
// Browse stack
export type BrowseStackParamList = {
BrowseHome: undefined;
Location: { locationId: string; name?: string };
Tag: { tagId: string; name?: string };
Explorer: { path: string; locationId?: string };
BrowseHome: undefined;
Location: { locationId: string; name?: string };
Tag: { tagId: string; name?: string };
Explorer: { path: string; locationId?: string };
};
// Network stack
export type NetworkStackParamList = {
Network: undefined;
Peers: undefined;
Pairing: undefined;
Network: undefined;
Peers: undefined;
Pairing: undefined;
};
// Settings stack
export type SettingsStackParamList = {
SettingsHome: undefined;
GeneralSettings: undefined;
LibrarySettings: undefined;
AppearanceSettings: undefined;
PrivacySettings: undefined;
About: undefined;
SettingsHome: undefined;
GeneralSettings: undefined;
LibrarySettings: undefined;
AppearanceSettings: undefined;
PrivacySettings: undefined;
About: undefined;
};
// Utility types for typed navigation
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}

View File

@@ -1,213 +1,219 @@
import React, { useState } from "react";
import { View, Text, ScrollView, Pressable } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useLibraryQuery, useCoreQuery } from "../../client";
import { Card } from "../../components/primitive";
import clsx from "clsx";
import type React from "react";
import { useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useLibraryQuery } from "../../client";
import { Card } from "../../components/primitive";
// Collapsible Group Component
interface CollapsibleGroupProps {
title: string;
children: React.ReactNode;
defaultCollapsed?: boolean;
title: string;
children: React.ReactNode;
defaultCollapsed?: boolean;
}
function CollapsibleGroup({ title, children, defaultCollapsed = false }: CollapsibleGroupProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
function CollapsibleGroup({
title,
children,
defaultCollapsed = false,
}: CollapsibleGroupProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
return (
<View className="mb-5">
<Pressable
onPress={() => setIsCollapsed(!isCollapsed)}
className="flex-row items-center mb-2 px-1"
>
<Text className="text-ink-faint text-xs font-semibold uppercase tracking-wider mr-2">
{isCollapsed ? "▶" : "▼"}
</Text>
<Text className="text-ink-faint text-xs font-semibold uppercase tracking-wider">
{title}
</Text>
</Pressable>
{!isCollapsed && (
<View className="space-y-1">
{children}
</View>
)}
</View>
);
return (
<View className="mb-5">
<Pressable
className="mb-2 flex-row items-center px-1"
onPress={() => setIsCollapsed(!isCollapsed)}
>
<Text className="mr-2 font-semibold text-ink-faint text-xs uppercase tracking-wider">
{isCollapsed ? "▶" : "▼"}
</Text>
<Text className="font-semibold text-ink-faint text-xs uppercase tracking-wider">
{title}
</Text>
</Pressable>
{!isCollapsed && <View className="space-y-1">{children}</View>}
</View>
);
}
// Sidebar Item Component
interface SidebarItemProps {
icon: string;
label: string;
onPress?: () => void;
isActive?: boolean;
color?: string;
icon: string;
label: string;
onPress?: () => void;
isActive?: boolean;
color?: string;
}
function SidebarItem({ icon, label, onPress, isActive = false, color }: SidebarItemProps) {
return (
<Pressable
onPress={onPress}
className={clsx(
"flex-row items-center gap-2 rounded-md px-2 py-2 transition-colors",
isActive
? "bg-sidebar-selected/30"
: "active:bg-sidebar-selected/20"
)}
>
{color ? (
<View
className="w-4 h-4 rounded-full"
style={{ backgroundColor: color }}
/>
) : (
<Text className="text-base">{icon}</Text>
)}
<Text
className={clsx(
"flex-1 text-sm font-medium",
isActive ? "text-sidebar-ink" : "text-sidebar-inkDull"
)}
>
{label}
</Text>
</Pressable>
);
function SidebarItem({
icon,
label,
onPress,
isActive = false,
color,
}: SidebarItemProps) {
return (
<Pressable
className={clsx(
"flex-row items-center gap-2 rounded-md px-2 py-2 transition-colors",
isActive ? "bg-sidebar-selected/30" : "active:bg-sidebar-selected/20"
)}
onPress={onPress}
>
{color ? (
<View
className="h-4 w-4 rounded-full"
style={{ backgroundColor: color }}
/>
) : (
<Text className="text-base">{icon}</Text>
)}
<Text
className={clsx(
"flex-1 font-medium text-sm",
isActive ? "text-sidebar-ink" : "text-sidebar-inkDull"
)}
>
{label}
</Text>
</Pressable>
);
}
// Space Switcher Component
interface Space {
id: string;
name: string;
color: string;
id: string;
name: string;
color: string;
}
function SpaceSwitcher({ spaces, currentSpace }: { spaces: Space[] | undefined; currentSpace: Space | undefined }) {
const [showDropdown, setShowDropdown] = useState(false);
function SpaceSwitcher({
spaces,
currentSpace,
}: {
spaces: Space[] | undefined;
currentSpace: Space | undefined;
}) {
const [showDropdown, setShowDropdown] = useState(false);
return (
<View className="mb-4">
<Pressable
onPress={() => setShowDropdown(!showDropdown)}
className="flex-row items-center gap-2 bg-sidebar-box border border-sidebar-line rounded-lg px-3 py-2"
>
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: currentSpace?.color || "#666" }}
/>
<Text className="flex-1 text-sm font-medium text-sidebar-ink">
{currentSpace?.name || "Select Space"}
</Text>
<Text className="text-sidebar-inkDull text-xs">
{showDropdown ? "▲" : "▼"}
</Text>
</Pressable>
return (
<View className="mb-4">
<Pressable
className="flex-row items-center gap-2 rounded-lg border border-sidebar-line bg-sidebar-box px-3 py-2"
onPress={() => setShowDropdown(!showDropdown)}
>
<View
className="h-2 w-2 rounded-full"
style={{ backgroundColor: currentSpace?.color || "#666" }}
/>
<Text className="flex-1 font-medium text-sidebar-ink text-sm">
{currentSpace?.name || "Select Space"}
</Text>
<Text className="text-sidebar-inkDull text-xs">
{showDropdown ? "▲" : "▼"}
</Text>
</Pressable>
{showDropdown && spaces && spaces.length > 0 && (
<Card className="mt-2">
{spaces.map((space) => (
<Pressable
key={space.id}
className="flex-row items-center gap-2 py-2 px-2"
onPress={() => setShowDropdown(false)}
>
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: space.color }}
/>
<Text className="text-ink text-sm">{space.name}</Text>
</Pressable>
))}
</Card>
)}
</View>
);
{showDropdown && spaces && spaces.length > 0 && (
<Card className="mt-2">
{spaces.map((space) => (
<Pressable
className="flex-row items-center gap-2 px-2 py-2"
key={space.id}
onPress={() => setShowDropdown(false)}
>
<View
className="h-2 w-2 rounded-full"
style={{ backgroundColor: space.color }}
/>
<Text className="text-ink text-sm">{space.name}</Text>
</Pressable>
))}
</Card>
)}
</View>
);
}
export function BrowseScreen() {
const insets = useSafeAreaInsets();
const insets = useSafeAreaInsets();
// Fetch data using queries
const { data: locations } = useLibraryQuery("locations.list");
// TODO: Re-enable when backend supports these queries
// const { data: tags } = useLibraryQuery("tags.list");
const { data: spaces } = useLibraryQuery("spaces.list", {});
// Fetch data using queries
const { data: locations } = useLibraryQuery("locations.list");
// TODO: Re-enable when backend supports these queries
// const { data: tags } = useLibraryQuery("tags.list");
const { data: spaces } = useLibraryQuery("spaces.list", {});
// Mock current space (first space if available)
const currentSpace = spaces && spaces.length > 0 ? spaces[0] : undefined;
// Mock current space (first space if available)
const currentSpace = spaces && spaces.length > 0 ? spaces[0] : undefined;
return (
<ScrollView
className="flex-1 bg-sidebar"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 100,
paddingHorizontal: 16,
}}
>
{/* Header */}
<View className="mb-6">
<Text className="text-2xl font-bold text-ink">Browse</Text>
<Text className="text-ink-dull text-sm mt-1">
Your libraries and spaces
</Text>
</View>
return (
<ScrollView
className="flex-1 bg-sidebar"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 100,
paddingHorizontal: 16,
}}
>
{/* Header */}
<View className="mb-6">
<Text className="font-bold text-2xl text-ink">Browse</Text>
<Text className="mt-1 text-ink-dull text-sm">
Your libraries and spaces
</Text>
</View>
{/* Space Switcher */}
<SpaceSwitcher spaces={spaces as Space[] | undefined} currentSpace={currentSpace as Space | undefined} />
{/* Space Switcher */}
<SpaceSwitcher
currentSpace={currentSpace as Space | undefined}
spaces={spaces as Space[] | undefined}
/>
{/* Quick Access */}
<CollapsibleGroup title="Quick Access">
<SidebarItem icon="🏠" label="Overview" isActive={true} />
<SidebarItem icon="🕒" label="Recents" />
<SidebarItem icon="❤️" label="Favorites" />
</CollapsibleGroup>
{/* Quick Access */}
<CollapsibleGroup title="Quick Access">
<SidebarItem icon="🏠" isActive={true} label="Overview" />
<SidebarItem icon="🕒" label="Recents" />
<SidebarItem icon="❤️" label="Favorites" />
</CollapsibleGroup>
{/* Locations */}
<CollapsibleGroup title="Locations">
{locations && Array.isArray(locations) && locations.length > 0 ? (
locations.map((loc: any) => (
<SidebarItem
key={loc.id}
icon="📁"
label={loc.name || "Unnamed"}
/>
))
) : (
<View className="px-2 py-3">
<Text className="text-ink-dull text-sm">
No locations added
</Text>
</View>
)}
</CollapsibleGroup>
{/* Locations */}
<CollapsibleGroup title="Locations">
{locations && Array.isArray(locations) && locations.length > 0 ? (
locations.map((loc: any) => (
<SidebarItem icon="📁" key={loc.id} label={loc.name || "Unnamed"} />
))
) : (
<View className="px-2 py-3">
<Text className="text-ink-dull text-sm">No locations added</Text>
</View>
)}
</CollapsibleGroup>
{/* Devices */}
<CollapsibleGroup title="Devices">
<SidebarItem icon="💻" label="This Device" />
</CollapsibleGroup>
{/* Devices */}
<CollapsibleGroup title="Devices">
<SidebarItem icon="💻" label="This Device" />
</CollapsibleGroup>
{/* Volumes */}
<CollapsibleGroup title="Volumes">
<SidebarItem icon="💾" label="Macintosh HD" />
</CollapsibleGroup>
{/* Volumes */}
<CollapsibleGroup title="Volumes">
<SidebarItem icon="💾" label="Macintosh HD" />
</CollapsibleGroup>
{/* Tags */}
<CollapsibleGroup title="Tags">
<View className="px-2 py-3">
<Text className="text-ink-dull text-sm">
No tags created
</Text>
</View>
</CollapsibleGroup>
{/* Tags */}
<CollapsibleGroup title="Tags">
<View className="px-2 py-3">
<Text className="text-ink-dull text-sm">No tags created</Text>
</View>
</CollapsibleGroup>
{/* Bottom Section */}
<View className="mt-6 space-y-1">
<SidebarItem icon="🔄" label="Sync Monitor" />
<SidebarItem icon="⚙️" label="Settings" />
</View>
</ScrollView>
);
{/* Bottom Section */}
<View className="mt-6 space-y-1">
<SidebarItem icon="🔄" label="Sync Monitor" />
<SidebarItem icon="⚙️" label="Settings" />
</View>
</ScrollView>
);
}

View File

@@ -1,108 +1,89 @@
import React from "react";
import { View, Text, ScrollView, Pressable } from "react-native";
import { DrawerActions, useNavigation } from "@react-navigation/native";
import { Pressable, ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useNavigation, DrawerActions } from "@react-navigation/native";
import { Card } from "../../components/primitive";
export function NetworkScreen() {
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const openDrawer = () => {
navigation.dispatch(DrawerActions.openDrawer());
};
const openDrawer = () => {
navigation.dispatch(DrawerActions.openDrawer());
};
return (
<ScrollView
className="flex-1 bg-app"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 100,
paddingHorizontal: 16,
}}
>
{/* Header */}
<View className="flex-row items-center justify-between mb-6">
<Pressable onPress={openDrawer} className="p-2 -ml-2">
<View className="w-6 h-0.5 bg-ink mb-1.5" />
<View className="w-6 h-0.5 bg-ink mb-1.5" />
<View className="w-6 h-0.5 bg-ink" />
</Pressable>
<Text className="text-2xl font-bold text-ink">Network</Text>
<View className="w-10" />
</View>
return (
<ScrollView
className="flex-1 bg-app"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 100,
paddingHorizontal: 16,
}}
>
{/* Header */}
<View className="mb-6 flex-row items-center justify-between">
<Pressable className="-ml-2 p-2" onPress={openDrawer}>
<View className="mb-1.5 h-0.5 w-6 bg-ink" />
<View className="mb-1.5 h-0.5 w-6 bg-ink" />
<View className="h-0.5 w-6 bg-ink" />
</Pressable>
<Text className="font-bold text-2xl text-ink">Network</Text>
<View className="w-10" />
</View>
{/* Network Status */}
<Card className="mb-6">
<View className="flex-row items-center">
<View className="w-3 h-3 rounded-full bg-green-500 mr-3" />
<View className="flex-1">
<Text className="text-ink font-medium">
Network Status
</Text>
<Text className="text-ink-dull text-sm">
P2P enabled
</Text>
</View>
</View>
</Card>
{/* Network Status */}
<Card className="mb-6">
<View className="flex-row items-center">
<View className="mr-3 h-3 w-3 rounded-full bg-green-500" />
<View className="flex-1">
<Text className="font-medium text-ink">Network Status</Text>
<Text className="text-ink-dull text-sm">P2P enabled</Text>
</View>
</View>
</Card>
{/* Devices */}
<View className="mb-6">
<Text className="text-lg font-semibold text-ink mb-3">
This Device
</Text>
<Card>
<View className="flex-row items-center">
<View className="w-10 h-10 rounded-lg bg-accent/20 items-center justify-center mr-3">
<Text className="text-accent">📱</Text>
</View>
<View>
<Text className="text-ink font-medium">
Spacedrive Mobile
</Text>
<Text className="text-ink-dull text-sm">
Connected
</Text>
</View>
</View>
</Card>
</View>
{/* Devices */}
<View className="mb-6">
<Text className="mb-3 font-semibold text-ink text-lg">This Device</Text>
<Card>
<View className="flex-row items-center">
<View className="mr-3 h-10 w-10 items-center justify-center rounded-lg bg-accent/20">
<Text className="text-accent">📱</Text>
</View>
<View>
<Text className="font-medium text-ink">Spacedrive Mobile</Text>
<Text className="text-ink-dull text-sm">Connected</Text>
</View>
</View>
</Card>
</View>
{/* Nearby Devices */}
<View className="mb-6">
<Text className="text-lg font-semibold text-ink mb-3">
Nearby Devices
</Text>
<Card>
<Text className="text-ink-dull">
Searching for devices...
</Text>
<Text className="text-ink-faint text-sm mt-1">
Make sure other devices are on the same network
</Text>
</Card>
</View>
{/* Nearby Devices */}
<View className="mb-6">
<Text className="mb-3 font-semibold text-ink text-lg">
Nearby Devices
</Text>
<Card>
<Text className="text-ink-dull">Searching for devices...</Text>
<Text className="mt-1 text-ink-faint text-sm">
Make sure other devices are on the same network
</Text>
</Card>
</View>
{/* Sync Status */}
<View className="mb-6">
<Text className="text-lg font-semibold text-ink mb-3">
Sync
</Text>
<Card className="flex-row items-center justify-between">
<View>
<Text className="text-ink font-medium">
Sync Status
</Text>
<Text className="text-ink-dull text-sm">
Up to date
</Text>
</View>
<View className="px-3 py-1 rounded-full bg-green-500/20">
<Text className="text-green-500 text-sm">Synced</Text>
</View>
</Card>
</View>
</ScrollView>
);
{/* Sync Status */}
<View className="mb-6">
<Text className="mb-3 font-semibold text-ink text-lg">Sync</Text>
<Card className="flex-row items-center justify-between">
<View>
<Text className="font-medium text-ink">Sync Status</Text>
<Text className="text-ink-dull text-sm">Up to date</Text>
</View>
<View className="rounded-full bg-green-500/20 px-3 py-1">
<Text className="text-green-500 text-sm">Synced</Text>
</View>
</Card>
</View>
</ScrollView>
);
}

View File

@@ -1,169 +1,163 @@
import React, { useState } from "react";
import { View, Text, ScrollView, Pressable } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useNavigation, DrawerActions } from "@react-navigation/native";
import { useNormalizedQuery } from "../../client";
import { DrawerActions, useNavigation } from "@react-navigation/native";
import type { LibraryInfoOutput } from "@sd/ts-client";
import { HeroStats, PairedDevices, StorageOverview } from "./components";
import { PairingPanel } from "../../components/PairingPanel";
import { useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useNormalizedQuery } from "../../client";
import { LibrarySwitcherPanel } from "../../components/LibrarySwitcherPanel";
import { PairingPanel } from "../../components/PairingPanel";
import { HeroStats, PairedDevices, StorageOverview } from "./components";
export function OverviewScreen() {
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const [showPairing, setShowPairing] = useState(false);
const [showLibrarySwitcher, setShowLibrarySwitcher] = useState(false);
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const [showPairing, setShowPairing] = useState(false);
const [showLibrarySwitcher, setShowLibrarySwitcher] = useState(false);
// Fetch library info with real-time statistics updates
const {
data: libraryInfo,
isLoading,
error,
} = useNormalizedQuery<null, LibraryInfoOutput>({
wireMethod: "query:libraries.info",
input: null,
resourceType: "library",
});
// Fetch library info with real-time statistics updates
const {
data: libraryInfo,
isLoading,
error,
} = useNormalizedQuery<null, LibraryInfoOutput>({
wireMethod: "query:libraries.info",
input: null,
resourceType: "library",
});
const openDrawer = () => {
navigation.dispatch(DrawerActions.openDrawer());
};
const openDrawer = () => {
navigation.dispatch(DrawerActions.openDrawer());
};
if (isLoading || !libraryInfo) {
return (
<ScrollView
className="flex-1 bg-app"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 100,
paddingHorizontal: 16,
}}
>
{/* Header */}
<View className="flex-row items-center justify-between mb-6">
<Pressable onPress={openDrawer} className="p-2 -ml-2">
<View className="w-6 h-0.5 bg-ink mb-1.5" />
<View className="w-6 h-0.5 bg-ink mb-1.5" />
<View className="w-6 h-0.5 bg-ink" />
</Pressable>
<Text className="text-xl font-bold text-ink">
{libraryInfo?.name || "Loading..."}
</Text>
<Pressable
onPress={() => setShowPairing(true)}
className="p-2 -mr-2 active:bg-app-hover rounded-lg"
>
<Text className="text-accent text-xl"></Text>
</Pressable>
</View>
if (isLoading || !libraryInfo) {
return (
<ScrollView
className="flex-1 bg-app"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 100,
paddingHorizontal: 16,
}}
>
{/* Header */}
<View className="mb-6 flex-row items-center justify-between">
<Pressable className="-ml-2 p-2" onPress={openDrawer}>
<View className="mb-1.5 h-0.5 w-6 bg-ink" />
<View className="mb-1.5 h-0.5 w-6 bg-ink" />
<View className="h-0.5 w-6 bg-ink" />
</Pressable>
<Text className="font-bold text-ink text-xl">
{libraryInfo?.name || "Loading..."}
</Text>
<Pressable
className="-mr-2 rounded-lg p-2 active:bg-app-hover"
onPress={() => setShowPairing(true)}
>
<Text className="text-accent text-xl"></Text>
</Pressable>
</View>
<View className="items-center justify-center py-12">
<Text className="text-ink-dull">
Loading library statistics...
</Text>
</View>
</ScrollView>
);
}
<View className="items-center justify-center py-12">
<Text className="text-ink-dull">Loading library statistics...</Text>
</View>
</ScrollView>
);
}
if (error) {
return (
<ScrollView
className="flex-1 bg-app"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 100,
paddingHorizontal: 16,
}}
>
{/* Header */}
<View className="flex-row items-center justify-between mb-6">
<Pressable onPress={openDrawer} className="p-2 -ml-2">
<View className="w-6 h-0.5 bg-ink mb-1.5" />
<View className="w-6 h-0.5 bg-ink mb-1.5" />
<View className="w-6 h-0.5 bg-ink" />
</Pressable>
<Text className="text-xl font-bold text-ink">Overview</Text>
<Pressable
onPress={() => setShowPairing(true)}
className="p-2 -mr-2 active:bg-app-hover rounded-lg"
>
<Text className="text-accent text-xl"></Text>
</Pressable>
</View>
if (error) {
return (
<ScrollView
className="flex-1 bg-app"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 100,
paddingHorizontal: 16,
}}
>
{/* Header */}
<View className="mb-6 flex-row items-center justify-between">
<Pressable className="-ml-2 p-2" onPress={openDrawer}>
<View className="mb-1.5 h-0.5 w-6 bg-ink" />
<View className="mb-1.5 h-0.5 w-6 bg-ink" />
<View className="h-0.5 w-6 bg-ink" />
</Pressable>
<Text className="font-bold text-ink text-xl">Overview</Text>
<Pressable
className="-mr-2 rounded-lg p-2 active:bg-app-hover"
onPress={() => setShowPairing(true)}
>
<Text className="text-accent text-xl"></Text>
</Pressable>
</View>
<View className="items-center justify-center py-12">
<Text className="text-red-500 font-semibold">Error</Text>
<Text className="text-ink-dull mt-2">
{String(error)}
</Text>
</View>
</ScrollView>
);
}
<View className="items-center justify-center py-12">
<Text className="font-semibold text-red-500">Error</Text>
<Text className="mt-2 text-ink-dull">{String(error)}</Text>
</View>
</ScrollView>
);
}
const stats = libraryInfo.statistics;
const stats = libraryInfo.statistics;
return (
<ScrollView
className="flex-1 bg-app"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 100,
paddingHorizontal: 16,
}}
>
{/* Header */}
<View className="flex-row items-center justify-between mb-6">
<Pressable onPress={openDrawer} className="p-2 -ml-2">
<View className="w-6 h-0.5 bg-ink mb-1.5" />
<View className="w-6 h-0.5 bg-ink mb-1.5" />
<View className="w-6 h-0.5 bg-ink" />
</Pressable>
<Pressable
onPress={() => setShowLibrarySwitcher(true)}
className="flex-1 items-center active:opacity-70"
>
<Text className="text-xl font-bold text-ink">
{libraryInfo.name}
</Text>
</Pressable>
<Pressable
onPress={() => setShowPairing(true)}
className="p-2 -mr-2 active:bg-app-hover rounded-lg"
>
<Text className="text-accent text-xl"></Text>
</Pressable>
</View>
return (
<ScrollView
className="flex-1 bg-app"
contentContainerStyle={{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 100,
paddingHorizontal: 16,
}}
>
{/* Header */}
<View className="mb-6 flex-row items-center justify-between">
<Pressable className="-ml-2 p-2" onPress={openDrawer}>
<View className="mb-1.5 h-0.5 w-6 bg-ink" />
<View className="mb-1.5 h-0.5 w-6 bg-ink" />
<View className="h-0.5 w-6 bg-ink" />
</Pressable>
<Pressable
className="flex-1 items-center active:opacity-70"
onPress={() => setShowLibrarySwitcher(true)}
>
<Text className="font-bold text-ink text-xl">{libraryInfo.name}</Text>
</Pressable>
<Pressable
className="-mr-2 rounded-lg p-2 active:bg-app-hover"
onPress={() => setShowPairing(true)}
>
<Text className="text-accent text-xl"></Text>
</Pressable>
</View>
{/* Hero Stats */}
<HeroStats
totalStorage={stats.total_capacity}
usedStorage={stats.total_capacity - stats.available_capacity}
totalFiles={Number(stats.total_files)}
locationCount={stats.location_count}
tagCount={stats.tag_count}
deviceCount={stats.device_count}
uniqueContentCount={Number(stats.unique_content_count)}
/>
{/* Hero Stats */}
<HeroStats
deviceCount={stats.device_count}
locationCount={stats.location_count}
tagCount={stats.tag_count}
totalFiles={Number(stats.total_files)}
totalStorage={stats.total_capacity}
uniqueContentCount={Number(stats.unique_content_count)}
usedStorage={stats.total_capacity - stats.available_capacity}
/>
{/* Paired Devices */}
<PairedDevices />
{/* Paired Devices */}
<PairedDevices />
{/* Storage Volumes */}
<StorageOverview />
{/* Storage Volumes */}
<StorageOverview />
{/* Pairing Panel */}
<PairingPanel
isOpen={showPairing}
onClose={() => setShowPairing(false)}
/>
{/* Pairing Panel */}
<PairingPanel
isOpen={showPairing}
onClose={() => setShowPairing(false)}
/>
{/* Library Switcher Panel */}
<LibrarySwitcherPanel
isOpen={showLibrarySwitcher}
onClose={() => setShowLibrarySwitcher(false)}
/>
</ScrollView>
);
{/* Library Switcher Panel */}
<LibrarySwitcherPanel
isOpen={showLibrarySwitcher}
onClose={() => setShowLibrarySwitcher(false)}
/>
</ScrollView>
);
}

View File

@@ -1,94 +1,93 @@
import React from "react";
import { View, Text } from "react-native";
import { Text, View } from "react-native";
interface HeroStatsProps {
totalStorage: number; // bytes
usedStorage: number; // bytes
totalFiles: number;
locationCount: number;
tagCount: number;
deviceCount: number;
uniqueContentCount: number;
totalStorage: number; // bytes
usedStorage: number; // bytes
totalFiles: number;
locationCount: number;
tagCount: number;
deviceCount: number;
uniqueContentCount: number;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
}
export function HeroStats({
totalStorage,
usedStorage,
totalFiles,
locationCount,
deviceCount,
uniqueContentCount,
totalStorage,
usedStorage,
totalFiles,
locationCount,
deviceCount,
uniqueContentCount,
}: HeroStatsProps) {
const usagePercent =
totalStorage > 0 ? (usedStorage / totalStorage) * 100 : 0;
const usagePercent =
totalStorage > 0 ? (usedStorage / totalStorage) * 100 : 0;
return (
<View className="bg-app-box border border-app-line rounded-2xl p-6 mb-6">
<View className="flex-row flex-wrap gap-4">
{/* Total Storage */}
<StatCard
label="Total Storage"
value={formatBytes(totalStorage)}
subtitle={`${formatBytes(usedStorage)} used`}
progress={usagePercent}
/>
return (
<View className="mb-6 rounded-2xl border border-app-line bg-app-box p-6">
<View className="flex-row flex-wrap gap-4">
{/* Total Storage */}
<StatCard
label="Total Storage"
progress={usagePercent}
subtitle={`${formatBytes(usedStorage)} used`}
value={formatBytes(totalStorage)}
/>
{/* Files */}
<StatCard
label="Files Indexed"
value={totalFiles.toLocaleString()}
subtitle={`${uniqueContentCount.toLocaleString()} unique`}
/>
{/* Files */}
<StatCard
label="Files Indexed"
subtitle={`${uniqueContentCount.toLocaleString()} unique`}
value={totalFiles.toLocaleString()}
/>
{/* Devices */}
<StatCard
label="Devices"
value={deviceCount.toString()}
subtitle="connected"
/>
{/* Devices */}
<StatCard
label="Devices"
subtitle="connected"
value={deviceCount.toString()}
/>
{/* Locations */}
<StatCard
label="Locations"
value={locationCount.toString()}
subtitle="tracked"
/>
</View>
</View>
);
{/* Locations */}
<StatCard
label="Locations"
subtitle="tracked"
value={locationCount.toString()}
/>
</View>
</View>
);
}
interface StatCardProps {
label: string;
value: string | number;
subtitle: string;
progress?: number;
label: string;
value: string | number;
subtitle: string;
progress?: number;
}
function StatCard({ label, value, subtitle, progress }: StatCardProps) {
return (
<View className="flex-1 min-w-[140px]">
<View className="mb-2">
<Text className="text-2xl font-bold text-ink">{value}</Text>
<Text className="text-xs text-ink-dull mt-0.5">{label}</Text>
<Text className="text-xs text-ink-faint">{subtitle}</Text>
</View>
{progress !== undefined && (
<View className="h-1.5 bg-app-darkBox rounded-full overflow-hidden">
<View
className="h-full bg-accent rounded-full"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</View>
)}
</View>
);
return (
<View className="min-w-[140px] flex-1">
<View className="mb-2">
<Text className="font-bold text-2xl text-ink">{value}</Text>
<Text className="mt-0.5 text-ink-dull text-xs">{label}</Text>
<Text className="text-ink-faint text-xs">{subtitle}</Text>
</View>
{progress !== undefined && (
<View className="h-1.5 overflow-hidden rounded-full bg-app-darkBox">
<View
className="h-full rounded-full bg-accent"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</View>
)}
</View>
);
}

View File

@@ -1,127 +1,113 @@
import React from "react";
import { View, Text, Pressable, Image } from "react-native";
import { useLibraryQuery } from "../../../client";
import { getDeviceIcon } from "@sd/ts-client";
import { Image, Text, View } from "react-native";
import { useLibraryQuery } from "../../../client";
export function PairedDevices() {
const { data: devices, isLoading } = useLibraryQuery(
"devices.list",
{
include_offline: true,
include_details: false,
show_paired: true,
}
);
const { data: devices, isLoading } = useLibraryQuery("devices.list", {
include_offline: true,
include_details: false,
show_paired: true,
});
if (isLoading) {
return (
<View className="bg-app-box border border-app-line rounded-xl overflow-hidden mb-6">
<View className="px-6 py-4 border-b border-app-line">
<Text className="text-base font-semibold text-ink">
Paired Devices
</Text>
<Text className="text-sm text-ink-dull mt-1">
Loading devices...
</Text>
</View>
</View>
);
}
if (isLoading) {
return (
<View className="mb-6 overflow-hidden rounded-xl border border-app-line bg-app-box">
<View className="border-app-line border-b px-6 py-4">
<Text className="font-semibold text-base text-ink">
Paired Devices
</Text>
<Text className="mt-1 text-ink-dull text-sm">Loading devices...</Text>
</View>
</View>
);
}
const devicesList = devices || [];
const connectedCount = devicesList.filter((d: any) => d.is_connected).length;
const devicesList = devices || [];
const connectedCount = devicesList.filter((d: any) => d.is_connected).length;
return (
<View className="bg-app-box border border-app-line rounded-xl overflow-hidden mb-6">
<View className="px-6 py-4 border-b border-app-line">
<Text className="text-base font-semibold text-ink">
Paired Devices
</Text>
<Text className="text-sm text-ink-dull mt-1">
{devicesList.length}{" "}
{devicesList.length === 1 ? "device" : "devices"} paired
{connectedCount > 0 && `${connectedCount} connected`}
</Text>
</View>
return (
<View className="mb-6 overflow-hidden rounded-xl border border-app-line bg-app-box">
<View className="border-app-line border-b px-6 py-4">
<Text className="font-semibold text-base text-ink">Paired Devices</Text>
<Text className="mt-1 text-ink-dull text-sm">
{devicesList.length} {devicesList.length === 1 ? "device" : "devices"}{" "}
paired
{connectedCount > 0 && `${connectedCount} connected`}
</Text>
</View>
<View className="p-4">
{devicesList.map((device: any, idx: number) => (
<DeviceCard key={device.id} device={device} />
))}
<View className="p-4">
{devicesList.map((device: any, idx: number) => (
<DeviceCard device={device} key={device.id} />
))}
{devicesList.length === 0 && (
<View className="py-12 items-center">
<Text className="text-ink-faint text-sm">
No paired devices
</Text>
<Text className="text-ink-faint text-xs mt-1">
Pair a device to share files and sync data
</Text>
</View>
)}
</View>
</View>
);
{devicesList.length === 0 && (
<View className="items-center py-12">
<Text className="text-ink-faint text-sm">No paired devices</Text>
<Text className="mt-1 text-ink-faint text-xs">
Pair a device to share files and sync data
</Text>
</View>
)}
</View>
</View>
);
}
interface DeviceCardProps {
device: any;
device: any;
}
function DeviceCard({ device }: DeviceCardProps) {
const iconSource = getDeviceIcon(device);
const iconSource = getDeviceIcon(device);
return (
<View className="p-4 mb-3 bg-app-darkBox rounded-lg border border-app-line">
<View className="flex-row items-center justify-between mb-2">
<View className="flex-row items-center gap-3">
<Image
source={iconSource}
className="w-10 h-10"
style={{ resizeMode: "contain" }}
/>
<View>
<Text className="font-semibold text-ink text-base">
{device.name}
</Text>
<Text className="text-xs text-ink-dull mt-0.5">
{device.device_type} {device.os_version}
</Text>
</View>
</View>
<View
className={`px-2 py-1 rounded-md ${
device.is_connected
? "bg-green-500/10 border border-green-500/30"
: "bg-app-box border border-app-line"
}`}
>
<Text
className={`text-xs font-medium ${
device.is_connected
? "text-green-500"
: "text-ink-faint"
}`}
>
{device.is_connected ? "Connected" : "Offline"}
</Text>
</View>
</View>
return (
<View className="mb-3 rounded-lg border border-app-line bg-app-darkBox p-4">
<View className="mb-2 flex-row items-center justify-between">
<View className="flex-row items-center gap-3">
<Image
className="h-10 w-10"
source={iconSource}
style={{ resizeMode: "contain" }}
/>
<View>
<Text className="font-semibold text-base text-ink">
{device.name}
</Text>
<Text className="mt-0.5 text-ink-dull text-xs">
{device.device_type} {device.os_version}
</Text>
</View>
</View>
<View
className={`rounded-md px-2 py-1 ${
device.is_connected
? "border border-green-500/30 bg-green-500/10"
: "border border-app-line bg-app-box"
}`}
>
<Text
className={`font-medium text-xs ${
device.is_connected ? "text-green-500" : "text-ink-faint"
}`}
>
{device.is_connected ? "Connected" : "Offline"}
</Text>
</View>
</View>
<View className="flex-row flex-wrap gap-2">
<View className="px-2 py-0.5 bg-app-box rounded border border-app-line">
<Text className="text-ink-dull text-xs">
v{device.app_version}
</Text>
</View>
{device.last_seen && (
<View className="px-2 py-0.5 bg-app-box rounded border border-app-line">
<Text className="text-ink-dull text-xs">
Last seen: {new Date(device.last_seen).toLocaleDateString()}
</Text>
</View>
)}
</View>
</View>
);
<View className="flex-row flex-wrap gap-2">
<View className="rounded border border-app-line bg-app-box px-2 py-0.5">
<Text className="text-ink-dull text-xs">v{device.app_version}</Text>
</View>
{device.last_seen && (
<View className="rounded border border-app-line bg-app-box px-2 py-0.5">
<Text className="text-ink-dull text-xs">
Last seen: {new Date(device.last_seen).toLocaleDateString()}
</Text>
</View>
)}
</View>
</View>
);
}

View File

@@ -1,221 +1,209 @@
import React from "react";
import { View, Text, Pressable } from "react-native";
import { useLibraryQuery, useLibraryAction } from "../../../client";
import { Pressable, Text, View } from "react-native";
import { useLibraryAction, useLibraryQuery } from "../../../client";
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
}
function getDiskTypeLabel(diskType: string): string {
return diskType === "SSD" ? "SSD" : diskType === "HDD" ? "HDD" : diskType;
return diskType === "SSD" ? "SSD" : diskType === "HDD" ? "HDD" : diskType;
}
export function StorageOverview() {
// Fetch all volumes
const { data: volumesData, isLoading: volumesLoading } = useLibraryQuery(
"volumes.list",
{ filter: "All" }
);
// Fetch all volumes
const { data: volumesData, isLoading: volumesLoading } = useLibraryQuery(
"volumes.list",
{ filter: "All" }
);
// Fetch all devices
const { data: devicesData, isLoading: devicesLoading } = useLibraryQuery(
"devices.list",
{ include_offline: true, include_details: false }
);
// Fetch all devices
const { data: devicesData, isLoading: devicesLoading } = useLibraryQuery(
"devices.list",
{ include_offline: true, include_details: false }
);
if (volumesLoading || devicesLoading) {
return (
<View className="bg-app-box border border-app-line rounded-xl overflow-hidden mb-6">
<View className="px-6 py-4 border-b border-app-line">
<Text className="text-base font-semibold text-ink">
Storage Volumes
</Text>
<Text className="text-sm text-ink-dull mt-1">
Loading volumes...
</Text>
</View>
</View>
);
}
if (volumesLoading || devicesLoading) {
return (
<View className="mb-6 overflow-hidden rounded-xl border border-app-line bg-app-box">
<View className="border-app-line border-b px-6 py-4">
<Text className="font-semibold text-base text-ink">
Storage Volumes
</Text>
<Text className="mt-1 text-ink-dull text-sm">Loading volumes...</Text>
</View>
</View>
);
}
const volumes = volumesData?.volumes || [];
const devices = devicesData || [];
const volumes = volumesData?.volumes || [];
const devices = devicesData || [];
// Filter to only show user-visible volumes
const userVisibleVolumes = volumes.filter(
(volume: any) => volume.is_user_visible !== false
);
// Filter to only show user-visible volumes
const userVisibleVolumes = volumes.filter(
(volume: any) => volume.is_user_visible !== false
);
return (
<View className="bg-app-box border border-app-line rounded-xl overflow-hidden mb-6">
<View className="px-6 py-4 border-b border-app-line">
<Text className="text-base font-semibold text-ink">
Storage Volumes
</Text>
<Text className="text-sm text-ink-dull mt-1">
{userVisibleVolumes.length}{" "}
{userVisibleVolumes.length === 1 ? "volume" : "volumes"}{" "}
across {devices.length}{" "}
{devices.length === 1 ? "device" : "devices"}
</Text>
</View>
return (
<View className="mb-6 overflow-hidden rounded-xl border border-app-line bg-app-box">
<View className="border-app-line border-b px-6 py-4">
<Text className="font-semibold text-base text-ink">
Storage Volumes
</Text>
<Text className="mt-1 text-ink-dull text-sm">
{userVisibleVolumes.length}{" "}
{userVisibleVolumes.length === 1 ? "volume" : "volumes"} across{" "}
{devices.length} {devices.length === 1 ? "device" : "devices"}
</Text>
</View>
<View className="p-4">
{userVisibleVolumes.map((volume: any, idx: number) => (
<VolumeBar key={volume.id} volume={volume} />
))}
<View className="p-4">
{userVisibleVolumes.map((volume: any, idx: number) => (
<VolumeBar key={volume.id} volume={volume} />
))}
{userVisibleVolumes.length === 0 && (
<View className="py-12 items-center">
<Text className="text-ink-faint text-sm">
No volumes detected
</Text>
<Text className="text-ink-faint text-xs mt-1">
Track a volume to see storage information
</Text>
</View>
)}
</View>
</View>
);
{userVisibleVolumes.length === 0 && (
<View className="items-center py-12">
<Text className="text-ink-faint text-sm">No volumes detected</Text>
<Text className="mt-1 text-ink-faint text-xs">
Track a volume to see storage information
</Text>
</View>
)}
</View>
</View>
);
}
interface VolumeBarProps {
volume: any;
volume: any;
}
function VolumeBar({ volume }: VolumeBarProps) {
const trackVolume = useLibraryAction("volumes.track");
const trackVolume = useLibraryAction("volumes.track");
const handleTrack = async () => {
try {
await trackVolume.mutateAsync({
fingerprint: volume.fingerprint,
});
} catch (error) {
console.error("Failed to track volume:", error);
}
};
const handleTrack = async () => {
try {
await trackVolume.mutateAsync({
fingerprint: volume.fingerprint,
});
} catch (error) {
console.error("Failed to track volume:", error);
}
};
if (!volume.total_capacity) {
return null;
}
if (!volume.total_capacity) {
return null;
}
const totalCapacity = volume.total_capacity;
const availableBytes = volume.available_capacity || 0;
const usedBytes = totalCapacity - availableBytes;
const totalCapacity = volume.total_capacity;
const availableBytes = volume.available_capacity || 0;
const usedBytes = totalCapacity - availableBytes;
const uniqueBytes = volume.unique_bytes ?? Math.floor(usedBytes * 0.7);
const duplicateBytes = usedBytes - uniqueBytes;
const uniqueBytes = volume.unique_bytes ?? Math.floor(usedBytes * 0.7);
const duplicateBytes = usedBytes - uniqueBytes;
const usagePercent = (usedBytes / totalCapacity) * 100;
const uniquePercent = (uniqueBytes / totalCapacity) * 100;
const duplicatePercent = (duplicateBytes / totalCapacity) * 100;
const usagePercent = (usedBytes / totalCapacity) * 100;
const uniquePercent = (uniqueBytes / totalCapacity) * 100;
const duplicatePercent = (duplicateBytes / totalCapacity) * 100;
const fileSystem = volume.file_system || "Unknown";
const diskType = volume.disk_type || "Unknown";
const readSpeed = volume.read_speed_mbps;
const fileSystem = volume.file_system || "Unknown";
const diskType = volume.disk_type || "Unknown";
const readSpeed = volume.read_speed_mbps;
return (
<View className="p-4 mb-3 bg-app-darkBox rounded-lg border border-app-line">
{/* Header */}
<View className="flex-row items-center justify-between mb-3">
<View className="flex-1 flex-row items-center gap-2">
<Text className="font-semibold text-ink text-base">
{volume.name}
</Text>
{!volume.is_online && (
<View className="px-2 py-0.5 bg-app-box border border-app-line rounded">
<Text className="text-ink-faint text-xs">
Offline
</Text>
</View>
)}
{!volume.is_tracked && (
<Pressable
onPress={handleTrack}
disabled={trackVolume.isPending}
className="px-2 py-0.5 bg-accent/10 border border-accent/20 rounded active:bg-accent/20"
>
<Text className="text-accent text-xs">
{trackVolume.isPending ? "Tracking..." : "Track"}
</Text>
</Pressable>
)}
</View>
<View className="items-end">
<Text className="text-sm font-medium text-ink">
{formatBytes(totalCapacity)}
</Text>
<Text className="text-xs text-ink-dull">
{formatBytes(availableBytes)} free
</Text>
</View>
</View>
return (
<View className="mb-3 rounded-lg border border-app-line bg-app-darkBox p-4">
{/* Header */}
<View className="mb-3 flex-row items-center justify-between">
<View className="flex-1 flex-row items-center gap-2">
<Text className="font-semibold text-base text-ink">
{volume.name}
</Text>
{!volume.is_online && (
<View className="rounded border border-app-line bg-app-box px-2 py-0.5">
<Text className="text-ink-faint text-xs">Offline</Text>
</View>
)}
{!volume.is_tracked && (
<Pressable
className="rounded border border-accent/20 bg-accent/10 px-2 py-0.5 active:bg-accent/20"
disabled={trackVolume.isPending}
onPress={handleTrack}
>
<Text className="text-accent text-xs">
{trackVolume.isPending ? "Tracking..." : "Track"}
</Text>
</Pressable>
)}
</View>
<View className="items-end">
<Text className="font-medium text-ink text-sm">
{formatBytes(totalCapacity)}
</Text>
<Text className="text-ink-dull text-xs">
{formatBytes(availableBytes)} free
</Text>
</View>
</View>
{/* Capacity bar */}
<View className="h-6 bg-app rounded-md overflow-hidden border border-app-line mb-3">
<View className="h-full flex-row">
{/* Unique bytes */}
<View
className="bg-blue-500"
style={{ width: `${uniquePercent}%` }}
/>
{/* Duplicate bytes */}
<View
className="bg-blue-400"
style={{ width: `${duplicatePercent}%` }}
/>
</View>
</View>
{/* Capacity bar */}
<View className="mb-3 h-6 overflow-hidden rounded-md border border-app-line bg-app">
<View className="h-full flex-row">
{/* Unique bytes */}
<View
className="bg-blue-500"
style={{ width: `${uniquePercent}%` }}
/>
{/* Duplicate bytes */}
<View
className="bg-blue-400"
style={{ width: `${duplicatePercent}%` }}
/>
</View>
</View>
{/* Stats */}
<View className="flex-row flex-wrap gap-2 mb-2">
<View className="flex-row items-center gap-1.5">
<View className="size-3 rounded bg-blue-500" />
<Text className="text-ink-dull text-xs">
Unique: {formatBytes(uniqueBytes)}
</Text>
</View>
<View className="flex-row items-center gap-1.5">
<View className="size-3 rounded bg-blue-400" />
<Text className="text-ink-dull text-xs">
Duplicate: {formatBytes(duplicateBytes)}
</Text>
</View>
<Text className="text-ink-faint text-xs"></Text>
<Text className="text-ink-dull text-xs">
{usagePercent.toFixed(1)}% used
</Text>
</View>
{/* Stats */}
<View className="mb-2 flex-row flex-wrap gap-2">
<View className="flex-row items-center gap-1.5">
<View className="size-3 rounded bg-blue-500" />
<Text className="text-ink-dull text-xs">
Unique: {formatBytes(uniqueBytes)}
</Text>
</View>
<View className="flex-row items-center gap-1.5">
<View className="size-3 rounded bg-blue-400" />
<Text className="text-ink-dull text-xs">
Duplicate: {formatBytes(duplicateBytes)}
</Text>
</View>
<Text className="text-ink-faint text-xs"></Text>
<Text className="text-ink-dull text-xs">
{usagePercent.toFixed(1)}% used
</Text>
</View>
{/* Tags */}
<View className="flex-row flex-wrap gap-2">
<View className="px-2 py-0.5 bg-app-box rounded border border-app-line">
<Text className="text-ink-dull text-xs">{fileSystem}</Text>
</View>
<View className="px-2 py-0.5 bg-app-box rounded border border-app-line">
<Text className="text-ink-dull text-xs">
{getDiskTypeLabel(diskType)}
</Text>
</View>
{readSpeed && (
<View className="px-2 py-0.5 bg-app-box rounded border border-app-line">
<Text className="text-ink-dull text-xs">
{readSpeed} MB/s
</Text>
</View>
)}
<View className="px-2 py-0.5 bg-app-box rounded border border-app-line">
<Text className="text-ink-dull text-xs">
{volume.volume_type}
</Text>
</View>
</View>
</View>
);
{/* Tags */}
<View className="flex-row flex-wrap gap-2">
<View className="rounded border border-app-line bg-app-box px-2 py-0.5">
<Text className="text-ink-dull text-xs">{fileSystem}</Text>
</View>
<View className="rounded border border-app-line bg-app-box px-2 py-0.5">
<Text className="text-ink-dull text-xs">
{getDiskTypeLabel(diskType)}
</Text>
</View>
{readSpeed && (
<View className="rounded border border-app-line bg-app-box px-2 py-0.5">
<Text className="text-ink-dull text-xs">{readSpeed} MB/s</Text>
</View>
)}
<View className="rounded border border-app-line bg-app-box px-2 py-0.5">
<Text className="text-ink-dull text-xs">{volume.volume_type}</Text>
</View>
</View>
</View>
);
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -2,100 +2,100 @@ import { create } from "zustand";
export type LayoutMode = "grid" | "list" | "media";
export type SortBy =
| "name"
| "size"
| "date_created"
| "date_modified"
| "kind";
| "name"
| "size"
| "date_created"
| "date_modified"
| "kind";
export type SortOrder = "asc" | "desc";
interface ExplorerStore {
// View mode
layoutMode: LayoutMode;
setLayoutMode: (mode: LayoutMode) => void;
// View mode
layoutMode: LayoutMode;
setLayoutMode: (mode: LayoutMode) => void;
// Grid configuration
gridColumns: number;
setGridColumns: (columns: number) => void;
// Grid configuration
gridColumns: number;
setGridColumns: (columns: number) => void;
// Sorting
sortBy: SortBy;
sortOrder: SortOrder;
setSortBy: (sort: SortBy) => void;
setSortOrder: (order: SortOrder) => void;
// Sorting
sortBy: SortBy;
sortOrder: SortOrder;
setSortBy: (sort: SortBy) => void;
setSortOrder: (order: SortOrder) => void;
// Selection
selectedItems: Set<string>;
isSelectionMode: boolean;
selectItem: (id: string) => void;
deselectItem: (id: string) => void;
toggleItem: (id: string) => void;
clearSelection: () => void;
setSelectionMode: (enabled: boolean) => void;
// Selection
selectedItems: Set<string>;
isSelectionMode: boolean;
selectItem: (id: string) => void;
deselectItem: (id: string) => void;
toggleItem: (id: string) => void;
clearSelection: () => void;
setSelectionMode: (enabled: boolean) => void;
// Current path
currentPath: string;
setCurrentPath: (path: string) => void;
// Current path
currentPath: string;
setCurrentPath: (path: string) => void;
// Current location
currentLocationId: string | null;
setCurrentLocation: (id: string | null) => void;
// Current location
currentLocationId: string | null;
setCurrentLocation: (id: string | null) => void;
}
export const useExplorerStore = create<ExplorerStore>((set, get) => ({
// View mode
layoutMode: "grid",
setLayoutMode: (mode) => set({ layoutMode: mode }),
// View mode
layoutMode: "grid",
setLayoutMode: (mode) => set({ layoutMode: mode }),
// Grid configuration
gridColumns: 3,
setGridColumns: (columns) => set({ gridColumns: columns }),
// Grid configuration
gridColumns: 3,
setGridColumns: (columns) => set({ gridColumns: columns }),
// Sorting
sortBy: "name",
sortOrder: "asc",
setSortBy: (sort) => set({ sortBy: sort }),
setSortOrder: (order) => set({ sortOrder: order }),
// Sorting
sortBy: "name",
sortOrder: "asc",
setSortBy: (sort) => set({ sortBy: sort }),
setSortOrder: (order) => set({ sortOrder: order }),
// Selection
selectedItems: new Set(),
isSelectionMode: false,
selectItem: (id) =>
set((state) => ({
selectedItems: new Set([...state.selectedItems, id]),
})),
deselectItem: (id) =>
set((state) => {
const newSet = new Set(state.selectedItems);
newSet.delete(id);
return { selectedItems: newSet };
}),
toggleItem: (id) =>
set((state) => {
const newSet = new Set(state.selectedItems);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return {
selectedItems: newSet,
isSelectionMode: newSet.size > 0,
};
}),
clearSelection: () =>
set({ selectedItems: new Set(), isSelectionMode: false }),
setSelectionMode: (enabled) =>
set({
isSelectionMode: enabled,
selectedItems: enabled ? get().selectedItems : new Set(),
}),
// Selection
selectedItems: new Set(),
isSelectionMode: false,
selectItem: (id) =>
set((state) => ({
selectedItems: new Set([...state.selectedItems, id]),
})),
deselectItem: (id) =>
set((state) => {
const newSet = new Set(state.selectedItems);
newSet.delete(id);
return { selectedItems: newSet };
}),
toggleItem: (id) =>
set((state) => {
const newSet = new Set(state.selectedItems);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return {
selectedItems: newSet,
isSelectionMode: newSet.size > 0,
};
}),
clearSelection: () =>
set({ selectedItems: new Set(), isSelectionMode: false }),
setSelectionMode: (enabled) =>
set({
isSelectionMode: enabled,
selectedItems: enabled ? get().selectedItems : new Set(),
}),
// Current path
currentPath: "/",
setCurrentPath: (path) => set({ currentPath: path }),
// Current path
currentPath: "/",
setCurrentPath: (path) => set({ currentPath: path }),
// Current location
currentLocationId: null,
setCurrentLocation: (id) => set({ currentLocationId: id }),
// Current location
currentLocationId: null,
setCurrentLocation: (id) => set({ currentLocationId: id }),
}));

View File

@@ -1,8 +1,8 @@
export { useSidebarStore } from "./sidebar";
export {
useExplorerStore,
type LayoutMode,
type SortBy,
type SortOrder,
type LayoutMode,
type SortBy,
type SortOrder,
useExplorerStore,
} from "./explorer";
export { usePreferencesStore, type ThemeMode } from "./preferences";
export { type ThemeMode, usePreferencesStore } from "./preferences";
export { useSidebarStore } from "./sidebar";

View File

@@ -1,97 +1,100 @@
import { create } from "zustand";
import { persist, createJSONStorage, StateStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import {
createJSONStorage,
persist,
type StateStorage,
} from "zustand/middleware";
// AsyncStorage adapter for Zustand
const asyncStorageAdapter: StateStorage = {
getItem: async (name: string) => {
return await AsyncStorage.getItem(name);
},
setItem: async (name: string, value: string) => {
await AsyncStorage.setItem(name, value);
},
removeItem: async (name: string) => {
await AsyncStorage.removeItem(name);
},
getItem: async (name: string) => {
return await AsyncStorage.getItem(name);
},
setItem: async (name: string, value: string) => {
await AsyncStorage.setItem(name, value);
},
removeItem: async (name: string) => {
await AsyncStorage.removeItem(name);
},
};
export type ThemeMode = "dark" | "light" | "system";
interface ViewPreferences {
viewMode: "grid" | "list" | "media";
gridSize: number;
showHiddenFiles: boolean;
viewMode: "grid" | "list" | "media";
gridSize: number;
showHiddenFiles: boolean;
}
interface PreferencesStore {
// Theme
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
// Theme
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
// Haptics
hapticsEnabled: boolean;
setHapticsEnabled: (enabled: boolean) => void;
// Haptics
hapticsEnabled: boolean;
setHapticsEnabled: (enabled: boolean) => void;
// View preferences per location/path
viewPreferences: Record<string, ViewPreferences>;
getViewPreferences: (key: string) => ViewPreferences;
setViewPreferences: (key: string, prefs: Partial<ViewPreferences>) => void;
// View preferences per location/path
viewPreferences: Record<string, ViewPreferences>;
getViewPreferences: (key: string) => ViewPreferences;
setViewPreferences: (key: string, prefs: Partial<ViewPreferences>) => void;
// Onboarding
hasCompletedOnboarding: boolean;
setHasCompletedOnboarding: (completed: boolean) => void;
// Onboarding
hasCompletedOnboarding: boolean;
setHasCompletedOnboarding: (completed: boolean) => void;
// Sync preferences
autoSwitchOnSync: boolean;
setAutoSwitchOnSync: (enabled: boolean) => void;
// Sync preferences
autoSwitchOnSync: boolean;
setAutoSwitchOnSync: (enabled: boolean) => void;
}
const defaultViewPreferences: ViewPreferences = {
viewMode: "grid",
gridSize: 120,
showHiddenFiles: false,
viewMode: "grid",
gridSize: 120,
showHiddenFiles: false,
};
export const usePreferencesStore = create<PreferencesStore>()(
persist(
(set, get) => ({
// Theme
themeMode: "dark",
setThemeMode: (mode) => set({ themeMode: mode }),
persist(
(set, get) => ({
// Theme
themeMode: "dark",
setThemeMode: (mode) => set({ themeMode: mode }),
// Haptics
hapticsEnabled: true,
setHapticsEnabled: (enabled) => set({ hapticsEnabled: enabled }),
// Haptics
hapticsEnabled: true,
setHapticsEnabled: (enabled) => set({ hapticsEnabled: enabled }),
// View preferences
viewPreferences: {},
getViewPreferences: (key) => {
return get().viewPreferences[key] ?? defaultViewPreferences;
},
setViewPreferences: (key, prefs) =>
set((state) => ({
viewPreferences: {
...state.viewPreferences,
[key]: {
...(state.viewPreferences[key] ??
defaultViewPreferences),
...prefs,
},
},
})),
// View preferences
viewPreferences: {},
getViewPreferences: (key) => {
return get().viewPreferences[key] ?? defaultViewPreferences;
},
setViewPreferences: (key, prefs) =>
set((state) => ({
viewPreferences: {
...state.viewPreferences,
[key]: {
...(state.viewPreferences[key] ?? defaultViewPreferences),
...prefs,
},
},
})),
// Onboarding
hasCompletedOnboarding: false,
setHasCompletedOnboarding: (completed) =>
set({ hasCompletedOnboarding: completed }),
// Onboarding
hasCompletedOnboarding: false,
setHasCompletedOnboarding: (completed) =>
set({ hasCompletedOnboarding: completed }),
// Sync preferences
autoSwitchOnSync: true,
setAutoSwitchOnSync: (enabled) => set({ autoSwitchOnSync: enabled }),
}),
{
name: "spacedrive-preferences",
storage: createJSONStorage(() => asyncStorageAdapter),
},
),
// Sync preferences
autoSwitchOnSync: true,
setAutoSwitchOnSync: (enabled) => set({ autoSwitchOnSync: enabled }),
}),
{
name: "spacedrive-preferences",
storage: createJSONStorage(() => asyncStorageAdapter),
}
)
);

View File

@@ -1,66 +1,67 @@
import { create } from "zustand";
import { persist, createJSONStorage, StateStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import {
createJSONStorage,
persist,
type StateStorage,
} from "zustand/middleware";
// AsyncStorage adapter for Zustand
const asyncStorageAdapter: StateStorage = {
getItem: async (name: string) => {
return await AsyncStorage.getItem(name);
},
setItem: async (name: string, value: string) => {
await AsyncStorage.setItem(name, value);
},
removeItem: async (name: string) => {
await AsyncStorage.removeItem(name);
},
getItem: async (name: string) => {
return await AsyncStorage.getItem(name);
},
setItem: async (name: string, value: string) => {
await AsyncStorage.setItem(name, value);
},
removeItem: async (name: string) => {
await AsyncStorage.removeItem(name);
},
};
interface SidebarStore {
// Current library selection
currentLibraryId: string | null;
setCurrentLibrary: (id: string | null) => void;
// Current library selection
currentLibraryId: string | null;
setCurrentLibrary: (id: string | null) => void;
// Collapsed section groups
collapsedGroups: string[];
isGroupCollapsed: (groupId: string) => boolean;
toggleGroup: (groupId: string) => void;
// Collapsed section groups
collapsedGroups: string[];
isGroupCollapsed: (groupId: string) => boolean;
toggleGroup: (groupId: string) => void;
// Drawer state
isDrawerOpen: boolean;
setDrawerOpen: (open: boolean) => void;
// Drawer state
isDrawerOpen: boolean;
setDrawerOpen: (open: boolean) => void;
}
export const useSidebarStore = create<SidebarStore>()(
persist(
(set, get) => ({
currentLibraryId: null,
setCurrentLibrary: (id) => set({ currentLibraryId: id }),
persist(
(set, get) => ({
currentLibraryId: null,
setCurrentLibrary: (id) => set({ currentLibraryId: id }),
collapsedGroups: [],
isGroupCollapsed: (groupId) =>
get().collapsedGroups.includes(groupId),
toggleGroup: (groupId) =>
set((state) => {
const isCollapsed = state.collapsedGroups.includes(groupId);
return {
collapsedGroups: isCollapsed
? state.collapsedGroups.filter(
(id) => id !== groupId,
)
: [...state.collapsedGroups, groupId],
};
}),
collapsedGroups: [],
isGroupCollapsed: (groupId) => get().collapsedGroups.includes(groupId),
toggleGroup: (groupId) =>
set((state) => {
const isCollapsed = state.collapsedGroups.includes(groupId);
return {
collapsedGroups: isCollapsed
? state.collapsedGroups.filter((id) => id !== groupId)
: [...state.collapsedGroups, groupId],
};
}),
isDrawerOpen: false,
setDrawerOpen: (open) => set({ isDrawerOpen: open }),
}),
{
name: "spacedrive-sidebar",
storage: createJSONStorage(() => asyncStorageAdapter),
partialize: (state) => ({
currentLibraryId: state.currentLibraryId,
collapsedGroups: state.collapsedGroups,
}),
},
),
isDrawerOpen: false,
setDrawerOpen: (open) => set({ isDrawerOpen: open }),
}),
{
name: "spacedrive-sidebar",
storage: createJSONStorage(() => asyncStorageAdapter),
partialize: (state) => ({
currentLibraryId: state.currentLibraryId,
collapsedGroups: state.collapsedGroups,
}),
}
)
);

View File

@@ -1,9 +1,9 @@
import { clsx, type ClassValue } from "clsx";
import { type ClassValue, clsx } from "clsx";
/**
* Utility function for combining class names.
* Similar to clsx but optimized for NativeWind.
*/
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
return clsx(inputs);
}

View File

@@ -1,4 +1,4 @@
const sharedColors = require('@sd/ui/style/colors');
const sharedColors = require("@sd/ui/style/colors");
/**
* Convert shared color format (HSL string) to NativeWind format (hsl() function)
@@ -8,41 +8,42 @@ const sharedColors = require('@sd/ui/style/colors');
* Also converts camelCase keys to kebab-case for NativeWind compatibility
*/
function toHSL(colorValue) {
if (typeof colorValue === 'string') {
return `hsl(${colorValue})`;
}
if (typeof colorValue === "string") {
return `hsl(${colorValue})`;
}
// Handle nested objects (like accent.DEFAULT)
const result = {};
for (const [key, value] of Object.entries(colorValue)) {
// Preserve DEFAULT (must be uppercase for Tailwind)
// Convert camelCase to kebab-case for everything else
const kebabKey = key === 'DEFAULT'
? key
: key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
result[kebabKey] = toHSL(value);
}
return result;
// Handle nested objects (like accent.DEFAULT)
const result = {};
for (const [key, value] of Object.entries(colorValue)) {
// Preserve DEFAULT (must be uppercase for Tailwind)
// Convert camelCase to kebab-case for everything else
const kebabKey =
key === "DEFAULT"
? key
: key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
result[kebabKey] = toHSL(value);
}
return result;
}
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{ts,tsx}", "./index.js"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
// Use shared colors from @sd/ui
accent: toHSL(sharedColors.accent),
ink: toHSL(sharedColors.ink),
sidebar: toHSL(sharedColors.sidebar),
app: toHSL(sharedColors.app),
menu: toHSL(sharedColors.menu),
},
fontSize: {
md: "16px",
},
},
},
plugins: [],
content: ["./src/**/*.{ts,tsx}", "./index.js"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
// Use shared colors from @sd/ui
accent: toHSL(sharedColors.accent),
ink: toHSL(sharedColors.ink),
sidebar: toHSL(sharedColors.sidebar),
app: toHSL(sharedColors.app),
menu: toHSL(sharedColors.menu),
},
fontSize: {
md: "16px",
},
},
},
plugins: [],
};

View File

@@ -3,9 +3,7 @@
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": [
"ES2020"
],
"lib": ["ES2020"],
"jsx": "react-native",
"strict": true,
"moduleResolution": "bundler",
@@ -13,9 +11,7 @@
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"~/*": [
"./src/*"
]
"~/*": ["./src/*"]
}
},
"include": [
@@ -25,7 +21,5 @@
".expo/types/**/*.ts",
"expo-env.d.ts"
],
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}

View File

@@ -1,98 +1,90 @@
{
"fill-specializations" : [
"fill-specializations": [
{
"value" : "automatic"
"value": "automatic"
},
{
"appearance" : "dark",
"value" : "system-dark"
"appearance": "dark",
"value": "system-dark"
}
],
"groups" : [
"groups": [
{
"layers" : [
"layers": [
{
"blend-mode-specializations" : [
"blend-mode-specializations": [
{
"appearance" : "tinted",
"value" : "screen"
"appearance": "tinted",
"value": "screen"
}
],
"fill-specializations" : [
"fill-specializations": [
{
"appearance" : "tinted",
"value" : {
"solid" : "display-p3:1.00000,0.72781,0.41766,1.00000"
"appearance": "tinted",
"value": {
"solid": "display-p3:1.00000,0.72781,0.41766,1.00000"
}
}
],
"glass" : true,
"hidden" : false,
"image-name" : "Ball.png",
"name" : "Ball",
"opacity-specializations" : [
"glass": true,
"hidden": false,
"image-name": "Ball.png",
"name": "Ball",
"opacity-specializations": [
{
"value" : 0.4
"value": 0.4
},
{
"appearance" : "dark",
"value" : 0
"appearance": "dark",
"value": 0
},
{
"appearance" : "tinted",
"value" : 0.53
"appearance": "tinted",
"value": 0.53
}
],
"position" : {
"scale" : 2,
"translation-in-points" : [
1.7218333746113785,
2.7640092574830533
]
"position": {
"scale": 2,
"translation-in-points": [1.7218333746113785, 2.7640092574830533]
}
},
{
"blend-mode-specializations" : [
"blend-mode-specializations": [
{
"appearance" : "tinted",
"value" : "normal"
"appearance": "tinted",
"value": "normal"
}
],
"fill-specializations" : [
"fill-specializations": [
{
"appearance" : "tinted",
"value" : {
"solid" : "display-p3:1.00000,0.72781,0.41766,1.00000"
"appearance": "tinted",
"value": {
"solid": "display-p3:1.00000,0.72781,0.41766,1.00000"
}
}
],
"glass" : true,
"hidden" : false,
"image-name" : "Ball.png",
"name" : "Ball",
"position" : {
"scale" : 2,
"translation-in-points" : [
1.7218333746113785,
2.7640092574830533
]
"glass": true,
"hidden": false,
"image-name": "Ball.png",
"name": "Ball",
"position": {
"scale": 2,
"translation-in-points": [1.7218333746113785, 2.7640092574830533]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
"supported-platforms": {
"circles": ["watchOS"],
"squares": "shared"
}
}
}

View File

@@ -1,12 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spacedrive</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spacedrive</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,47 +1,47 @@
{
"name": "@sd/tauri",
"private": true,
"version": "2.0.0",
"type": "module",
"engines": {
"bun": ">=1.3.0"
},
"scripts": {
"dev": "vite dev",
"dev:with-daemon": "bun ./scripts/dev-with-daemon.ts",
"build": "vite build",
"build:daemon": "cargo build --bin sd-daemon --manifest-path ../../Cargo.toml",
"build:daemon:release": "cargo build --release --bin sd-daemon --manifest-path ../../Cargo.toml",
"preview": "vite preview",
"typecheck": "tsc -b",
"tauri": "bunx tauri",
"tauri:dev": "bunx tauri dev",
"tauri:dev:no-watch": "bunx tauri dev --no-watch",
"tauri:build": "bunx tauri build && ./scripts/fix-daemon-entitlements.sh ../../target/release/bundle/macos/Spacedrive.app || true"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.0",
"@sd/assets": "workspace:*",
"@sd/interface": "workspace:*",
"@sd/ts-client": "workspace:*",
"@sd/ui": "workspace:*",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.0.1",
"@tauri-apps/plugin-shell": "^2.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-scan": "^0.4.3"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"@vitejs/plugin-react-swc": "^3.7.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.36",
"tailwindcss": "^3.4.1",
"typescript": "^5.6.2",
"vite": "^5.4.9"
}
"name": "@sd/tauri",
"private": true,
"version": "2.0.0",
"type": "module",
"engines": {
"bun": ">=1.3.0"
},
"scripts": {
"dev": "vite dev",
"dev:with-daemon": "bun ./scripts/dev-with-daemon.ts",
"build": "vite build",
"build:daemon": "cargo build --bin sd-daemon --manifest-path ../../Cargo.toml",
"build:daemon:release": "cargo build --release --bin sd-daemon --manifest-path ../../Cargo.toml",
"preview": "vite preview",
"typecheck": "tsc -b",
"tauri": "bunx tauri",
"tauri:dev": "bunx tauri dev",
"tauri:dev:no-watch": "bunx tauri dev --no-watch",
"tauri:build": "bunx tauri build && ./scripts/fix-daemon-entitlements.sh ../../target/release/bundle/macos/Spacedrive.app || true"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.0",
"@sd/assets": "workspace:*",
"@sd/interface": "workspace:*",
"@sd/ts-client": "workspace:*",
"@sd/ui": "workspace:*",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.0.1",
"@tauri-apps/plugin-shell": "^2.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-scan": "^0.4.3"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"@vitejs/plugin-react-swc": "^3.7.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.36",
"tailwindcss": "^3.4.1",
"typescript": "^5.6.2",
"vite": "^5.4.9"
}
}

View File

@@ -1,6 +1,7 @@
"use strict";
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -10,9 +10,9 @@
*/
import { spawn } from "child_process";
import { existsSync, unlinkSync } from "fs";
import { join, resolve, dirname } from "path";
import { existsSync } from "fs";
import { homedir, platform } from "os";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
// Get script directory
@@ -35,9 +35,9 @@ const DAEMON_PORT = 6969;
const DAEMON_ADDR = `127.0.0.1:${DAEMON_PORT}`;
// Fix Data Directory for Windows (Optional but recommended)
const DATA_DIR = IS_WIN
? join(homedir(), "AppData/Roaming/spacedrive")
: join(homedir(), "Library/Application Support/spacedrive");
const DATA_DIR = IS_WIN
? join(homedir(), "AppData/Roaming/spacedrive")
: join(homedir(), "Library/Application Support/spacedrive");
let daemonProcess: any = null;
let viteProcess: any = null;
@@ -45,21 +45,21 @@ let startedDaemon = false;
// Cleanup function
function cleanup() {
console.log("\nCleaning up...");
console.log("\nCleaning up...");
if (viteProcess) {
console.log("Stopping Vite...");
viteProcess.kill();
}
if (viteProcess) {
console.log("Stopping Vite...");
viteProcess.kill();
}
if (daemonProcess && startedDaemon) {
console.log("Stopping daemon (started by us)...");
daemonProcess.kill();
} else if (!startedDaemon) {
console.log("Leaving existing daemon running...");
}
if (daemonProcess && startedDaemon) {
console.log("Stopping daemon (started by us)...");
daemonProcess.kill();
} else if (!startedDaemon) {
console.log("Leaving existing daemon running...");
}
process.exit(0);
process.exit(0);
}
// Handle signals
@@ -67,137 +67,137 @@ process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
async function main() {
console.log("Building daemon (dev profile)...");
console.log("Project root:", PROJECT_ROOT);
console.log("Daemon binary:", DAEMON_BIN);
console.log("Building daemon (dev profile)...");
console.log("Project root:", PROJECT_ROOT);
console.log("Daemon binary:", DAEMON_BIN);
// Build daemon
// On Windows, the binary target name is still just "sd-daemon" (Cargo handles the .exe)
const build = spawn("cargo", ["build", "--bin", "sd-daemon"], {
cwd: PROJECT_ROOT,
stdio: "inherit",
shell: IS_WIN, // shell: true is often needed on Windows for spawn to work correctly
// Build daemon
// On Windows, the binary target name is still just "sd-daemon" (Cargo handles the .exe)
const build = spawn("cargo", ["build", "--bin", "sd-daemon"], {
cwd: PROJECT_ROOT,
stdio: "inherit",
shell: IS_WIN, // shell: true is often needed on Windows for spawn to work correctly
});
await new Promise<void>((resolve, reject) => {
build.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Daemon build failed with code ${code}`));
}
});
});
console.log("Daemon built successfully");
// Check if daemon is already running by trying to connect to TCP port
let daemonAlreadyRunning = false;
console.log(`Checking if daemon is running on ${DAEMON_ADDR}...`);
try {
const { connect } = await import("net");
await new Promise<void>((resolve, reject) => {
build.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Daemon build failed with code ${code}`));
}
});
const client = connect(DAEMON_PORT, "127.0.0.1");
client.on("connect", () => {
daemonAlreadyRunning = true;
client.end();
resolve();
});
client.on("error", () => {
reject();
});
setTimeout(() => reject(), 1000);
});
} catch (e) {
// Connection failed, daemon not running
daemonAlreadyRunning = false;
}
if (daemonAlreadyRunning) {
console.log("Daemon already running, will connect to existing instance");
startedDaemon = false;
} else {
// Start daemon
console.log("Starting daemon...");
startedDaemon = true;
// Verify binary exists
if (!existsSync(DAEMON_BIN)) {
throw new Error(`Daemon binary not found at: ${DAEMON_BIN}`);
}
const depsLibPath = join(PROJECT_ROOT, "apps/.deps/lib");
const depsBinPath = join(PROJECT_ROOT, "apps/.deps/bin");
daemonProcess = spawn(DAEMON_BIN, ["--data-dir", DATA_DIR], {
cwd: PROJECT_ROOT,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
// macOS library path
DYLD_LIBRARY_PATH: depsLibPath,
// Windows: Add DLLs directory to PATH
PATH: IS_WIN
? `${depsBinPath};${process.env.PATH || ""}`
: process.env.PATH,
},
});
console.log("Daemon built successfully");
// Log daemon output
daemonProcess.stdout.on("data", (data: Buffer) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
console.log(`[daemon] ${line}`);
}
});
// Check if daemon is already running by trying to connect to TCP port
let daemonAlreadyRunning = false;
console.log(`Checking if daemon is running on ${DAEMON_ADDR}...`);
try {
daemonProcess.stderr.on("data", (data: Buffer) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
console.log(`[daemon] ${line}`);
}
});
// Wait for daemon to be ready
console.log("Waiting for daemon to be ready...");
for (let i = 0; i < 30; i++) {
try {
const { connect } = await import("net");
await new Promise<void>((resolve, reject) => {
const client = connect(DAEMON_PORT, "127.0.0.1");
client.on("connect", () => {
daemonAlreadyRunning = true;
client.end();
resolve();
});
client.on("error", () => {
reject();
});
setTimeout(() => reject(), 1000);
const client = connect(DAEMON_PORT, "127.0.0.1");
client.on("connect", () => {
client.end();
resolve();
});
client.on("error", reject);
setTimeout(() => reject(), 500);
});
} catch (e) {
// Connection failed, daemon not running
daemonAlreadyRunning = false;
}
if (daemonAlreadyRunning) {
console.log("Daemon already running, will connect to existing instance");
startedDaemon = false;
} else {
// Start daemon
console.log("Starting daemon...");
startedDaemon = true;
// Verify binary exists
if (!existsSync(DAEMON_BIN)) {
throw new Error(`Daemon binary not found at: ${DAEMON_BIN}`);
}
const depsLibPath = join(PROJECT_ROOT, "apps/.deps/lib");
const depsBinPath = join(PROJECT_ROOT, "apps/.deps/bin");
daemonProcess = spawn(DAEMON_BIN, ["--data-dir", DATA_DIR], {
cwd: PROJECT_ROOT,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
// macOS library path
DYLD_LIBRARY_PATH: depsLibPath,
// Windows: Add DLLs directory to PATH
PATH: IS_WIN
? `${depsBinPath};${process.env.PATH || ""}`
: process.env.PATH,
},
});
// Log daemon output
daemonProcess.stdout.on("data", (data: Buffer) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
console.log(`[daemon] ${line}`);
}
});
daemonProcess.stderr.on("data", (data: Buffer) => {
const lines = data.toString().trim().split("\n");
for (const line of lines) {
console.log(`[daemon] ${line}`);
}
});
// Wait for daemon to be ready
console.log("Waiting for daemon to be ready...");
for (let i = 0; i < 30; i++) {
try {
const { connect } = await import("net");
await new Promise<void>((resolve, reject) => {
const client = connect(DAEMON_PORT, "127.0.0.1");
client.on("connect", () => {
client.end();
resolve();
});
client.on("error", reject);
setTimeout(() => reject(), 500);
});
console.log(`Daemon ready at ${DAEMON_ADDR}`);
break;
} catch (e) {
if (i === 29) {
throw new Error("Daemon failed to start (connection not available)");
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
console.log(`Daemon ready at ${DAEMON_ADDR}`);
break;
} catch (e) {
if (i === 29) {
throw new Error("Daemon failed to start (connection not available)");
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
// Start Vite
console.log("Starting Vite dev server...");
// Use 'bun' explicitly, with shell true for Windows compatibility
viteProcess = spawn("bun", ["run", "dev"], {
stdio: "inherit",
shell: IS_WIN,
});
// Start Vite
console.log("Starting Vite dev server...");
// Keep running
await new Promise(() => {});
// Use 'bun' explicitly, with shell true for Windows compatibility
viteProcess = spawn("bun", ["run", "dev"], {
stdio: "inherit",
shell: IS_WIN,
});
// Keep running
await new Promise(() => {});
}
main().catch((error) => {
console.error("Error:", error);
cleanup();
process.exit(1);
});
console.error("Error:", error);
cleanup();
process.exit(1);
});

View File

@@ -1,23 +1,29 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for Spacedrive",
"windows": ["main", "inspector-*", "quick-preview-*", "settings-*", "job-manager"],
"permissions": [
"core:default",
"core:event:allow-listen",
"core:event:allow-emit",
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-get-all-windows",
"core:window:allow-start-dragging",
"core:webview:allow-create-webview-window",
"core:path:default",
"dialog:allow-open",
"dialog:allow-save",
"shell:allow-open",
"fs:allow-home-read-recursive",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text"
]
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for Spacedrive",
"windows": [
"main",
"inspector-*",
"quick-preview-*",
"settings-*",
"job-manager"
],
"permissions": [
"core:default",
"core:event:allow-listen",
"core:event:allow-emit",
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-get-all-windows",
"core:window:allow-start-dragging",
"core:webview:allow-create-webview-window",
"core:path:default",
"dialog:allow-open",
"dialog:allow-save",
"shell:allow-open",
"fs:allow-home-read-recursive",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text"
]
}

View File

@@ -1,301 +1,293 @@
import {
FloatingControls,
JobsScreen,
LocationCacheDemo,
PlatformProvider,
PopoutInspector,
QuickPreview,
ServerProvider,
Settings,
Shell,
SpacedriveProvider,
} from "@sd/interface";
import type { Event as CoreEvent } from "@sd/ts-client";
import {
SpacedriveClient,
TauriTransport,
useSyncPreferencesStore,
} from "@sd/ts-client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import {
Shell,
FloatingControls,
LocationCacheDemo,
PopoutInspector,
QuickPreview,
JobsScreen,
Settings,
PlatformProvider,
SpacedriveProvider,
ServerProvider,
} from "@sd/interface";
import {
SpacedriveClient,
TauriTransport,
useSyncPreferencesStore,
} from "@sd/ts-client";
import type { Event as CoreEvent } from "@sd/ts-client";
import { sounds } from "@sd/assets/sounds";
import { useEffect, useState } from "react";
import { DragOverlay } from "./routes/DragOverlay";
import { ContextMenuWindow } from "./routes/ContextMenuWindow";
import { DragDemo } from "./components/DragDemo";
import { SpacedropWindow } from "./routes/Spacedrop";
import { platform } from "./platform";
import { initializeContextMenuHandler } from "./contextMenu";
import { initializeKeybindGlobal } from "./keybinds";
import { platform } from "./platform";
import { ContextMenuWindow } from "./routes/ContextMenuWindow";
import { DragOverlay } from "./routes/DragOverlay";
import { SpacedropWindow } from "./routes/Spacedrop";
function App() {
const [client, setClient] = useState<SpacedriveClient | null>(null);
const [error, setError] = useState<string | null>(null);
const [route, setRoute] = useState<string>("/");
const [client, setClient] = useState<SpacedriveClient | null>(null);
const [error, setError] = useState<string | null>(null);
const [route, setRoute] = useState<string>("/");
useEffect(() => {
// React Scan disabled - too heavy for development
// Uncomment if you need to debug render performance:
if (import.meta.env.DEV) {
// setTimeout(() => {
// import("react-scan").then(({ scan }) => {
// scan({ enabled: true, log: false });
// });
// }, 2000);
}
useEffect(() => {
// React Scan disabled - too heavy for development
// Uncomment if you need to debug render performance:
if (import.meta.env.DEV) {
// setTimeout(() => {
// import("react-scan").then(({ scan }) => {
// scan({ enabled: true, log: false });
// });
// }, 2000);
}
// Initialize Tauri native context menu handler
initializeContextMenuHandler();
// Initialize Tauri native context menu handler
initializeContextMenuHandler();
// Initialize Tauri keybind handler
initializeKeybindGlobal();
// Initialize Tauri keybind handler
initializeKeybindGlobal();
// Prevent default context menu globally (except in context menu windows)
const currentWindow = getCurrentWebviewWindow();
const label = currentWindow.label;
// Prevent default context menu globally (except in context menu windows)
const currentWindow = getCurrentWebviewWindow();
const label = currentWindow.label;
// Prevent default browser context menu globally (except in context menu windows)
if (!label.startsWith("context-menu")) {
const preventContextMenu = (e: Event) => {
// Default behavior: prevent browser context menu
// React's onContextMenu handlers can override this with their own preventDefault
e.preventDefault();
};
document.addEventListener("contextmenu", preventContextMenu, {
capture: false,
});
}
// Prevent default browser context menu globally (except in context menu windows)
if (!label.startsWith("context-menu")) {
const preventContextMenu = (e: Event) => {
// Default behavior: prevent browser context menu
// React's onContextMenu handlers can override this with their own preventDefault
e.preventDefault();
};
document.addEventListener("contextmenu", preventContextMenu, {
capture: false,
});
}
// Set route based on window label
if (label === "floating-controls") {
setRoute("/floating-controls");
} else if (label.startsWith("drag-overlay")) {
setRoute("/drag-overlay");
} else if (label.startsWith("context-menu")) {
setRoute("/contextmenu");
} else if (label.startsWith("drag-demo")) {
setRoute("/drag-demo");
} else if (label.startsWith("spacedrop")) {
setRoute("/spacedrop");
} else if (label.startsWith("settings")) {
setRoute("/settings");
} else if (label.startsWith("inspector")) {
setRoute("/inspector");
} else if (label.startsWith("quick-preview")) {
setRoute("/quick-preview");
} else if (label.startsWith("cache-demo")) {
setRoute("/cache-demo");
} else if (label.startsWith("job-manager")) {
setRoute("/job-manager");
}
// Set route based on window label
if (label === "floating-controls") {
setRoute("/floating-controls");
} else if (label.startsWith("drag-overlay")) {
setRoute("/drag-overlay");
} else if (label.startsWith("context-menu")) {
setRoute("/contextmenu");
} else if (label.startsWith("drag-demo")) {
setRoute("/drag-demo");
} else if (label.startsWith("spacedrop")) {
setRoute("/spacedrop");
} else if (label.startsWith("settings")) {
setRoute("/settings");
} else if (label.startsWith("inspector")) {
setRoute("/inspector");
} else if (label.startsWith("quick-preview")) {
setRoute("/quick-preview");
} else if (label.startsWith("cache-demo")) {
setRoute("/cache-demo");
} else if (label.startsWith("job-manager")) {
setRoute("/job-manager");
}
// Tell Tauri window is ready to be shown
invoke("app_ready").catch(console.error);
// Tell Tauri window is ready to be shown
invoke("app_ready").catch(console.error);
// Play startup sound
// sounds.startup();
// Play startup sound
// sounds.startup();
let unsubscribePromise: Promise<() => void> | null = null;
let unsubscribePromise: Promise<() => void> | null = null;
// Create Tauri-based client
try {
const transport = new TauriTransport(invoke, listen);
const spacedrive = new SpacedriveClient(transport);
setClient(spacedrive);
// Create Tauri-based client
try {
const transport = new TauriTransport(invoke, listen);
const spacedrive = new SpacedriveClient(transport);
setClient(spacedrive);
// Query current library ID from platform state (for popout windows)
if (platform.getCurrentLibraryId) {
platform
.getCurrentLibraryId()
.then((libraryId) => {
if (libraryId) {
spacedrive.setCurrentLibrary(libraryId, false); // Don't emit - already in sync
}
})
.catch(() => {
// Library not selected yet - this is fine for initial load
});
}
// Query current library ID from platform state (for popout windows)
if (platform.getCurrentLibraryId) {
platform
.getCurrentLibraryId()
.then((libraryId) => {
if (libraryId) {
spacedrive.setCurrentLibrary(libraryId, false); // Don't emit - already in sync
}
})
.catch(() => {
// Library not selected yet - this is fine for initial load
});
}
// Listen for library-changed events via platform (emitted when library switches)
if (platform.onLibraryIdChanged) {
platform.onLibraryIdChanged((newLibraryId) => {
spacedrive.setCurrentLibrary(newLibraryId, true); // DO emit - hooks need to know!
});
}
// Listen for library-changed events via platform (emitted when library switches)
if (platform.onLibraryIdChanged) {
platform.onLibraryIdChanged((newLibraryId) => {
spacedrive.setCurrentLibrary(newLibraryId, true); // DO emit - hooks need to know!
});
}
// Subscribe to core events for auto-switching on synced library creation
unsubscribePromise = spacedrive.subscribe((event: CoreEvent) => {
// Check if this is a LibraryCreated event from sync
if (
typeof event === "object" &&
"LibraryCreated" in event &&
(event.LibraryCreated as any).source === "Sync"
) {
const { id, name } = event.LibraryCreated;
// Subscribe to core events for auto-switching on synced library creation
unsubscribePromise = spacedrive.subscribe((event: CoreEvent) => {
// Check if this is a LibraryCreated event from sync
if (
typeof event === "object" &&
"LibraryCreated" in event &&
(event.LibraryCreated as any).source === "Sync"
) {
const { id, name } = event.LibraryCreated;
// Check user preference for auto-switching
const autoSwitchEnabled =
useSyncPreferencesStore.getState().autoSwitchOnSync;
// Check user preference for auto-switching
const autoSwitchEnabled =
useSyncPreferencesStore.getState().autoSwitchOnSync;
if (autoSwitchEnabled) {
console.log(
`[Auto-Switch] Received synced library "${name}", switching...`,
);
if (autoSwitchEnabled) {
console.log(
`[Auto-Switch] Received synced library "${name}", switching...`
);
// Switch to the new library via platform (syncs across all windows)
if (platform.setCurrentLibraryId) {
platform.setCurrentLibraryId(id).catch((err) => {
console.error(
"[Auto-Switch] Failed to switch library:",
err,
);
});
} else {
// Fallback: just update the client
spacedrive.setCurrentLibrary(id);
}
} else {
console.log(
`[Auto-Switch] Received synced library "${name}", but auto-switch is disabled`,
);
}
}
});
// Switch to the new library via platform (syncs across all windows)
if (platform.setCurrentLibraryId) {
platform.setCurrentLibraryId(id).catch((err) => {
console.error("[Auto-Switch] Failed to switch library:", err);
});
} else {
// Fallback: just update the client
spacedrive.setCurrentLibrary(id);
}
} else {
console.log(
`[Auto-Switch] Received synced library "${name}", but auto-switch is disabled`
);
}
}
});
// No global subscription needed - each useNormalizedCache creates its own filtered subscription
} catch (err) {
console.error("Failed to create client:", err);
setError(err instanceof Error ? err.message : String(err));
}
// No global subscription needed - each useNormalizedCache creates its own filtered subscription
} catch (err) {
console.error("Failed to create client:", err);
setError(err instanceof Error ? err.message : String(err));
}
return () => {
if (unsubscribePromise) {
unsubscribePromise.then((unsubscribe) => unsubscribe());
}
return () => {
if (unsubscribePromise) {
unsubscribePromise.then((unsubscribe) => unsubscribe());
}
// Clean up all backend TCP connections to prevent connection leaks
// This is especially important during development hot reloads
invoke("cleanup_all_connections").catch((err) => {
console.warn("Failed to cleanup connections:", err);
});
};
}, []);
// Clean up all backend TCP connections to prevent connection leaks
// This is especially important during development hot reloads
invoke("cleanup_all_connections").catch((err) => {
console.warn("Failed to cleanup connections:", err);
});
};
}, []);
// Routes that don't need the client
if (route === "/floating-controls") {
return <FloatingControls />;
}
// Routes that don't need the client
if (route === "/floating-controls") {
return <FloatingControls />;
}
if (route === "/drag-overlay") {
return <DragOverlay />;
}
if (route === "/drag-overlay") {
return <DragOverlay />;
}
if (route === "/contextmenu") {
return <ContextMenuWindow />;
}
if (route === "/contextmenu") {
return <ContextMenuWindow />;
}
if (route === "/drag-demo") {
return <DragDemo />;
}
if (route === "/drag-demo") {
return <DragDemo />;
}
if (route === "/spacedrop") {
return <SpacedropWindow />;
}
if (route === "/spacedrop") {
return <SpacedropWindow />;
}
if (error) {
console.log("Rendering error state");
return (
<div className="flex h-screen items-center justify-center bg-gray-950 text-white">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Error</h1>
<p className="text-red-400">{error}</p>
</div>
</div>
);
}
if (error) {
console.log("Rendering error state");
return (
<div className="flex h-screen items-center justify-center bg-gray-950 text-white">
<div className="text-center">
<h1 className="mb-4 font-bold text-2xl">Error</h1>
<p className="text-red-400">{error}</p>
</div>
</div>
);
}
if (!client) {
console.log("Rendering loading state");
return (
<div className="flex h-screen items-center justify-center bg-gray-950 text-white">
<div className="text-center">
<div className="animate-pulse text-xl">
Initializing client...
</div>
<p className="text-gray-400 text-sm mt-2">
Check console for logs
</p>
</div>
</div>
);
}
if (!client) {
console.log("Rendering loading state");
return (
<div className="flex h-screen items-center justify-center bg-gray-950 text-white">
<div className="text-center">
<div className="animate-pulse text-xl">Initializing client...</div>
<p className="mt-2 text-gray-400 text-sm">Check console for logs</p>
</div>
</div>
);
}
console.log("Rendering Interface with client");
console.log("Rendering Interface with client");
// Route to different UIs based on window type
if (route === "/settings") {
return (
<PlatformProvider platform={platform}>
<SpacedriveProvider client={client}>
<Settings />
</SpacedriveProvider>
</PlatformProvider>
);
}
// Route to different UIs based on window type
if (route === "/settings") {
return (
<PlatformProvider platform={platform}>
<SpacedriveProvider client={client}>
<Settings />
</SpacedriveProvider>
</PlatformProvider>
);
}
if (route === "/inspector") {
return (
<PlatformProvider platform={platform}>
<SpacedriveProvider client={client}>
<ServerProvider>
<div className="h-screen bg-app overflow-hidden">
<PopoutInspector />
</div>
</ServerProvider>
</SpacedriveProvider>
</PlatformProvider>
);
}
if (route === "/inspector") {
return (
<PlatformProvider platform={platform}>
<SpacedriveProvider client={client}>
<ServerProvider>
<div className="h-screen overflow-hidden bg-app">
<PopoutInspector />
</div>
</ServerProvider>
</SpacedriveProvider>
</PlatformProvider>
);
}
if (route === "/cache-demo") {
return <LocationCacheDemo />;
}
if (route === "/cache-demo") {
return <LocationCacheDemo />;
}
if (route === "/quick-preview") {
return (
<PlatformProvider platform={platform}>
<SpacedriveProvider client={client}>
<ServerProvider>
<div className="h-screen bg-app overflow-hidden">
<QuickPreview />
</div>
</ServerProvider>
</SpacedriveProvider>
</PlatformProvider>
);
}
if (route === "/quick-preview") {
return (
<PlatformProvider platform={platform}>
<SpacedriveProvider client={client}>
<ServerProvider>
<div className="h-screen overflow-hidden bg-app">
<QuickPreview />
</div>
</ServerProvider>
</SpacedriveProvider>
</PlatformProvider>
);
}
if (route === "/job-manager") {
return (
<PlatformProvider platform={platform}>
<SpacedriveProvider client={client}>
<ServerProvider>
<div className="h-screen bg-app overflow-hidden rounded-[10px] border border-transparent frame">
<JobsScreen />
</div>
</ServerProvider>
</SpacedriveProvider>
</PlatformProvider>
);
}
if (route === "/job-manager") {
return (
<PlatformProvider platform={platform}>
<SpacedriveProvider client={client}>
<ServerProvider>
<div className="frame h-screen overflow-hidden rounded-[10px] border border-transparent bg-app">
<JobsScreen />
</div>
</ServerProvider>
</SpacedriveProvider>
</PlatformProvider>
);
}
return (
<PlatformProvider platform={platform}>
<Shell client={client} />
</PlatformProvider>
);
return (
<PlatformProvider platform={platform}>
<Shell client={client} />
</PlatformProvider>
);
}
export default App;
export default App;

View File

@@ -1,274 +1,249 @@
import { useState, useRef } from "react";
import { Copy, Trash, Eye, Share } from "@phosphor-icons/react";
import { Copy, Eye, Share, Trash } from "@phosphor-icons/react";
import { useContextMenu } from "@sd/interface";
import { useRef, useState } from "react";
import { useDragOperation } from "../hooks/useDragOperation";
import { useDropZone } from "../hooks/useDropZone";
import { useContextMenu } from "@sd/interface";
import type { DragItem } from "../lib/drag";
export function DragDemo() {
const [selectedFiles, setSelectedFiles] = useState<string[]>([
"/Users/example/Documents/report.pdf",
"/Users/example/Pictures/photo.jpg",
]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [draggingFile, setDraggingFile] = useState<string | null>(null);
const dragStartPos = useRef<{ x: number; y: number } | null>(null);
const [selectedFiles, setSelectedFiles] = useState<string[]>([
"/Users/example/Documents/report.pdf",
"/Users/example/Pictures/photo.jpg",
]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [draggingFile, setDraggingFile] = useState<string | null>(null);
const dragStartPos = useRef<{ x: number; y: number } | null>(null);
// Context menu for files
const contextMenu = useContextMenu({
items: [
{
icon: Copy,
label: "Copy",
onClick: () => alert(`Copying: ${selectedFile}`),
keybind: "⌘C",
condition: () => selectedFile !== null,
},
{
icon: Eye,
label: "Quick Look",
onClick: () => alert(`Quick Look: ${selectedFile}`),
keybind: "Space",
},
{ type: "separator" },
{
icon: Share,
label: "Share",
submenu: [
{
label: "AirDrop",
onClick: () => alert("AirDrop share"),
},
{
label: "Messages",
onClick: () => alert("Messages share"),
},
],
},
{ type: "separator" },
{
icon: Trash,
label: "Delete",
onClick: () => {
if (selectedFile && confirm(`Delete ${selectedFile}?`)) {
setSelectedFiles((files) =>
files.filter((f) => f !== selectedFile),
);
setSelectedFile(null);
}
},
keybind: "⌘⌫",
variant: "danger" as const,
},
],
});
// Context menu for files
const contextMenu = useContextMenu({
items: [
{
icon: Copy,
label: "Copy",
onClick: () => alert(`Copying: ${selectedFile}`),
keybind: "⌘C",
condition: () => selectedFile !== null,
},
{
icon: Eye,
label: "Quick Look",
onClick: () => alert(`Quick Look: ${selectedFile}`),
keybind: "Space",
},
{ type: "separator" },
{
icon: Share,
label: "Share",
submenu: [
{
label: "AirDrop",
onClick: () => alert("AirDrop share"),
},
{
label: "Messages",
onClick: () => alert("Messages share"),
},
],
},
{ type: "separator" },
{
icon: Trash,
label: "Delete",
onClick: () => {
if (selectedFile && confirm(`Delete ${selectedFile}?`)) {
setSelectedFiles((files) =>
files.filter((f) => f !== selectedFile)
);
setSelectedFile(null);
}
},
keybind: "⌘⌫",
variant: "danger" as const,
},
],
});
const { isDragging, startDrag, cursorPosition } = useDragOperation({
onDragStart: (sessionId) => {
console.log("Drag started:", sessionId);
},
onDragEnd: (result) => {
console.log("Drag ended:", result);
setDraggingFile(null);
dragStartPos.current = null;
},
});
const { isDragging, startDrag, cursorPosition } = useDragOperation({
onDragStart: (sessionId) => {
console.log("Drag started:", sessionId);
},
onDragEnd: (result) => {
console.log("Drag ended:", result);
setDraggingFile(null);
dragStartPos.current = null;
},
});
const { isHovered, dropZoneProps } = useDropZone({
onDrop: (items) => {
console.log("Files dropped:", items);
},
onDragEnter: () => {
console.log("Drag entered drop zone");
},
onDragLeave: () => {
console.log("Drag left drop zone");
},
});
const { isHovered, dropZoneProps } = useDropZone({
onDrop: (items) => {
console.log("Files dropped:", items);
},
onDragEnter: () => {
console.log("Drag entered drop zone");
},
onDragLeave: () => {
console.log("Drag left drop zone");
},
});
const handleMouseDown = (file: string, e: React.MouseEvent) => {
setDraggingFile(file);
dragStartPos.current = { x: e.clientX, y: e.clientY };
};
const handleMouseDown = (file: string, e: React.MouseEvent) => {
setDraggingFile(file);
dragStartPos.current = { x: e.clientX, y: e.clientY };
};
const handleMouseMove = async (e: React.MouseEvent) => {
if (!draggingFile || !dragStartPos.current || isDragging) return;
const handleMouseMove = async (e: React.MouseEvent) => {
if (!(draggingFile && dragStartPos.current) || isDragging) return;
const distance = Math.sqrt(
Math.pow(e.clientX - dragStartPos.current.x, 2) +
Math.pow(e.clientY - dragStartPos.current.y, 2),
);
const distance = Math.sqrt(
(e.clientX - dragStartPos.current.x) ** 2 +
(e.clientY - dragStartPos.current.y) ** 2
);
// Start native drag after moving 10px
if (distance > 10) {
const items: DragItem[] = [
{
id: `file-${draggingFile}`,
kind: {
type: "file" as const,
path: draggingFile,
},
},
];
// Start native drag after moving 10px
if (distance > 10) {
const items: DragItem[] = [
{
id: `file-${draggingFile}`,
kind: {
type: "file" as const,
path: draggingFile,
},
},
];
try {
await startDrag({
items,
allowedOperations: ["copy", "move"],
});
} catch (error) {
console.error("Failed to start drag:", error);
setDraggingFile(null);
}
}
};
try {
await startDrag({
items,
allowedOperations: ["copy", "move"],
});
} catch (error) {
console.error("Failed to start drag:", error);
setDraggingFile(null);
}
}
};
const handleMouseUp = () => {
setDraggingFile(null);
dragStartPos.current = null;
};
const handleMouseUp = () => {
setDraggingFile(null);
dragStartPos.current = null;
};
return (
<div
className="p-8 space-y-6 bg-gray-900 text-white min-h-screen"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<h1 className="text-3xl font-bold">Native Drag & Drop Demo</h1>
return (
<div
className="min-h-screen space-y-6 bg-gray-900 p-8 text-white"
onMouseLeave={handleMouseUp}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<h1 className="font-bold text-3xl">Native Drag & Drop Demo</h1>
{/* Draggable items */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">Draggable Files</h2>
<div className="space-y-2">
{selectedFiles.map((file, idx) => (
<div
key={idx}
className={`bg-gray-800 p-4 rounded-lg border transition-colors cursor-move select-none ${
draggingFile === file
? "border-accent bg-blue-900/20"
: selectedFile === file
? "border-green-500 bg-green-900/20"
: "border-gray-700 hover:border-accent"
}`}
onMouseDown={(e) => {
e.preventDefault();
handleMouseDown(file, e);
}}
onClick={() => setSelectedFile(file)}
onContextMenu={(e) => {
setSelectedFile(file);
contextMenu.show(e);
}}
>
<div className="flex items-center gap-3">
<div className="text-2xl"></div>
<div className="flex-1">
<div className="font-medium">
{file.split("/").pop()}
</div>
<div className="text-sm text-gray-400">
{file}
</div>
</div>
</div>
</div>
))}
</div>
<p className="text-sm text-gray-400">
Click and drag these files - move them out of the window to
start native drag!
<br />
Right-click on a file to test the native context menu.
</p>
</div>
{/* Draggable items */}
<div className="space-y-4">
<h2 className="font-semibold text-xl">Draggable Files</h2>
<div className="space-y-2">
{selectedFiles.map((file, idx) => (
<div
className={`cursor-move select-none rounded-lg border bg-gray-800 p-4 transition-colors ${
draggingFile === file
? "border-accent bg-blue-900/20"
: selectedFile === file
? "border-green-500 bg-green-900/20"
: "border-gray-700 hover:border-accent"
}`}
key={idx}
onClick={() => setSelectedFile(file)}
onContextMenu={(e) => {
setSelectedFile(file);
contextMenu.show(e);
}}
onMouseDown={(e) => {
e.preventDefault();
handleMouseDown(file, e);
}}
>
<div className="flex items-center gap-3">
<div className="text-2xl" />
<div className="flex-1">
<div className="font-medium">{file.split("/").pop()}</div>
<div className="text-gray-400 text-sm">{file}</div>
</div>
</div>
</div>
))}
</div>
<p className="text-gray-400 text-sm">
Click and drag these files - move them out of the window to start
native drag!
<br />
Right-click on a file to test the native context menu.
</p>
</div>
{/* Drop zone */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">Drop Zone</h2>
<div
{...dropZoneProps}
className={`
border-2 border-dashed rounded-lg p-8 text-center transition-all
${isHovered ? "border-accent bg-accent/10" : "border-gray-700 bg-gray-800/50"}
{/* Drop zone */}
<div className="space-y-4">
<h2 className="font-semibold text-xl">Drop Zone</h2>
<div
{...dropZoneProps}
className={`rounded-lg border-2 border-dashed p-8 text-center transition-all ${isHovered ? "border-accent bg-accent/10" : "border-gray-700 bg-gray-800/50"}
`}
>
<div className="text-4xl mb-2">{isHovered ? "" : ""}</div>
<div className="text-lg font-medium">
{isHovered ? "Drop files here" : "Drag files here"}
</div>
<div className="text-sm text-gray-400 mt-1">
This drop zone accepts files from other Spacedrive
windows
</div>
</div>
</div>
>
<div className="mb-2 text-4xl">{isHovered ? "" : ""}</div>
<div className="font-medium text-lg">
{isHovered ? "Drop files here" : "Drag files here"}
</div>
<div className="mt-1 text-gray-400 text-sm">
This drop zone accepts files from other Spacedrive windows
</div>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<h2 className="text-xl font-semibold">Status</h2>
<div className="bg-gray-800 p-4 rounded-lg space-y-2 font-mono text-sm">
<div>
<span className="text-gray-400">Dragging:</span>{" "}
<span
className={
isDragging ? "text-green-400" : "text-gray-500"
}
>
{isDragging ? "Yes" : "No"}
</span>
</div>
<div>
<span className="text-gray-400">
Drop zone hovered:
</span>{" "}
<span
className={
isHovered ? "text-blue-400" : "text-gray-500"
}
>
{isHovered ? "Yes" : "No"}
</span>
</div>
{cursorPosition && (
<div>
<span className="text-gray-400">Cursor:</span>{" "}
<span className="text-gray-300">
({Math.round(cursorPosition.x)},{" "}
{Math.round(cursorPosition.y)})
</span>
</div>
)}
</div>
</div>
{/* Status */}
<div className="space-y-2">
<h2 className="font-semibold text-xl">Status</h2>
<div className="space-y-2 rounded-lg bg-gray-800 p-4 font-mono text-sm">
<div>
<span className="text-gray-400">Dragging:</span>{" "}
<span className={isDragging ? "text-green-400" : "text-gray-500"}>
{isDragging ? "Yes" : "No"}
</span>
</div>
<div>
<span className="text-gray-400">Drop zone hovered:</span>{" "}
<span className={isHovered ? "text-blue-400" : "text-gray-500"}>
{isHovered ? "Yes" : "No"}
</span>
</div>
{cursorPosition && (
<div>
<span className="text-gray-400">Cursor:</span>{" "}
<span className="text-gray-300">
({Math.round(cursorPosition.x)}, {Math.round(cursorPosition.y)})
</span>
</div>
)}
</div>
</div>
<div className="text-sm text-gray-500 border-t border-gray-800 pt-4">
<p className="font-semibold mb-2">How it works:</p>
<ul className="list-disc list-inside space-y-1">
<li>
Drag files from the list above to Finder - they'll
appear as real files
</li>
<li>
The custom overlay window follows your cursor during the
drag
</li>
<li>
Drop zones in other Spacedrive windows can receive the
dragged files
</li>
<li>
All drag state is synchronized across windows via Tauri
events
</li>
<li>
<strong>
Right-click files for native context menu
</strong>{" "}
- transparent window positioned at cursor
</li>
</ul>
</div>
</div>
);
<div className="border-gray-800 border-t pt-4 text-gray-500 text-sm">
<p className="mb-2 font-semibold">How it works:</p>
<ul className="list-inside list-disc space-y-1">
<li>
Drag files from the list above to Finder - they'll appear as real
files
</li>
<li>The custom overlay window follows your cursor during the drag</li>
<li>
Drop zones in other Spacedrive windows can receive the dragged files
</li>
<li>
All drag state is synchronized across windows via Tauri events
</li>
<li>
<strong>Right-click files for native context menu</strong> -
transparent window positioned at cursor
</li>
</ul>
</div>
</div>
);
}

View File

@@ -1,63 +1,68 @@
import { Menu, MenuItem, Submenu, PredefinedMenuItem } from '@tauri-apps/api/menu';
import type { ContextMenuItem } from '@sd/interface';
import type { ContextMenuItem } from "@sd/interface";
import {
Menu,
MenuItem,
PredefinedMenuItem,
Submenu,
} from "@tauri-apps/api/menu";
/**
* Convert platform-agnostic menu items to Tauri's native Menu API
*/
export async function showNativeContextMenu(
items: ContextMenuItem[],
position: { x: number; y: number }
items: ContextMenuItem[],
position: { x: number; y: number }
) {
console.log('[Tauri ContextMenu] Building native menu from items:', items);
console.log("[Tauri ContextMenu] Building native menu from items:", items);
const menuItems = await buildMenuItems(items);
const menu = await Menu.new({ items: menuItems });
const menuItems = await buildMenuItems(items);
const menu = await Menu.new({ items: menuItems });
console.log('[Tauri ContextMenu] Showing menu at position:', position);
await menu.popup();
console.log("[Tauri ContextMenu] Showing menu at position:", position);
await menu.popup();
}
/**
* Recursively build Tauri menu items from platform-agnostic definitions
*/
async function buildMenuItems(items: ContextMenuItem[]): Promise<any[]> {
const menuItems = [];
const menuItems = [];
for (const item of items) {
if (item.type === 'separator') {
// Add separator
menuItems.push(await PredefinedMenuItem.new({ item: 'Separator' }));
} else if (item.submenu) {
// Add submenu
const subItems = await buildMenuItems(item.submenu);
const submenu = await Submenu.new({
text: item.label || 'Submenu',
items: subItems,
});
menuItems.push(submenu);
} else {
// Add regular menu item
const menuItem = await MenuItem.new({
text: item.label || '',
enabled: !item.disabled,
accelerator: item.keybind,
action: item.onClick,
});
menuItems.push(menuItem);
}
}
for (const item of items) {
if (item.type === "separator") {
// Add separator
menuItems.push(await PredefinedMenuItem.new({ item: "Separator" }));
} else if (item.submenu) {
// Add submenu
const subItems = await buildMenuItems(item.submenu);
const submenu = await Submenu.new({
text: item.label || "Submenu",
items: subItems,
});
menuItems.push(submenu);
} else {
// Add regular menu item
const menuItem = await MenuItem.new({
text: item.label || "",
enabled: !item.disabled,
accelerator: item.keybind,
action: item.onClick,
});
menuItems.push(menuItem);
}
}
return menuItems;
return menuItems;
}
/**
* Initialize the context menu handler on the window global
*/
export function initializeContextMenuHandler() {
if (!window.__SPACEDRIVE__) {
(window as any).__SPACEDRIVE__ = {};
}
if (!window.__SPACEDRIVE__) {
(window as any).__SPACEDRIVE__ = {};
}
window.__SPACEDRIVE__.showContextMenu = showNativeContextMenu;
console.log('[Tauri ContextMenu] Handler initialized');
window.__SPACEDRIVE__.showContextMenu = showNativeContextMenu;
console.log("[Tauri ContextMenu] Handler initialized");
}

View File

@@ -1,16 +1,16 @@
import { useCallback, useEffect, useState, useRef } from 'react';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useCallback, useEffect, useRef, useState } from "react";
import {
beginDrag,
type DragConfig,
type DragMoveEvent,
type DragResult,
type DragSession,
endDrag,
onDragBegan,
onDragEnded,
onDragMoved,
type DragConfig,
type DragSession,
type DragResult,
type DragMoveEvent,
} from '../lib/drag';
} from "../lib/drag";
export interface UseDragOperationOptions {
onDragStart?: (sessionId: string) => void;
@@ -64,12 +64,12 @@ export function useDragOperation(options: UseDragOperationOptions = {}) {
}, []);
const startDrag = useCallback(
async (config: Omit<DragConfig, 'overlayUrl' | 'overlaySize'>) => {
async (config: Omit<DragConfig, "overlayUrl" | "overlaySize">) => {
const currentWindow = getCurrentWebviewWindow();
const sessionId = await beginDrag(
{
...config,
overlayUrl: '/drag-overlay',
overlayUrl: "/drag-overlay",
overlaySize: [200, 150],
},
currentWindow.label
@@ -80,7 +80,7 @@ export function useDragOperation(options: UseDragOperationOptions = {}) {
);
const cancelDrag = useCallback(async (sessionId: string) => {
await endDrag(sessionId, { type: 'Cancelled' });
await endDrag(sessionId, { type: "Cancelled" });
}, []);
return {

View File

@@ -1,12 +1,11 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef, useState } from "react";
import {
type DragItem,
onDragEnded,
onDragEntered,
onDragLeft,
onDragEnded,
type DragItem,
type DragResult,
} from '../lib/drag';
} from "../lib/drag";
export interface UseDropZoneOptions {
onDrop?: (items: DragItem[]) => void;
@@ -48,10 +47,12 @@ export function useDropZone(options: UseDropZoneOptions = {}) {
const unlistenEnded = onDragEnded((event) => {
setIsHovered((prevHovered) => {
setDragItems((prevItems) => {
if (currentSessionRef.current === event.sessionId && prevHovered) {
if (event.result.type === 'Dropped') {
onDropRef.current?.(prevItems);
}
if (
currentSessionRef.current === event.sessionId &&
prevHovered &&
event.result.type === "Dropped"
) {
onDropRef.current?.(prevItems);
}
return [];
});
@@ -68,8 +69,8 @@ export function useDropZone(options: UseDropZoneOptions = {}) {
}, [currentWindowLabel]);
const dropZoneProps = {
'data-drop-zone': true,
'data-hovered': isHovered,
"data-drop-zone": true,
"data-hovered": isHovered,
};
return {

View File

@@ -4,53 +4,53 @@
/* Utility classes */
.top-bar-blur {
backdrop-filter: saturate(120%) blur(18px);
backdrop-filter: saturate(120%) blur(18px);
}
.frame::before {
content: "";
pointer-events: none;
user-select: none;
position: absolute;
inset: 0px;
border-radius: inherit;
padding: 1px;
background: var(--color-app-frame);
mask:
linear-gradient(black, black) content-box content-box,
linear-gradient(black, black);
mask-composite: xor;
-webkit-mask-composite: xor;
z-index: 9999;
content: "";
pointer-events: none;
user-select: none;
position: absolute;
inset: 0px;
border-radius: inherit;
padding: 1px;
background: var(--color-app-frame);
mask:
linear-gradient(black, black) content-box content-box,
linear-gradient(black, black);
mask-composite: xor;
-webkit-mask-composite: xor;
z-index: 9999;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
-ms-overflow-style: none;
scrollbar-width: none;
}
.mask-fade-out {
mask-image: linear-gradient(
to bottom,
black calc(100% - 40px),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
black calc(100% - 40px),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
black calc(100% - 40px),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
black calc(100% - 40px),
transparent 100%
);
}
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -1,8 +1,8 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
interface KeybindEvent {
id: string;
id: string;
}
type KeybindHandler = () => void | Promise<void>;
@@ -13,166 +13,178 @@ let clipboardUnlisten: UnlistenFn | null = null;
// Check if an input element is currently focused
function isInputFocused(): boolean {
const activeElement = document.activeElement;
console.log('[Clipboard] Active element:', {
element: activeElement,
tagName: activeElement?.tagName,
type: (activeElement as HTMLInputElement)?.type,
contenteditable: activeElement?.getAttribute('contenteditable')
});
const activeElement = document.activeElement;
console.log("[Clipboard] Active element:", {
element: activeElement,
tagName: activeElement?.tagName,
type: (activeElement as HTMLInputElement)?.type,
contenteditable: activeElement?.getAttribute("contenteditable"),
});
if (!activeElement) {
console.log('[Clipboard] No active element');
return false;
}
if (!activeElement) {
console.log("[Clipboard] No active element");
return false;
}
const tagName = activeElement.tagName.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
console.log('[Clipboard] Input element focused:', tagName);
return true;
}
const tagName = activeElement.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
console.log("[Clipboard] Input element focused:", tagName);
return true;
}
// Check for contenteditable
if (activeElement.getAttribute('contenteditable') === 'true') {
console.log('[Clipboard] Contenteditable element focused');
return true;
}
// Check for contenteditable
if (activeElement.getAttribute("contenteditable") === "true") {
console.log("[Clipboard] Contenteditable element focused");
return true;
}
console.log('[Clipboard] Non-input element focused:', tagName);
return false;
console.log("[Clipboard] Non-input element focused:", tagName);
return false;
}
// Execute native clipboard operation (for text inputs)
function executeNativeClipboard(action: 'copy' | 'cut' | 'paste'): void {
console.log(`[Clipboard] Executing native ${action} operation`);
try {
// Use execCommand for compatibility (deprecated but still works)
const result = document.execCommand(action);
console.log(`[Clipboard] execCommand('${action}') result:`, result);
} catch (err) {
console.error(`[Clipboard] Failed to execute native ${action}:`, err);
}
function executeNativeClipboard(action: "copy" | "cut" | "paste"): void {
console.log(`[Clipboard] Executing native ${action} operation`);
try {
// Use execCommand for compatibility (deprecated but still works)
const result = document.execCommand(action);
console.log(`[Clipboard] execCommand('${action}') result:`, result);
} catch (err) {
console.error(`[Clipboard] Failed to execute native ${action}:`, err);
}
}
// Initialize Tauri keybind listener
export async function initializeKeybindHandler(): Promise<void> {
// Only initialize once
if (eventUnlisten !== null) return;
// Only initialize once
if (eventUnlisten !== null) return;
// Listen for keybind events from Rust
eventUnlisten = await listen<KeybindEvent>('keybind-triggered', async (event) => {
const handler = keybindHandlers.get(event.payload.id);
if (handler) {
try {
await handler();
} catch (err) {
console.error(`[Keybind] Handler error for ${event.payload.id}:`, err);
}
}
});
// Listen for keybind events from Rust
eventUnlisten = await listen<KeybindEvent>(
"keybind-triggered",
async (event) => {
const handler = keybindHandlers.get(event.payload.id);
if (handler) {
try {
await handler();
} catch (err) {
console.error(
`[Keybind] Handler error for ${event.payload.id}:`,
err
);
}
}
}
);
// Listen for clipboard actions from native menu
clipboardUnlisten = await listen<string>('clipboard-action', async (event) => {
const action = event.payload as 'copy' | 'cut' | 'paste';
console.log(`[Clipboard] Received clipboard-action event:`, action);
// Listen for clipboard actions from native menu
clipboardUnlisten = await listen<string>(
"clipboard-action",
async (event) => {
const action = event.payload as "copy" | "cut" | "paste";
console.log("[Clipboard] Received clipboard-action event:", action);
// Check if an input is focused
if (isInputFocused()) {
// Execute native browser clipboard operation
console.log('[Clipboard] Input focused, executing native operation');
executeNativeClipboard(action);
} else {
// Trigger file operation via keybind system
const keybindId = `explorer.${action}`;
console.log('[Clipboard] No input focused, triggering file operation:', keybindId);
const handler = keybindHandlers.get(keybindId);
if (handler) {
try {
await handler();
console.log(`[Clipboard] File operation ${keybindId} completed`);
} catch (err) {
console.error(`[Clipboard] Handler error for ${keybindId}:`, err);
}
} else {
console.warn(`[Clipboard] No handler registered for ${keybindId}`);
}
}
});
// Check if an input is focused
if (isInputFocused()) {
// Execute native browser clipboard operation
console.log("[Clipboard] Input focused, executing native operation");
executeNativeClipboard(action);
} else {
// Trigger file operation via keybind system
const keybindId = `explorer.${action}`;
console.log(
"[Clipboard] No input focused, triggering file operation:",
keybindId
);
const handler = keybindHandlers.get(keybindId);
if (handler) {
try {
await handler();
console.log(`[Clipboard] File operation ${keybindId} completed`);
} catch (err) {
console.error(`[Clipboard] Handler error for ${keybindId}:`, err);
}
} else {
console.warn(`[Clipboard] No handler registered for ${keybindId}`);
}
}
}
);
console.log('[Clipboard] Action listener initialized');
console.log("[Clipboard] Action listener initialized");
console.log('[Keybind] Handler initialized');
console.log("[Keybind] Handler initialized");
}
// Register a keybind with Tauri
export async function registerTauriKeybind(
id: string,
accelerator: string,
handler: KeybindHandler
id: string,
accelerator: string,
handler: KeybindHandler
): Promise<void> {
keybindHandlers.set(id, handler);
keybindHandlers.set(id, handler);
try {
await invoke('register_keybind', {
id,
accelerator
});
console.log(`[Keybind] Registered: ${id} (${accelerator})`);
} catch (error) {
console.error(`[Keybind] Failed to register ${id}:`, error);
// Keep the handler registered for web fallback
}
try {
await invoke("register_keybind", {
id,
accelerator,
});
console.log(`[Keybind] Registered: ${id} (${accelerator})`);
} catch (error) {
console.error(`[Keybind] Failed to register ${id}:`, error);
// Keep the handler registered for web fallback
}
}
// Unregister a keybind
export async function unregisterTauriKeybind(id: string): Promise<void> {
keybindHandlers.delete(id);
keybindHandlers.delete(id);
try {
await invoke('unregister_keybind', { id });
console.log(`[Keybind] Unregistered: ${id}`);
} catch (error) {
console.error(`[Keybind] Failed to unregister ${id}:`, error);
}
try {
await invoke("unregister_keybind", { id });
console.log(`[Keybind] Unregistered: ${id}`);
} catch (error) {
console.error(`[Keybind] Failed to unregister ${id}:`, error);
}
}
// Cleanup function
export async function cleanupKeybindHandler(): Promise<void> {
if (eventUnlisten) {
eventUnlisten();
eventUnlisten = null;
}
if (eventUnlisten) {
eventUnlisten();
eventUnlisten = null;
}
if (clipboardUnlisten) {
clipboardUnlisten();
clipboardUnlisten = null;
}
if (clipboardUnlisten) {
clipboardUnlisten();
clipboardUnlisten = null;
}
// Unregister all keybinds
const ids = Array.from(keybindHandlers.keys());
for (const id of ids) {
try {
await invoke('unregister_keybind', { id });
} catch {
// Ignore errors during cleanup
}
}
keybindHandlers.clear();
// Unregister all keybinds
const ids = Array.from(keybindHandlers.keys());
for (const id of ids) {
try {
await invoke("unregister_keybind", { id });
} catch {
// Ignore errors during cleanup
}
}
keybindHandlers.clear();
console.log('[Keybind] Handler cleaned up');
console.log("[Keybind] Handler cleaned up");
}
// Initialize keybind handler on window global (same pattern as context menu)
export function initializeKeybindGlobal(): void {
if (!window.__SPACEDRIVE__) {
(window as any).__SPACEDRIVE__ = {};
}
if (!window.__SPACEDRIVE__) {
(window as any).__SPACEDRIVE__ = {};
}
window.__SPACEDRIVE__.registerKeybind = registerTauriKeybind;
window.__SPACEDRIVE__.unregisterKeybind = unregisterTauriKeybind;
window.__SPACEDRIVE__.registerKeybind = registerTauriKeybind;
window.__SPACEDRIVE__.unregisterKeybind = unregisterTauriKeybind;
// Initialize the event listener
initializeKeybindHandler().catch(console.error);
// Initialize the event listener
initializeKeybindHandler().catch(console.error);
console.log('[Keybind] Global handlers initialized');
console.log("[Keybind] Global handlers initialized");
}

View File

@@ -1,18 +1,18 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
// Types matching Rust definitions (lowercase to match serde rename_all = "camelCase")
export type DragItemKind =
| { type: 'file'; path: string }
| { type: 'filePromise'; name: string; mimeType: string }
| { type: 'text'; content: string };
| { type: "file"; path: string }
| { type: "filePromise"; name: string; mimeType: string }
| { type: "text"; content: string };
export interface DragItem {
kind: DragItemKind;
id: string;
}
export type DragOperation = 'copy' | 'move' | 'link';
export type DragOperation = "copy" | "move" | "link";
export interface DragConfig {
items: DragItem[];
@@ -29,9 +29,9 @@ export interface DragSession {
}
export type DragResult =
| { type: 'Dropped'; operation: DragOperation; target?: string }
| { type: 'Cancelled' }
| { type: 'Failed'; error: string };
| { type: "Dropped"; operation: DragOperation; target?: string }
| { type: "Cancelled" }
| { type: "Failed"; error: string };
// Event types
export interface DragBeganEvent {
@@ -67,37 +67,37 @@ export async function beginDrag(
config: DragConfig,
sourceWindowLabel: string
): Promise<string> {
return await invoke('begin_drag', { config, sourceWindowLabel });
return await invoke("begin_drag", { config, sourceWindowLabel });
}
export async function endDrag(
sessionId: string,
result: DragResult
): Promise<void> {
return await invoke('end_drag', { sessionId, result });
return await invoke("end_drag", { sessionId, result });
}
export async function getDragSession(): Promise<DragSession | null> {
return await invoke('get_drag_session');
return await invoke("get_drag_session");
}
// Event listeners
export async function onDragBegan(
handler: (event: DragBeganEvent) => void
): Promise<UnlistenFn> {
return await listen<DragBeganEvent>('drag:began', (e) => handler(e.payload));
return await listen<DragBeganEvent>("drag:began", (e) => handler(e.payload));
}
export async function onDragMoved(
handler: (event: DragMoveEvent) => void
): Promise<UnlistenFn> {
return await listen<DragMoveEvent>('drag:moved', (e) => handler(e.payload));
return await listen<DragMoveEvent>("drag:moved", (e) => handler(e.payload));
}
export async function onDragEntered(
handler: (event: DragWindowEvent) => void
): Promise<UnlistenFn> {
return await listen<DragWindowEvent>('drag:entered', (e) =>
return await listen<DragWindowEvent>("drag:entered", (e) =>
handler(e.payload)
);
}
@@ -105,11 +105,11 @@ export async function onDragEntered(
export async function onDragLeft(
handler: (event: DragWindowEvent) => void
): Promise<UnlistenFn> {
return await listen<DragWindowEvent>('drag:left', (e) => handler(e.payload));
return await listen<DragWindowEvent>("drag:left", (e) => handler(e.payload));
}
export async function onDragEnded(
handler: (event: DragEndEvent) => void
): Promise<UnlistenFn> {
return await listen<DragEndEvent>('drag:ended', (e) => handler(e.payload));
return await listen<DragEndEvent>("drag:ended", (e) => handler(e.payload));
}

View File

@@ -1,13 +1,13 @@
import { ErrorBoundary } from '@sd/interface';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import { ErrorBoundary } from "@sd/interface";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);

View File

@@ -1,10 +1,20 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
import { convertFileSrc as tauriConvertFileSrc, invoke } from "@tauri-apps/api/core";
import type { Platform } from "@sd/interface/platform";
import {
invoke,
convertFileSrc as tauriConvertFileSrc,
} from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import type { Platform } from "@sd/interface/platform";
import { beginDrag, onDragBegan, onDragMoved, onDragEntered, onDragLeft, onDragEnded } from "./lib/drag";
import { open, save } from "@tauri-apps/plugin-dialog";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
import {
beginDrag,
onDragBegan,
onDragEnded,
onDragEntered,
onDragLeft,
onDragMoved,
} from "./lib/drag";
let _isDragging = false;
@@ -12,285 +22,288 @@ let _isDragging = false;
* Tauri platform implementation
*/
export const platform: Platform = {
platform: "tauri",
platform: "tauri",
async openDirectoryPickerDialog(opts) {
const result = await open({
directory: true,
multiple: opts?.multiple ?? false,
title: opts?.title ?? "Choose a folder",
});
async openDirectoryPickerDialog(opts) {
const result = await open({
directory: true,
multiple: opts?.multiple ?? false,
title: opts?.title ?? "Choose a folder",
});
return result;
},
return result;
},
async openFilePickerDialog(opts) {
const result = await open({
directory: false,
multiple: opts?.multiple ?? false,
title: opts?.title ?? "Choose a file",
});
async openFilePickerDialog(opts) {
const result = await open({
directory: false,
multiple: opts?.multiple ?? false,
title: opts?.title ?? "Choose a file",
});
return result;
},
return result;
},
async saveFilePickerDialog(opts) {
const result = await save({
title: opts?.title ?? "Save file",
defaultPath: opts?.defaultPath,
});
async saveFilePickerDialog(opts) {
const result = await save({
title: opts?.title ?? "Save file",
defaultPath: opts?.defaultPath,
});
return result;
},
return result;
},
openLink(url: string) {
shellOpen(url);
},
openLink(url: string) {
shellOpen(url);
},
confirm(message: string, callback: (result: boolean) => void) {
// Use browser confirm for now - could be replaced with custom dialog
callback(window.confirm(message));
},
confirm(message: string, callback: (result: boolean) => void) {
// Use browser confirm for now - could be replaced with custom dialog
callback(window.confirm(message));
},
convertFileSrc(filePath: string) {
return tauriConvertFileSrc(filePath);
},
convertFileSrc(filePath: string) {
return tauriConvertFileSrc(filePath);
},
async revealFile(filePath: string) {
await invoke("reveal_file", { path: filePath });
},
async revealFile(filePath: string) {
await invoke("reveal_file", { path: filePath });
},
async getAppsForPaths(paths: string[]) {
return await invoke<Array<{ id: string; name: string; icon?: string }>>(
"get_apps_for_paths",
{ paths }
);
},
async getAppsForPaths(paths: string[]) {
return await invoke<Array<{ id: string; name: string; icon?: string }>>(
"get_apps_for_paths",
{ paths }
);
},
async openPathDefault(path: string) {
return await invoke<
| { status: "success" }
| { status: "file_not_found"; path: string }
| { status: "app_not_found"; app_id: string }
| { status: "permission_denied"; path: string }
| { status: "platform_error"; message: string }
>("open_path_default", { path });
},
async openPathDefault(path: string) {
return await invoke<
| { status: "success" }
| { status: "file_not_found"; path: string }
| { status: "app_not_found"; app_id: string }
| { status: "permission_denied"; path: string }
| { status: "platform_error"; message: string }
>("open_path_default", { path });
},
async openPathWithApp(path: string, appId: string) {
return await invoke<
| { status: "success" }
| { status: "file_not_found"; path: string }
| { status: "app_not_found"; app_id: string }
| { status: "permission_denied"; path: string }
| { status: "platform_error"; message: string }
>("open_path_with_app", { path, appId });
},
async openPathWithApp(path: string, appId: string) {
return await invoke<
| { status: "success" }
| { status: "file_not_found"; path: string }
| { status: "app_not_found"; app_id: string }
| { status: "permission_denied"; path: string }
| { status: "platform_error"; message: string }
>("open_path_with_app", { path, appId });
},
async openPathsWithApp(paths: string[], appId: string) {
return await invoke<
Array<
| { status: "success" }
| { status: "file_not_found"; path: string }
| { status: "app_not_found"; app_id: string }
| { status: "permission_denied"; path: string }
| { status: "platform_error"; message: string }
>
>("open_paths_with_app", { paths, appId });
},
async openPathsWithApp(paths: string[], appId: string) {
return await invoke<
Array<
| { status: "success" }
| { status: "file_not_found"; path: string }
| { status: "app_not_found"; app_id: string }
| { status: "permission_denied"; path: string }
| { status: "platform_error"; message: string }
>
>("open_paths_with_app", { paths, appId });
},
async getSidecarPath(
libraryId: string,
contentUuid: string,
kind: string,
variant: string,
format: string
) {
return await invoke<string>("get_sidecar_path", {
libraryId,
contentUuid,
kind,
variant,
format,
});
},
async getSidecarPath(
libraryId: string,
contentUuid: string,
kind: string,
variant: string,
format: string
) {
return await invoke<string>("get_sidecar_path", {
libraryId,
contentUuid,
kind,
variant,
format,
});
},
async updateMenuItems(items) {
await invoke("update_menu_items", { items });
},
async updateMenuItems(items) {
await invoke("update_menu_items", { items });
},
async getCurrentLibraryId() {
try {
return await invoke<string>("get_current_library_id");
} catch {
return null;
}
},
async getCurrentLibraryId() {
try {
return await invoke<string>("get_current_library_id");
} catch {
return null;
}
},
async setCurrentLibraryId(libraryId: string) {
await invoke("set_current_library_id", { libraryId });
},
async setCurrentLibraryId(libraryId: string) {
await invoke("set_current_library_id", { libraryId });
},
async onLibraryIdChanged(callback: (libraryId: string) => void) {
const unlisten = await listen<string>("library-changed", (event) => {
callback(event.payload);
});
return unlisten;
},
async onLibraryIdChanged(callback: (libraryId: string) => void) {
const unlisten = await listen<string>("library-changed", (event) => {
callback(event.payload);
});
return unlisten;
},
async showWindow(window: any) {
await invoke("show_window", { window });
},
async showWindow(window: any) {
await invoke("show_window", { window });
},
async closeWindow(label: string) {
await invoke("close_window", { label });
},
async closeWindow(label: string) {
await invoke("close_window", { label });
},
async onWindowEvent(event: string, callback: () => void) {
const unlisten = await listen(event, () => {
callback();
});
return unlisten;
},
async onWindowEvent(event: string, callback: () => void) {
const unlisten = await listen(event, () => {
callback();
});
return unlisten;
},
getCurrentWindowLabel() {
const window = getCurrentWebviewWindow();
return window.label;
},
getCurrentWindowLabel() {
const window = getCurrentWebviewWindow();
return window.label;
},
async closeCurrentWindow() {
const window = getCurrentWebviewWindow();
await window.close();
},
async closeCurrentWindow() {
const window = getCurrentWebviewWindow();
await window.close();
},
async getSelectedFileIds() {
return await invoke<string[]>("get_selected_file_ids");
},
async getSelectedFileIds() {
return await invoke<string[]>("get_selected_file_ids");
},
async setSelectedFileIds(fileIds: string[]) {
await invoke("set_selected_file_ids", { fileIds });
},
async setSelectedFileIds(fileIds: string[]) {
await invoke("set_selected_file_ids", { fileIds });
},
async onSelectedFilesChanged(callback: (fileIds: string[]) => void) {
const unlisten = await listen<string[]>("selected-files-changed", (event) => {
callback(event.payload);
});
return unlisten;
},
async onSelectedFilesChanged(callback: (fileIds: string[]) => void) {
const unlisten = await listen<string[]>(
"selected-files-changed",
(event) => {
callback(event.payload);
}
);
return unlisten;
},
async getAppVersion() {
const { getVersion } = await import("@tauri-apps/api/app");
return await getVersion();
},
async getAppVersion() {
const { getVersion } = await import("@tauri-apps/api/app");
return await getVersion();
},
async getDaemonStatus() {
return await invoke<{
is_running: boolean;
socket_path: string;
server_url: string | null;
started_by_us: boolean;
}>("get_daemon_status");
},
async getDaemonStatus() {
return await invoke<{
is_running: boolean;
socket_path: string;
server_url: string | null;
started_by_us: boolean;
}>("get_daemon_status");
},
async startDaemonProcess() {
await invoke("start_daemon_process");
},
async startDaemonProcess() {
await invoke("start_daemon_process");
},
async stopDaemonProcess() {
await invoke("stop_daemon_process");
},
async stopDaemonProcess() {
await invoke("stop_daemon_process");
},
async onDaemonConnected(callback: () => void) {
const unlisten = await listen("daemon-connected", () => {
callback();
});
return unlisten;
},
async onDaemonConnected(callback: () => void) {
const unlisten = await listen("daemon-connected", () => {
callback();
});
return unlisten;
},
async onDaemonDisconnected(callback: () => void) {
const unlisten = await listen("daemon-disconnected", () => {
callback();
});
return unlisten;
},
async onDaemonDisconnected(callback: () => void) {
const unlisten = await listen("daemon-disconnected", () => {
callback();
});
return unlisten;
},
async onDaemonStarting(callback: () => void) {
const unlisten = await listen("daemon-starting", () => {
callback();
});
return unlisten;
},
async onDaemonStarting(callback: () => void) {
const unlisten = await listen("daemon-starting", () => {
callback();
});
return unlisten;
},
async checkDaemonInstalled() {
return await invoke<boolean>("check_daemon_installed");
},
async checkDaemonInstalled() {
return await invoke<boolean>("check_daemon_installed");
},
async installDaemonService() {
await invoke("install_daemon_service");
},
async installDaemonService() {
await invoke("install_daemon_service");
},
async uninstallDaemonService() {
await invoke("uninstall_daemon_service");
},
async uninstallDaemonService() {
await invoke("uninstall_daemon_service");
},
async openMacOSSettings() {
await invoke("open_macos_settings");
},
async openMacOSSettings() {
await invoke("open_macos_settings");
},
async startDrag(config) {
const currentWindow = getCurrentWebviewWindow();
const sessionId = await beginDrag(
{
items: config.items.map(item => ({
id: item.id,
kind: item.kind,
})),
overlayUrl: "/drag-overlay",
overlaySize: [200, 150],
allowedOperations: config.allowedOperations,
},
currentWindow.label
);
_isDragging = true;
return sessionId;
},
async startDrag(config) {
const currentWindow = getCurrentWebviewWindow();
const sessionId = await beginDrag(
{
items: config.items.map((item) => ({
id: item.id,
kind: item.kind,
})),
overlayUrl: "/drag-overlay",
overlaySize: [200, 150],
allowedOperations: config.allowedOperations,
},
currentWindow.label
);
_isDragging = true;
return sessionId;
},
async onDragEvent(event, callback) {
const handlers: Record<string, typeof onDragBegan> = {
began: onDragBegan,
moved: onDragMoved,
entered: onDragEntered,
left: onDragLeft,
ended: onDragEnded,
};
const handler = handlers[event];
if (!handler) {
throw new Error(`Unknown drag event: ${event}`);
}
const unlisten = await handler((payload: any) => {
if (event === "ended") {
_isDragging = false;
}
callback(payload);
});
return unlisten;
},
async onDragEvent(event, callback) {
const handlers: Record<string, typeof onDragBegan> = {
began: onDragBegan,
moved: onDragMoved,
entered: onDragEntered,
left: onDragLeft,
ended: onDragEnded,
};
const handler = handlers[event];
if (!handler) {
throw new Error(`Unknown drag event: ${event}`);
}
const unlisten = await handler((payload: any) => {
if (event === "ended") {
_isDragging = false;
}
callback(payload);
});
return unlisten;
},
isDragging() {
return _isDragging;
},
isDragging() {
return _isDragging;
},
async registerKeybind(id, accelerator, handler) {
// Use the global handler if available (initialized in keybinds.ts)
if (window.__SPACEDRIVE__?.registerKeybind) {
await window.__SPACEDRIVE__.registerKeybind(id, accelerator, handler);
}
},
async registerKeybind(id, accelerator, handler) {
// Use the global handler if available (initialized in keybinds.ts)
if (window.__SPACEDRIVE__?.registerKeybind) {
await window.__SPACEDRIVE__.registerKeybind(id, accelerator, handler);
}
},
async unregisterKeybind(id) {
// Use the global handler if available (initialized in keybinds.ts)
if (window.__SPACEDRIVE__?.unregisterKeybind) {
await window.__SPACEDRIVE__.unregisterKeybind(id);
}
},
async unregisterKeybind(id) {
// Use the global handler if available (initialized in keybinds.ts)
if (window.__SPACEDRIVE__?.unregisterKeybind) {
await window.__SPACEDRIVE__.unregisterKeybind(id);
}
},
};

View File

@@ -1,155 +1,160 @@
import { ContextMenu } from "@sd/ui";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { ContextMenu } from "@sd/ui";
import { useEffect, useRef, useState } from "react";
export interface MenuItem {
type?: "separator";
icon?: React.ElementType;
label?: string;
onClick?: () => void;
keybind?: string;
variant?: "default" | "dull" | "danger";
disabled?: boolean;
submenu?: MenuItem[];
type?: "separator";
icon?: React.ElementType;
label?: string;
onClick?: () => void;
keybind?: string;
variant?: "default" | "dull" | "danger";
disabled?: boolean;
submenu?: MenuItem[];
}
export interface ContextMenuData {
items: MenuItem[];
x: number;
y: number;
items: MenuItem[];
x: number;
y: number;
}
export function ContextMenuWindow() {
const [items, setItems] = useState<MenuItem[]>([]);
const [contextId, setContextId] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const window = getCurrentWebviewWindow();
const [items, setItems] = useState<MenuItem[]>([]);
const [contextId, setContextId] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const window = getCurrentWebviewWindow();
useEffect(() => {
console.log('[ContextMenuWindow] Component mounted');
console.log('[ContextMenuWindow] Window location:', window.location.href);
useEffect(() => {
console.log("[ContextMenuWindow] Component mounted");
console.log("[ContextMenuWindow] Window location:", window.location.href);
// Extract context ID from URL params
const params = new URLSearchParams(window.location.search);
const id = params.get("context");
console.log('[ContextMenuWindow] Context ID from params:', id);
console.log('[ContextMenuWindow] All params:', Array.from(params.entries()));
setContextId(id);
// Extract context ID from URL params
const params = new URLSearchParams(window.location.search);
const id = params.get("context");
console.log("[ContextMenuWindow] Context ID from params:", id);
console.log(
"[ContextMenuWindow] All params:",
Array.from(params.entries())
);
setContextId(id);
if (!id) {
console.error("[ContextMenuWindow] No context ID provided");
return;
}
if (!id) {
console.error("[ContextMenuWindow] No context ID provided");
return;
}
// Listen for menu data event
const setupMenu = async () => {
console.log('[ContextMenuWindow] Setting up menu listener...');
const { listen } = await import("@tauri-apps/api/event");
// Listen for menu data event
const setupMenu = async () => {
console.log("[ContextMenuWindow] Setting up menu listener...");
const { listen } = await import("@tauri-apps/api/event");
const eventName = `context-menu-data-${id}`;
console.log('[ContextMenuWindow] Listening for event:', eventName);
const eventName = `context-menu-data-${id}`;
console.log("[ContextMenuWindow] Listening for event:", eventName);
const unlisten = await listen<ContextMenuData>(
eventName,
(event) => {
console.log('[ContextMenuWindow] Received menu data:', event.payload);
const data = event.payload;
setItems(data.items);
const unlisten = await listen<ContextMenuData>(eventName, (event) => {
console.log("[ContextMenuWindow] Received menu data:", event.payload);
const data = event.payload;
setItems(data.items);
// Measure actual size and adjust window after render
requestAnimationFrame(() => {
if (menuRef.current) {
const { width, height } = menuRef.current.getBoundingClientRect();
console.log('[ContextMenuWindow] Positioning menu:', { width, height, x: data.x, y: data.y });
// Measure actual size and adjust window after render
requestAnimationFrame(() => {
if (menuRef.current) {
const { width, height } = menuRef.current.getBoundingClientRect();
console.log("[ContextMenuWindow] Positioning menu:", {
width,
height,
x: data.x,
y: data.y,
});
// Position the menu at the cursor
invoke("position_context_menu", {
label: window.label,
x: data.x,
y: data.y,
menuWidth: width,
menuHeight: height,
}).catch(console.error);
}
});
}
);
// Position the menu at the cursor
invoke("position_context_menu", {
label: window.label,
x: data.x,
y: data.y,
menuWidth: width,
menuHeight: height,
}).catch(console.error);
}
});
});
console.log('[ContextMenuWindow] Listener set up successfully');
return unlisten;
};
console.log("[ContextMenuWindow] Listener set up successfully");
return unlisten;
};
setupMenu();
setupMenu();
// Close on blur (when clicking outside)
const handleBlur = async () => {
invoke("close_window", { label: window.label }).catch(console.error);
};
// Close on blur (when clicking outside)
const handleBlur = async () => {
invoke("close_window", { label: window.label }).catch(console.error);
};
window.listen("tauri://blur", handleBlur);
window.listen("tauri://blur", handleBlur);
return () => {
// Cleanup handled by Tauri
};
}, []);
return () => {
// Cleanup handled by Tauri
};
}, []);
const handleItemClick = (item: MenuItem) => {
if (item.onClick && !item.disabled) {
item.onClick();
}
// Close menu after click
invoke("close_window", { label: window.label }).catch(console.error);
};
const handleItemClick = (item: MenuItem) => {
if (item.onClick && !item.disabled) {
item.onClick();
}
// Close menu after click
invoke("close_window", { label: window.label }).catch(console.error);
};
const renderItem = (item: MenuItem, index: number) => {
if (item.type === "separator") {
return <ContextMenu.Separator key={index} />;
}
const renderItem = (item: MenuItem, index: number) => {
if (item.type === "separator") {
return <ContextMenu.Separator key={index} />;
}
if (item.submenu) {
return (
<ContextMenu.SubMenu
key={index}
label={item.label || ""}
icon={item.icon}
variant={item.variant}
>
{item.submenu.map((sub, subIndex) => renderItem(sub, subIndex))}
</ContextMenu.SubMenu>
);
}
if (item.submenu) {
return (
<ContextMenu.SubMenu
icon={item.icon}
key={index}
label={item.label || ""}
variant={item.variant}
>
{item.submenu.map((sub, subIndex) => renderItem(sub, subIndex))}
</ContextMenu.SubMenu>
);
}
return (
<ContextMenu.Item
key={index}
icon={item.icon}
label={item.label}
keybind={item.keybind}
variant={item.variant}
disabled={item.disabled}
onClick={() => handleItemClick(item)}
/>
);
};
return (
<ContextMenu.Item
disabled={item.disabled}
icon={item.icon}
key={index}
keybind={item.keybind}
label={item.label}
onClick={() => handleItemClick(item)}
variant={item.variant}
/>
);
};
// Don't render until we have items
if (items.length === 0) {
return null;
}
// Don't render until we have items
if (items.length === 0) {
return null;
}
return (
<div
ref={menuRef}
className="p-1"
style={{
// Ensure transparent background shows through
background: "transparent",
}}
>
<div className="bg-menu/95 backdrop-blur-lg border border-menu-line rounded-lg shadow-2xl overflow-hidden">
{items.map((item, index) => renderItem(item, index))}
</div>
</div>
);
return (
<div
className="p-1"
ref={menuRef}
style={{
// Ensure transparent background shows through
background: "transparent",
}}
>
<div className="overflow-hidden rounded-lg border border-menu-line bg-menu/95 shadow-2xl backdrop-blur-lg">
{items.map((item, index) => renderItem(item, index))}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { getDragSession, onDragMoved, type DragSession } from '../lib/drag';
import { useEffect, useState } from "react";
import { type DragSession, getDragSession, onDragMoved } from "../lib/drag";
export function DragOverlay() {
const [session, setSession] = useState<DragSession | null>(null);
@@ -8,7 +8,7 @@ export function DragOverlay() {
useEffect(() => {
// Get the session from query params
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session');
const sessionId = params.get("session");
if (sessionId) {
getDragSession().then((s) => setSession(s));
@@ -30,18 +30,18 @@ export function DragOverlay() {
const itemCount = session.config.items.length;
return (
<div className="flex items-center justify-center w-full h-full bg-transparent select-none">
<div className="bg-blue-600 text-white px-4 py-3 rounded-lg shadow-2xl border-2 border-blue-400">
<div className="flex h-full w-full select-none items-center justify-center bg-transparent">
<div className="rounded-lg border-2 border-blue-400 bg-blue-600 px-4 py-3 text-white shadow-2xl">
<div className="flex items-center gap-3">
<div className="text-2xl"></div>
<div className="text-2xl" />
<div>
<div className="font-semibold text-sm">
{itemCount} {itemCount === 1 ? 'file' : 'files'}
{itemCount} {itemCount === 1 ? "file" : "files"}
</div>
<div className="text-xs text-blue-100 opacity-80">
{session.config.items[0]?.kind.type === 'file'
? session.config.items[0].kind.path.split('/').pop()
: 'Dragging...'}
<div className="text-blue-100 text-xs opacity-80">
{session.config.items[0]?.kind.type === "file"
? session.config.items[0].kind.path.split("/").pop()
: "Dragging..."}
</div>
</div>
</div>

View File

@@ -1,20 +1,20 @@
import { Spacedrop } from '@sd/interface';
import { Spacedrop } from "@sd/interface";
const samplePeople = [
{ id: '1', name: 'Jamie', initials: 'JP', status: 'online' as const },
{ id: '2', name: 'Alex', initials: 'AB', status: 'online' as const },
{ id: '3', name: 'Sam', initials: 'SC', status: 'offline' as const },
{ id: '4', name: 'Morgan', initials: 'MJ', status: 'online' as const },
{ id: '5', name: 'Taylor', initials: 'TW', status: 'online' as const },
{ id: '6', name: 'Jordan', initials: 'JK', status: 'offline' as const },
{ id: '7', name: 'Casey', initials: 'CD', status: 'online' as const },
{ id: '8', name: 'Riley', initials: 'RM', status: 'online' as const }
{ id: "1", name: "Jamie", initials: "JP", status: "online" as const },
{ id: "2", name: "Alex", initials: "AB", status: "online" as const },
{ id: "3", name: "Sam", initials: "SC", status: "offline" as const },
{ id: "4", name: "Morgan", initials: "MJ", status: "online" as const },
{ id: "5", name: "Taylor", initials: "TW", status: "online" as const },
{ id: "6", name: "Jordan", initials: "JK", status: "offline" as const },
{ id: "7", name: "Casey", initials: "CD", status: "online" as const },
{ id: "8", name: "Riley", initials: "RM", status: "online" as const },
];
export function SpacedropWindow() {
return (
<div className="h-screen w-screen bg-sidebar">
<Spacedrop people={samplePeople} onClose={() => window.close()} />
</div>
);
return (
<div className="h-screen w-screen bg-sidebar">
<Spacedrop onClose={() => window.close()} people={samplePeople} />
</div>
);
}

View File

@@ -1,4 +1,5 @@
const config = require('@sd/ui/tailwind');
"use strict";
const config = require("@sd/ui/tailwind");
/** @type {import('tailwindcss').Config} */
module.exports = config('tauri');
module.exports = config("tauri");

View File

@@ -1,30 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
/* Path aliases */
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -1,11 +1,11 @@
{
"compilerOptions": {
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
"compilerOptions": {
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,47 +1,41 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { defineConfig } from "vite";
const COMMANDS = ["initialize_core", "core_rpc", "subscribe_events"];
export default defineConfig(async () => ({
plugins: [react()],
plugins: [react()],
css: {
postcss: "./postcss.config.cjs",
},
css: {
postcss: "./postcss.config.cjs",
},
resolve: {
alias: {
"@sd/interface": path.resolve(
__dirname,
"../../packages/interface/src",
),
"@sd/ts-client": path.resolve(
__dirname,
"../../packages/ts-client/src",
),
"@sd/ui/style": path.resolve(__dirname, "../../packages/ui/style"),
"@sd/ui": path.resolve(__dirname, "../../packages/ui/src"),
},
},
resolve: {
alias: {
"@sd/interface": path.resolve(__dirname, "../../packages/interface/src"),
"@sd/ts-client": path.resolve(__dirname, "../../packages/ts-client/src"),
"@sd/ui/style": path.resolve(__dirname, "../../packages/ui/style"),
"@sd/ui": path.resolve(__dirname, "../../packages/ui/src"),
},
},
optimizeDeps: {
include: ["rooks"],
},
optimizeDeps: {
include: ["rooks"],
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ["**/src-tauri/**"],
},
},
envPrefix: ["VITE_", "TAURI_ENV_*"],
build: {
target: ["es2021", "chrome100", "safari13"],
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ["**/src-tauri/**"],
},
},
envPrefix: ["VITE_", "TAURI_ENV_*"],
build: {
target: ["es2021", "chrome100", "safari13"],
minify: process.env.TAURI_ENV_DEBUG ? false : "esbuild",
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
}));

Some files were not shown because too many files have changed in this diff Show More