mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-02-20 07:37:26 -05:00
lint
This commit is contained in:
123
.claude/CLAUDE.md
Normal file
123
.claude/CLAUDE.md
Normal 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
10
.cursor/hooks.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"afterFileEdit": [
|
||||
{
|
||||
"command": "bun x ultracite fix"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
43
.github/actions/publish-artifacts/.eslintrc.cjs
vendored
43
.github/actions/publish-artifacts/.eslintrc.cjs
vendored
@@ -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/**/*"],
|
||||
};
|
||||
|
||||
125
.github/actions/publish-artifacts/index.ts
vendored
125
.github/actions/publish-artifacts/index.ts
vendored
@@ -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);
|
||||
});
|
||||
|
||||
36
.github/actions/publish-artifacts/package.json
vendored
36
.github/actions/publish-artifacts/package.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
50
.github/actions/publish-artifacts/tsconfig.json
vendored
50
.github/actions/publish-artifacts/tsconfig.json
vendored
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
.vscode/extensions.json
vendored
16
.vscode/extensions.json
vendored
@@ -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
258
.vscode/launch.json
vendored
@@ -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
67
.vscode/settings.json
vendored
@@ -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
165
.vscode/tasks.json
vendored
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
"icon": "./icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"scheme": "spacedrive",
|
||||
"platforms": [
|
||||
"ios",
|
||||
"android"
|
||||
],
|
||||
"platforms": ["ios", "android"],
|
||||
"newArchEnabled": true,
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,4 +11,4 @@
|
||||
"version": 1,
|
||||
"author": "expo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "expo"
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "expo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@
|
||||
"version": 1,
|
||||
"author": "expo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
module.exports = {
|
||||
dependencies: {
|
||||
// Ensure DoubleConversion is properly linked
|
||||
},
|
||||
project: {
|
||||
ios: {},
|
||||
android: {},
|
||||
},
|
||||
dependencies: {
|
||||
// Ensure DoubleConversion is properly linked
|
||||
},
|
||||
project: {
|
||||
ios: {},
|
||||
android: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { BrowseScreen } from '../../../screens/browse/BrowseScreen';
|
||||
import { BrowseScreen } from "../../../screens/browse/BrowseScreen";
|
||||
|
||||
export default BrowseScreen;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { OverviewScreen } from '../../../screens/overview/OverviewScreen';
|
||||
import { OverviewScreen } from "../../../screens/overview/OverviewScreen";
|
||||
|
||||
export default OverviewScreen;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { SettingsScreen } from '../../../screens/settings/SettingsScreen';
|
||||
import { SettingsScreen } from "../../../screens/settings/SettingsScreen";
|
||||
|
||||
export default SettingsScreen;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function DrawerLayout() {
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user