Compare commits
44
Commits
fd13edae6d
...
4ec19dbd70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec19dbd70 | ||
|
|
f8ab674b84 | ||
|
|
866a31b89d | ||
|
|
4f296ac1e3 | ||
|
|
d6a6d1233c | ||
|
|
8f86a71c39 | ||
|
|
1cddc3c373 | ||
|
|
bd2669af4d | ||
|
|
65495a07b2 | ||
|
|
a30b6eaa02 | ||
|
|
9ddaa59155 | ||
|
|
5bf64fc5f3 | ||
|
|
824db2087c | ||
|
|
1135873968 | ||
|
|
e7e7881772 | ||
|
|
f7b274c1ec | ||
|
|
b561f46d2c | ||
|
|
b9c95437d2 | ||
|
|
7d8595b63f | ||
|
|
32d627fd6d | ||
|
|
09430c6f40 | ||
|
|
49998fa23d | ||
|
|
14077d26d7 | ||
|
|
40832ff1c3 | ||
|
|
0b8f856876 | ||
|
|
16ac16cd30 | ||
|
|
326a6042ec | ||
|
|
8efe2d19ba | ||
|
|
1c5b920e13 | ||
|
|
00f5d3adea | ||
|
|
f6ceff5444 | ||
|
|
9559a1b5e2 | ||
|
|
e752361f39 | ||
|
|
41c9ae30e9 | ||
|
|
03bc311403 | ||
|
|
366f716b1c | ||
|
|
3731fe8e40 | ||
|
|
3c416600a6 | ||
|
|
6f6ee59461 | ||
|
|
9aed7ec524 | ||
|
|
816c880e8e | ||
|
|
816910ca40 | ||
|
|
6eb3f8a3d9 | ||
|
|
303ece0529 |
@@ -28,6 +28,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Derive repository metadata
|
||||||
|
id: repo_meta
|
||||||
|
run: |
|
||||||
|
repo="${GITHUB_REPOSITORY:-$GITEA_REPOSITORY}"
|
||||||
|
owner="${repo%%/*}"
|
||||||
|
name="${repo##*/}"
|
||||||
|
echo "repo_owner=$owner" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "repo_name=$name" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Compute base tag
|
- name: Compute base tag
|
||||||
id: compute_tag
|
id: compute_tag
|
||||||
run: |
|
run: |
|
||||||
@@ -80,7 +89,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
provenance: false
|
provenance: false
|
||||||
tags: |
|
tags: |
|
||||||
${{ vars.REGISTRY_URL }}/${{ gitea.repository }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
|
${{ vars.REGISTRY_URL }}/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
|
||||||
${{ vars.REGISTRY_URL }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.sha }}
|
${{ vars.REGISTRY_URL }}/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ gitea.sha }}
|
||||||
ghcr.io/${{ vars.GHCR_USERNAME }}/${{ gitea.repository }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
|
ghcr.io/paperless-dms/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
|
||||||
ghcr.io/${{ vars.GHCR_USERNAME }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.sha }}
|
ghcr.io/paperless-dms/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
name: Build & Publish Images
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, dev ]
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_BACKEND: papercrate-dms/papercrate-backend
|
|
||||||
IMAGE_FRONTEND: papercrate-dms/papercrate-frontend
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
component: [backend, frontend]
|
|
||||||
platform: [linux/amd64, linux/arm64]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set component metadata
|
|
||||||
run: |
|
|
||||||
if [ "${{ matrix.component }}" = "backend" ]; then
|
|
||||||
echo "COMPONENT_CONTEXT=./backend" >> "$GITHUB_ENV"
|
|
||||||
echo "COMPONENT_IMAGE=${{ env.IMAGE_BACKEND }}" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "COMPONENT_CONTEXT=./frontend" >> "$GITHUB_ENV"
|
|
||||||
echo "COMPONENT_IMAGE=${{ env.IMAGE_FRONTEND }}" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
echo "COMPONENT_NAME=${{ matrix.component }}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Compute tags
|
|
||||||
env:
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
GITHUB_REF: ${{ github.ref }}
|
|
||||||
REGISTRY: ${{ env.REGISTRY }}
|
|
||||||
PLATFORM: ${{ matrix.platform }}
|
|
||||||
run: |
|
|
||||||
ARCH=${PLATFORM#*/}
|
|
||||||
TAGS=("${GITHUB_SHA::7}")
|
|
||||||
|
|
||||||
BRANCH=${GITHUB_REF##*/}
|
|
||||||
if [ "$BRANCH" = "dev" ]; then
|
|
||||||
TAGS+=("${GITHUB_SHA::7}-dev")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
|
||||||
TAGS+=("${GITHUB_REF#refs/tags/}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
UNIQUE=()
|
|
||||||
for tag in "${TAGS[@]}"; do
|
|
||||||
[[ -z "$tag" ]] && continue
|
|
||||||
skip=false
|
|
||||||
for seen in "${UNIQUE[@]}"; do
|
|
||||||
if [ "$tag" = "$seen" ]; then
|
|
||||||
skip=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$skip" = false ]; then
|
|
||||||
UNIQUE+=("$tag")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "BASE_TAGS<<EOF"
|
|
||||||
for tag in "${UNIQUE[@]}"; do
|
|
||||||
printf '%s\n' "$tag"
|
|
||||||
done
|
|
||||||
echo "EOF"
|
|
||||||
echo "IMAGE_TAGS<<EOF"
|
|
||||||
for tag in "${UNIQUE[@]}"; do
|
|
||||||
printf '%s\n' "$REGISTRY/$COMPONENT_IMAGE:${tag}-${ARCH}"
|
|
||||||
done
|
|
||||||
echo "EOF"
|
|
||||||
echo "ARCH=$ARCH"
|
|
||||||
} >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build & push ${{ env.COMPONENT_NAME }} (${{ matrix.platform }})
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: ${{ env.COMPONENT_CONTEXT }}
|
|
||||||
push: true
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
tags: ${{ env.IMAGE_TAGS }}
|
|
||||||
|
|
||||||
manifest:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
component: [backend, frontend]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
ARCHES: amd64 arm64
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Set component metadata
|
|
||||||
run: |
|
|
||||||
if [ "${{ matrix.component }}" = "backend" ]; then
|
|
||||||
echo "COMPONENT_IMAGE=${{ env.IMAGE_BACKEND }}" >> "$GITHUB_ENV"
|
|
||||||
else
|
|
||||||
echo "COMPONENT_IMAGE=${{ env.IMAGE_FRONTEND }}" >> "$GITHUB_ENV"
|
|
||||||
fi
|
|
||||||
echo "COMPONENT_NAME=${{ matrix.component }}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Compute base tags
|
|
||||||
env:
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
GITHUB_REF: ${{ github.ref }}
|
|
||||||
run: |
|
|
||||||
TAGS=("${GITHUB_SHA::7}")
|
|
||||||
|
|
||||||
BRANCH=${GITHUB_REF##*/}
|
|
||||||
if [ "$BRANCH" = "dev" ]; then
|
|
||||||
TAGS+=("${GITHUB_SHA::7}-dev")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
|
||||||
TAGS+=("${GITHUB_REF#refs/tags/}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
UNIQUE=()
|
|
||||||
for tag in "${TAGS[@]}"; do
|
|
||||||
[[ -z "$tag" ]] && continue
|
|
||||||
skip=false
|
|
||||||
for seen in "${UNIQUE[@]}"; do
|
|
||||||
if [ "$tag" = "$seen" ]; then
|
|
||||||
skip=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$skip" = false ]; then
|
|
||||||
UNIQUE+=("$tag")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "BASE_TAGS<<EOF"
|
|
||||||
for tag in "${UNIQUE[@]}"; do
|
|
||||||
printf '%s\n' "$tag"
|
|
||||||
done
|
|
||||||
echo "EOF"
|
|
||||||
} >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Create manifests for ${{ env.COMPONENT_NAME }}
|
|
||||||
run: |
|
|
||||||
while IFS= read -r tag; do
|
|
||||||
[ -n "$tag" ] || continue
|
|
||||||
args=()
|
|
||||||
for arch in $ARCHES; do
|
|
||||||
args+=("$REGISTRY/$COMPONENT_IMAGE:${tag}-${arch}")
|
|
||||||
done
|
|
||||||
docker buildx imagetools create \
|
|
||||||
--tag "$REGISTRY/$COMPONENT_IMAGE:$tag" \
|
|
||||||
"${args[@]}"
|
|
||||||
done <<< "$BASE_TAGS"
|
|
||||||
@@ -36,6 +36,12 @@ SPA frontend. Once the containers report healthy, visit `http://<host>:8080`
|
|||||||
and use the passkey signup flow to provision the first tenant/user. Upgrades are
|
and use the passkey signup flow to provision the first tenant/user. Upgrades are
|
||||||
as simple as `git pull` followed by `docker compose up -d`.
|
as simple as `git pull` followed by `docker compose up -d`.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
For development workflows (local stack, integration tests, migrations, and
|
For development workflows (local stack, integration tests, migrations, and
|
||||||
|
|||||||
Generated
+17
@@ -1959,6 +1959,7 @@ checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder-lite",
|
"byteorder-lite",
|
||||||
|
"image-webp",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png",
|
||||||
@@ -1966,6 +1967,16 @@ dependencies = [
|
|||||||
"zune-jpeg",
|
"zune-jpeg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.11.4"
|
version = "2.11.4"
|
||||||
@@ -2695,6 +2706,12 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.32.0"
|
version = "0.32.0"
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ sha2 = "0.10"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
bytes = "1.5"
|
bytes = "1.5"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
pdfium-render = "0.8"
|
pdfium-render = "0.8"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
tempfile = "3.10"
|
tempfile = "3.10"
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
DROP POLICY IF EXISTS tenant_api_token_policy ON tenant.api_tokens;
|
||||||
|
DROP FUNCTION IF EXISTS shared.current_api_token_prefix();
|
||||||
|
|
||||||
|
ALTER TABLE tenant.api_tokens DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tenant.api_tokens NO FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
ALTER TABLE tenant.api_tokens
|
||||||
|
DROP COLUMN IF EXISTS capabilities;
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS shared.api_token_capability;
|
||||||
|
|
||||||
|
ALTER TABLE tenant.api_tokens RENAME TO webdav_tokens;
|
||||||
|
ALTER INDEX tenant.api_tokens_token_prefix_key RENAME TO webdav_tokens_token_prefix_key;
|
||||||
|
ALTER INDEX tenant.api_tokens_user_tenant_idx RENAME TO webdav_tokens_user_tenant_idx;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$
|
||||||
|
SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '')
|
||||||
|
$$ LANGUAGE SQL STABLE;
|
||||||
|
|
||||||
|
ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens
|
||||||
|
USING (
|
||||||
|
tenant_id = shared.current_tenant_id()
|
||||||
|
OR (
|
||||||
|
shared.current_webdav_token_prefix() IS NOT NULL
|
||||||
|
AND token_prefix = shared.current_webdav_token_prefix()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (tenant_id = shared.current_tenant_id());
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
ALTER TABLE tenant.webdav_tokens RENAME TO api_tokens;
|
||||||
|
ALTER INDEX tenant.webdav_tokens_token_prefix_key RENAME TO api_tokens_token_prefix_key;
|
||||||
|
ALTER INDEX tenant.webdav_tokens_user_tenant_idx RENAME TO api_tokens_user_tenant_idx;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.api_tokens;
|
||||||
|
DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix();
|
||||||
|
|
||||||
|
CREATE TYPE shared.api_token_capability AS ENUM ('api', 'webdav');
|
||||||
|
|
||||||
|
ALTER TABLE tenant.api_tokens
|
||||||
|
ADD COLUMN capabilities shared.api_token_capability[] NOT NULL DEFAULT ARRAY['webdav']::shared.api_token_capability[];
|
||||||
|
|
||||||
|
ALTER TABLE tenant.api_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tenant.api_tokens FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shared.current_api_token_prefix() RETURNS text AS $$
|
||||||
|
SELECT NULLIF(current_setting('papercrate.api_token_prefix', true), '')
|
||||||
|
$$ LANGUAGE SQL STABLE;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_api_token_policy ON tenant.api_tokens
|
||||||
|
USING (
|
||||||
|
tenant_id = shared.current_tenant_id()
|
||||||
|
OR (
|
||||||
|
shared.current_api_token_prefix() IS NOT NULL
|
||||||
|
AND token_prefix = shared.current_api_token_prefix()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (tenant_id = shared.current_tenant_id());
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$
|
||||||
|
SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '')
|
||||||
|
$$ LANGUAGE SQL STABLE;
|
||||||
|
|
||||||
|
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
||||||
|
USING (
|
||||||
|
tenant_id = shared.current_tenant_id()
|
||||||
|
OR (
|
||||||
|
shared.current_refresh_token_hash() IS NOT NULL
|
||||||
|
AND token_hash = shared.current_refresh_token_hash()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
||||||
|
WITH CHECK (tenant_id = shared.current_tenant_id());
|
||||||
|
|
||||||
|
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
||||||
|
RENAME TO tenant_refresh_token_policy;
|
||||||
|
|
||||||
|
ALTER INDEX tenant.idx_user_sessions_user_id RENAME TO idx_refresh_tokens_user_id;
|
||||||
|
ALTER INDEX tenant.idx_user_sessions_token_hash RENAME TO idx_refresh_tokens_token_hash;
|
||||||
|
ALTER INDEX tenant.user_sessions_tenant_id_idx RENAME TO refresh_tokens_tenant_id_idx;
|
||||||
|
|
||||||
|
ALTER TABLE tenant.user_sessions RENAME TO refresh_tokens;
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS shared.current_user_session_hash();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
ALTER TABLE tenant.refresh_tokens RENAME TO user_sessions;
|
||||||
|
|
||||||
|
ALTER INDEX tenant.idx_refresh_tokens_user_id RENAME TO idx_user_sessions_user_id;
|
||||||
|
ALTER INDEX tenant.idx_refresh_tokens_token_hash RENAME TO idx_user_sessions_token_hash;
|
||||||
|
ALTER INDEX tenant.refresh_tokens_tenant_id_idx RENAME TO user_sessions_tenant_id_idx;
|
||||||
|
|
||||||
|
ALTER POLICY tenant_refresh_token_policy ON tenant.user_sessions
|
||||||
|
RENAME TO tenant_user_session_policy;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shared.current_user_session_hash() RETURNS text AS $$
|
||||||
|
SELECT NULLIF(current_setting('papercrate.user_session_hash', true), '')
|
||||||
|
$$ LANGUAGE SQL STABLE;
|
||||||
|
|
||||||
|
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
||||||
|
USING (
|
||||||
|
tenant_id = shared.current_tenant_id()
|
||||||
|
OR (
|
||||||
|
shared.current_user_session_hash() IS NOT NULL
|
||||||
|
AND token_hash = shared.current_user_session_hash()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER POLICY tenant_user_session_policy ON tenant.user_sessions
|
||||||
|
WITH CHECK (tenant_id = shared.current_tenant_id());
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS shared.current_refresh_token_hash();
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
use argon2::{
|
||||||
|
password_hash::{PasswordHasher, SaltString},
|
||||||
|
Argon2,
|
||||||
|
};
|
||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::AppError,
|
||||||
|
models::{ApiToken, ApiTokenCapability, NewApiToken},
|
||||||
|
schema::api_tokens,
|
||||||
|
state::PgPooledConnection,
|
||||||
|
tenants::{apply_api_token_prefix, clear_api_token_prefix},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::schema::api_tokens::dsl as api_tokens_dsl;
|
||||||
|
|
||||||
|
const TOKEN_PREFIX_LENGTH: usize = 12;
|
||||||
|
const TOKEN_SECRET_LENGTH: usize = 32;
|
||||||
|
|
||||||
|
/// Represents a newly issued API token and the raw secret that was generated for it.
|
||||||
|
pub struct IssuedApiToken {
|
||||||
|
pub token: String,
|
||||||
|
pub record: ApiToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new API token for the supplied user/tenant combination.
|
||||||
|
pub fn create_api_token(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
label: Option<String>,
|
||||||
|
expires_at: Option<NaiveDateTime>,
|
||||||
|
capabilities: Vec<ApiTokenCapability>,
|
||||||
|
) -> Result<IssuedApiToken, AppError> {
|
||||||
|
let capabilities = normalize_capabilities(capabilities)?;
|
||||||
|
|
||||||
|
let raw_secret = generate_secret()?;
|
||||||
|
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
||||||
|
let token_hash = hash_secret(&raw_secret)?;
|
||||||
|
let new_token = NewApiToken {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
user_id,
|
||||||
|
tenant_id,
|
||||||
|
token_prefix,
|
||||||
|
token_hash,
|
||||||
|
label,
|
||||||
|
expires_at,
|
||||||
|
capabilities,
|
||||||
|
};
|
||||||
|
|
||||||
|
let record = diesel::insert_into(api_tokens::table)
|
||||||
|
.values(&new_token)
|
||||||
|
.get_result::<ApiToken>(conn)?;
|
||||||
|
|
||||||
|
Ok(IssuedApiToken {
|
||||||
|
token: raw_secret,
|
||||||
|
record,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists API tokens belonging to a user within an optional tenant scope.
|
||||||
|
pub fn list_api_tokens(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Option<Uuid>,
|
||||||
|
) -> Result<Vec<ApiToken>, AppError> {
|
||||||
|
let mut query = api_tokens::table
|
||||||
|
.filter(api_tokens::user_id.eq(user_id))
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
if let Some(tenant_id) = tenant_id {
|
||||||
|
query = query.filter(api_tokens::tenant_id.eq(tenant_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens = query
|
||||||
|
.order(api_tokens::created_at.asc())
|
||||||
|
.load::<ApiToken>(conn)?;
|
||||||
|
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regenerates the secret value for an API token.
|
||||||
|
pub fn regenerate_api_token(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
token_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Option<Uuid>,
|
||||||
|
) -> Result<IssuedApiToken, AppError> {
|
||||||
|
let record = find_user_token(conn, token_id, user_id, tenant_id)?;
|
||||||
|
|
||||||
|
if record.revoked_at.is_some() {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"cannot regenerate a revoked API token",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_secret = generate_secret()?;
|
||||||
|
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
||||||
|
let token_hash = hash_secret(&raw_secret)?;
|
||||||
|
|
||||||
|
let updated = diesel::update(api_tokens::table.find(record.id))
|
||||||
|
.set((
|
||||||
|
api_tokens::token_prefix.eq(&token_prefix),
|
||||||
|
api_tokens::token_hash.eq(&token_hash),
|
||||||
|
api_tokens::last_used_at.eq::<Option<NaiveDateTime>>(None),
|
||||||
|
))
|
||||||
|
.get_result::<ApiToken>(conn)?;
|
||||||
|
|
||||||
|
Ok(IssuedApiToken {
|
||||||
|
token: raw_secret,
|
||||||
|
record: updated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the set of capabilities associated with an API token.
|
||||||
|
pub fn update_api_token_capabilities(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
token_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Option<Uuid>,
|
||||||
|
capabilities: Vec<ApiTokenCapability>,
|
||||||
|
) -> Result<ApiToken, AppError> {
|
||||||
|
let capabilities = normalize_capabilities(capabilities)?;
|
||||||
|
|
||||||
|
let token = find_user_token(conn, token_id, user_id, tenant_id)?;
|
||||||
|
|
||||||
|
if token.revoked_at.is_some() {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"cannot modify capabilities of a revoked API token",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = diesel::update(api_tokens::table.find(token.id))
|
||||||
|
.set(api_tokens::capabilities.eq(capabilities))
|
||||||
|
.get_result::<ApiToken>(conn)?;
|
||||||
|
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to resolve an API token by its secret value while ensuring it provides the
|
||||||
|
/// requested capability.
|
||||||
|
pub fn find_active_token_by_secret(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
tenant_id: Option<Uuid>,
|
||||||
|
secret: &str,
|
||||||
|
required_capability: ApiTokenCapability,
|
||||||
|
) -> Result<Option<ApiToken>, AppError> {
|
||||||
|
if secret.len() < TOKEN_PREFIX_LENGTH {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
|
||||||
|
let candidates = with_api_token_prefix(conn, prefix, |conn| {
|
||||||
|
let mut query = api_tokens::table
|
||||||
|
.filter(api_tokens::token_prefix.eq(prefix))
|
||||||
|
.filter(api_tokens::revoked_at.is_null())
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
query = query.filter(
|
||||||
|
api_tokens::expires_at
|
||||||
|
.is_null()
|
||||||
|
.or(api_tokens::expires_at.gt(now)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(tenant_id) = tenant_id {
|
||||||
|
query = query.filter(api_tokens::tenant_id.eq(tenant_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
query.load::<ApiToken>(conn).map_err(AppError::from)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for token in candidates {
|
||||||
|
if !token.capabilities.contains(&required_capability) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if verify_token_secret(secret, &token.token_hash)? {
|
||||||
|
return Ok(Some(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes an API token belonging to the specified user.
|
||||||
|
pub fn revoke_api_token(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
token_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let token = find_user_token(conn, token_id, user_id, None)?;
|
||||||
|
|
||||||
|
diesel::update(api_tokens::table.find(token.id))
|
||||||
|
.set(api_tokens::revoked_at.eq(Utc::now().naive_utc()))
|
||||||
|
.execute(conn)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the last-used timestamp for a token.
|
||||||
|
pub fn touch_api_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
|
||||||
|
diesel::update(api_tokens::table.filter(api_tokens::id.eq(token_id)))
|
||||||
|
.set(api_tokens::last_used_at.eq(Utc::now().naive_utc()))
|
||||||
|
.execute(conn)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies a secret against its stored hash representation.
|
||||||
|
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
||||||
|
crate::auth::password::verify_password(secret, token_hash).map_err(|err| {
|
||||||
|
tracing::error!(error = ?err, "failed to verify token");
|
||||||
|
AppError::internal("failed to verify token")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_capabilities(
|
||||||
|
capabilities: Vec<ApiTokenCapability>,
|
||||||
|
) -> Result<Vec<ApiTokenCapability>, AppError> {
|
||||||
|
if capabilities.is_empty() {
|
||||||
|
return Err(AppError::bad_request("at least one capability is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut unique = Vec::new();
|
||||||
|
for capability in capabilities {
|
||||||
|
if !unique.contains(&capability) {
|
||||||
|
unique.push(capability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(unique)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_user_token(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
token_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
tenant_id: Option<Uuid>,
|
||||||
|
) -> Result<ApiToken, AppError> {
|
||||||
|
let mut query = api_tokens_dsl::api_tokens
|
||||||
|
.filter(api_tokens_dsl::id.eq(token_id))
|
||||||
|
.filter(api_tokens_dsl::user_id.eq(user_id))
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
if let Some(tid) = tenant_id {
|
||||||
|
query = query.filter(api_tokens_dsl::tenant_id.eq(tid));
|
||||||
|
}
|
||||||
|
|
||||||
|
query
|
||||||
|
.first::<ApiToken>(conn)
|
||||||
|
.optional()
|
||||||
|
.map_err(AppError::from)?
|
||||||
|
.ok_or_else(AppError::not_found)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_api_token_prefix<T, F>(
|
||||||
|
conn: &mut PgPooledConnection,
|
||||||
|
prefix: &str,
|
||||||
|
operation: F,
|
||||||
|
) -> Result<T, AppError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut PgPooledConnection) -> Result<T, AppError>,
|
||||||
|
{
|
||||||
|
apply_api_token_prefix(conn, prefix)?;
|
||||||
|
let operation_result = operation(conn);
|
||||||
|
let clear_result = clear_api_token_prefix(conn);
|
||||||
|
|
||||||
|
if let Err(err) = clear_result {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
operation_result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_secret() -> Result<String, AppError> {
|
||||||
|
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
||||||
|
OsRng.try_fill_bytes(&mut buffer).map_err(|err| {
|
||||||
|
tracing::error!(error = ?err, "failed to generate token");
|
||||||
|
AppError::internal("failed to generate token")
|
||||||
|
})?;
|
||||||
|
Ok(hex::encode(buffer))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_secret(secret: &str) -> Result<String, AppError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(secret.as_bytes(), &salt)
|
||||||
|
.map_err(|err| {
|
||||||
|
tracing::error!(error = ?err, "failed to hash token");
|
||||||
|
AppError::internal("failed to hash token")
|
||||||
|
})?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_secret_has_expected_length() {
|
||||||
|
let secret = generate_secret().unwrap();
|
||||||
|
assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_and_verify_secret_round_trip() {
|
||||||
|
let secret = generate_secret().unwrap();
|
||||||
|
let hash = hash_secret(&secret).unwrap();
|
||||||
|
assert!(verify_token_secret(&secret, &hash).unwrap());
|
||||||
|
assert!(!verify_token_secret("wrong", &hash).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_capabilities_deduplicates() {
|
||||||
|
let caps = normalize_capabilities(vec![
|
||||||
|
ApiTokenCapability::Api,
|
||||||
|
ApiTokenCapability::Webdav,
|
||||||
|
ApiTokenCapability::Api,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(caps.len(), 2);
|
||||||
|
assert!(caps.contains(&ApiTokenCapability::Api));
|
||||||
|
assert!(caps.contains(&ApiTokenCapability::Webdav));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_capabilities_rejects_empty() {
|
||||||
|
assert!(normalize_capabilities(Vec::new()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefix_length_is_less_than_secret_length() {
|
||||||
|
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
pub mod api_tokens;
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
pub mod passkeys;
|
pub mod passkeys;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
pub mod webdav_tokens;
|
|
||||||
|
|
||||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||||
use axum_extra::headers::{authorization::Bearer, Authorization};
|
use axum_extra::headers::{authorization::Bearer, Authorization};
|
||||||
|
|||||||
@@ -1,240 +0,0 @@
|
|||||||
use argon2::{
|
|
||||||
password_hash::{PasswordHasher, SaltString},
|
|
||||||
Argon2,
|
|
||||||
};
|
|
||||||
use chrono::{NaiveDateTime, Utc};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
use rand::RngCore;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
error::AppError,
|
|
||||||
models::{NewWebdavToken, WebdavToken},
|
|
||||||
schema::webdav_tokens,
|
|
||||||
state::PgPooledConnection,
|
|
||||||
tenants::{apply_webdav_token_prefix, clear_webdav_token_prefix},
|
|
||||||
};
|
|
||||||
|
|
||||||
const TOKEN_PREFIX_LENGTH: usize = 12;
|
|
||||||
const TOKEN_SECRET_LENGTH: usize = 32;
|
|
||||||
|
|
||||||
pub struct IssuedWebdavToken {
|
|
||||||
pub token: String,
|
|
||||||
pub record: WebdavToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_webdav_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Uuid,
|
|
||||||
label: Option<String>,
|
|
||||||
expires_at: Option<NaiveDateTime>,
|
|
||||||
) -> Result<IssuedWebdavToken, AppError> {
|
|
||||||
let raw_secret = generate_secret()?;
|
|
||||||
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
|
||||||
let token_hash = hash_secret(&raw_secret)?;
|
|
||||||
let new_token = NewWebdavToken {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
user_id,
|
|
||||||
tenant_id,
|
|
||||||
token_prefix,
|
|
||||||
token_hash,
|
|
||||||
label,
|
|
||||||
expires_at,
|
|
||||||
};
|
|
||||||
|
|
||||||
let record = diesel::insert_into(webdav_tokens::table)
|
|
||||||
.values(&new_token)
|
|
||||||
.get_result::<WebdavToken>(conn)?;
|
|
||||||
|
|
||||||
Ok(IssuedWebdavToken {
|
|
||||||
token: raw_secret,
|
|
||||||
record,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_webdav_tokens(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
) -> Result<Vec<WebdavToken>, AppError> {
|
|
||||||
let mut query = webdav_tokens::table
|
|
||||||
.filter(webdav_tokens::user_id.eq(user_id))
|
|
||||||
.into_boxed();
|
|
||||||
|
|
||||||
if let Some(tenant_id) = tenant_id {
|
|
||||||
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
let tokens = query
|
|
||||||
.order(webdav_tokens::created_at.asc())
|
|
||||||
.load::<WebdavToken>(conn)?;
|
|
||||||
|
|
||||||
Ok(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn regenerate_webdav_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
token_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
) -> Result<IssuedWebdavToken, AppError> {
|
|
||||||
let mut query = webdav_tokens::table
|
|
||||||
.filter(webdav_tokens::id.eq(token_id))
|
|
||||||
.filter(webdav_tokens::user_id.eq(user_id))
|
|
||||||
.into_boxed();
|
|
||||||
|
|
||||||
if let Some(tenant) = tenant_id {
|
|
||||||
query = query.filter(webdav_tokens::tenant_id.eq(tenant));
|
|
||||||
}
|
|
||||||
|
|
||||||
let record = query
|
|
||||||
.first::<WebdavToken>(conn)
|
|
||||||
.optional()
|
|
||||||
.map_err(AppError::from)?
|
|
||||||
.ok_or_else(AppError::not_found)?;
|
|
||||||
|
|
||||||
if record.revoked_at.is_some() {
|
|
||||||
return Err(AppError::bad_request(
|
|
||||||
"cannot regenerate a revoked WebDAV token",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw_secret = generate_secret()?;
|
|
||||||
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
|
|
||||||
let token_hash = hash_secret(&raw_secret)?;
|
|
||||||
|
|
||||||
let updated = diesel::update(webdav_tokens::table.find(record.id))
|
|
||||||
.set((
|
|
||||||
webdav_tokens::token_prefix.eq(&token_prefix),
|
|
||||||
webdav_tokens::token_hash.eq(&token_hash),
|
|
||||||
webdav_tokens::last_used_at.eq::<Option<NaiveDateTime>>(None),
|
|
||||||
))
|
|
||||||
.get_result::<WebdavToken>(conn)?;
|
|
||||||
|
|
||||||
Ok(IssuedWebdavToken {
|
|
||||||
token: raw_secret,
|
|
||||||
record: updated,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_active_token_by_secret(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
tenant_id: Option<Uuid>,
|
|
||||||
secret: &str,
|
|
||||||
) -> Result<Option<WebdavToken>, AppError> {
|
|
||||||
if secret.len() < TOKEN_PREFIX_LENGTH {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
|
|
||||||
apply_webdav_token_prefix(conn, prefix)?;
|
|
||||||
let mut query = webdav_tokens::table
|
|
||||||
.filter(webdav_tokens::user_id.eq(user_id))
|
|
||||||
.filter(webdav_tokens::token_prefix.eq(prefix))
|
|
||||||
.filter(webdav_tokens::revoked_at.is_null())
|
|
||||||
.into_boxed();
|
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
query = query.filter(
|
|
||||||
webdav_tokens::expires_at
|
|
||||||
.is_null()
|
|
||||||
.or(webdav_tokens::expires_at.gt(now)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(tenant_id) = tenant_id {
|
|
||||||
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
let load_result = query.load::<WebdavToken>(conn);
|
|
||||||
let clear_result = clear_webdav_token_prefix(conn);
|
|
||||||
clear_result?;
|
|
||||||
let candidates = load_result?;
|
|
||||||
|
|
||||||
for token in candidates {
|
|
||||||
if verify_token_secret(secret, &token.token_hash)? {
|
|
||||||
return Ok(Some(token));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn revoke_webdav_token(
|
|
||||||
conn: &mut PgPooledConnection,
|
|
||||||
token_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let affected = diesel::update(
|
|
||||||
webdav_tokens::table
|
|
||||||
.filter(webdav_tokens::id.eq(token_id))
|
|
||||||
.filter(webdav_tokens::user_id.eq(user_id)),
|
|
||||||
)
|
|
||||||
.set(webdav_tokens::revoked_at.eq(Utc::now().naive_utc()))
|
|
||||||
.execute(conn)?;
|
|
||||||
|
|
||||||
if affected == 0 {
|
|
||||||
return Err(AppError::not_found());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
|
|
||||||
diesel::update(webdav_tokens::table.filter(webdav_tokens::id.eq(token_id)))
|
|
||||||
.set(webdav_tokens::last_used_at.eq(Utc::now().naive_utc()))
|
|
||||||
.execute(conn)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
|
|
||||||
crate::auth::password::verify_password(secret, token_hash).map_err(|err| {
|
|
||||||
tracing::error!(error = ?err, "failed to verify token");
|
|
||||||
AppError::internal("failed to verify token")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_secret() -> Result<String, AppError> {
|
|
||||||
let mut buffer = [0u8; TOKEN_SECRET_LENGTH];
|
|
||||||
OsRng.try_fill_bytes(&mut buffer).map_err(|err| {
|
|
||||||
tracing::error!(error = ?err, "failed to generate token");
|
|
||||||
AppError::internal("failed to generate token")
|
|
||||||
})?;
|
|
||||||
Ok(hex::encode(buffer))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_secret(secret: &str) -> Result<String, AppError> {
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
|
||||||
let hash = Argon2::default()
|
|
||||||
.hash_password(secret.as_bytes(), &salt)
|
|
||||||
.map_err(|err| {
|
|
||||||
tracing::error!(error = ?err, "failed to hash token");
|
|
||||||
AppError::internal("failed to hash token")
|
|
||||||
})?;
|
|
||||||
Ok(hash.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn _ensure_constants() {
|
|
||||||
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generated_secret_has_expected_length() {
|
|
||||||
let secret = generate_secret().unwrap();
|
|
||||||
assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hash_and_verify_secret_round_trip() {
|
|
||||||
let secret = generate_secret().unwrap();
|
|
||||||
let hash = hash_secret(&secret).unwrap();
|
|
||||||
assert!(verify_token_secret(&secret, &hash).unwrap());
|
|
||||||
assert!(!verify_token_secret("wrong", &hash).unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
|
||||||
pub type AppResult<T> = Result<T, AppError>;
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
@@ -13,6 +14,7 @@ pub struct AppError {
|
|||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
message: String,
|
message: String,
|
||||||
code: Option<String>,
|
code: Option<String>,
|
||||||
|
details: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppError {
|
impl AppError {
|
||||||
@@ -21,6 +23,7 @@ impl AppError {
|
|||||||
status,
|
status,
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
code: None,
|
code: None,
|
||||||
|
details: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +51,11 @@ impl AppError {
|
|||||||
self.code = Some(code.into());
|
self.code = Some(code.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_details(mut self, details: Value) -> Self {
|
||||||
|
self.details = Some(details);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
@@ -56,6 +64,7 @@ impl IntoResponse for AppError {
|
|||||||
let body = Json(ErrorResponse {
|
let body = Json(ErrorResponse {
|
||||||
error: self.message,
|
error: self.message,
|
||||||
code: self.code,
|
code: self.code,
|
||||||
|
details: self.details,
|
||||||
});
|
});
|
||||||
(status, body).into_response()
|
(status, body).into_response()
|
||||||
}
|
}
|
||||||
@@ -66,6 +75,8 @@ struct ErrorResponse {
|
|||||||
error: String,
|
error: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
code: Option<String>,
|
code: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
details: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<diesel::result::Error> for AppError {
|
impl From<diesel::result::Error> for AppError {
|
||||||
|
|||||||
+76
-9
@@ -4,14 +4,18 @@ use diesel::pg::{Pg, PgValue};
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::serialize::{IsNull, Output, ToSql};
|
use diesel::serialize::{IsNull, Output, ToSql};
|
||||||
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
|
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::str;
|
use std::str;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::schema::sql_types::{
|
use crate::schema::sql_types::{
|
||||||
MagicTokenKind as MagicTokenKindSql, TenantStatus as TenantStatusSql,
|
ApiTokenCapability as ApiTokenCapabilitySql, MagicTokenKind as MagicTokenKindSql,
|
||||||
|
TenantStatus as TenantStatusSql,
|
||||||
};
|
};
|
||||||
use crate::schema::*;
|
use crate::schema::*;
|
||||||
|
|
||||||
@@ -52,6 +56,16 @@ pub enum MagicTokenKind {
|
|||||||
DemoLogin,
|
DemoLogin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow, Serialize, Deserialize, ToSchema,
|
||||||
|
)]
|
||||||
|
#[diesel(sql_type = ApiTokenCapabilitySql)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ApiTokenCapability {
|
||||||
|
Api,
|
||||||
|
Webdav,
|
||||||
|
}
|
||||||
|
|
||||||
impl MagicTokenKind {
|
impl MagicTokenKind {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -65,12 +79,31 @@ impl MagicTokenKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ApiTokenCapability {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ApiTokenCapability::Api => "api",
|
||||||
|
ApiTokenCapability::Webdav => "webdav",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn variants() -> &'static [&'static str] {
|
||||||
|
&["api", "webdav"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl fmt::Display for MagicTokenKind {
|
impl fmt::Display for MagicTokenKind {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}", self.as_str())
|
write!(f, "{}", self.as_str())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ApiTokenCapability {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
||||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
||||||
out.write_all(self.as_str().as_bytes())?;
|
out.write_all(self.as_str().as_bytes())?;
|
||||||
@@ -78,6 +111,13 @@ impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ToSql<ApiTokenCapabilitySql, Pg> for ApiTokenCapability {
|
||||||
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
||||||
|
out.write_all(self.as_str().as_bytes())?;
|
||||||
|
Ok(IsNull::No)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
||||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||||
match std::str::from_utf8(bytes.as_bytes())? {
|
match std::str::from_utf8(bytes.as_bytes())? {
|
||||||
@@ -91,6 +131,19 @@ impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FromSql<ApiTokenCapabilitySql, Pg> for ApiTokenCapability {
|
||||||
|
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||||
|
match std::str::from_utf8(bytes.as_bytes())? {
|
||||||
|
"api" => Ok(ApiTokenCapability::Api),
|
||||||
|
"webdav" => Ok(ApiTokenCapability::Webdav),
|
||||||
|
other => Err(Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("invalid api_token_capability '{other}'"),
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl str::FromStr for MagicTokenKind {
|
impl str::FromStr for MagicTokenKind {
|
||||||
type Err = &'static str;
|
type Err = &'static str;
|
||||||
|
|
||||||
@@ -103,6 +156,18 @@ impl str::FromStr for MagicTokenKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl str::FromStr for ApiTokenCapability {
|
||||||
|
type Err = &'static str;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"api" => Ok(ApiTokenCapability::Api),
|
||||||
|
"webdav" => Ok(ApiTokenCapability::Webdav),
|
||||||
|
_ => Err("unsupported api token capability"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TenantStatus {
|
impl TenantStatus {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -243,10 +308,10 @@ pub struct NewWebauthnChallenge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
#[diesel(table_name = webdav_tokens)]
|
#[diesel(table_name = api_tokens)]
|
||||||
#[diesel(belongs_to(User))]
|
#[diesel(belongs_to(User))]
|
||||||
#[diesel(belongs_to(Tenant))]
|
#[diesel(belongs_to(Tenant))]
|
||||||
pub struct WebdavToken {
|
pub struct ApiToken {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
@@ -257,11 +322,12 @@ pub struct WebdavToken {
|
|||||||
pub last_used_at: Option<NaiveDateTime>,
|
pub last_used_at: Option<NaiveDateTime>,
|
||||||
pub expires_at: Option<NaiveDateTime>,
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
pub revoked_at: Option<NaiveDateTime>,
|
pub revoked_at: Option<NaiveDateTime>,
|
||||||
|
pub capabilities: Vec<ApiTokenCapability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
#[diesel(table_name = webdav_tokens)]
|
#[diesel(table_name = api_tokens)]
|
||||||
pub struct NewWebdavToken {
|
pub struct NewApiToken {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
@@ -269,6 +335,7 @@ pub struct NewWebdavToken {
|
|||||||
pub token_hash: String,
|
pub token_hash: String,
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
pub expires_at: Option<NaiveDateTime>,
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
pub capabilities: Vec<ApiTokenCapability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
@@ -529,9 +596,9 @@ pub struct NewDocumentCorrespondent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
#[diesel(table_name = refresh_tokens)]
|
#[diesel(table_name = user_sessions)]
|
||||||
#[diesel(belongs_to(User))]
|
#[diesel(belongs_to(User))]
|
||||||
pub struct RefreshToken {
|
pub struct UserSession {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub token_hash: String,
|
pub token_hash: String,
|
||||||
@@ -544,8 +611,8 @@ pub struct RefreshToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
#[diesel(table_name = refresh_tokens)]
|
#[diesel(table_name = user_sessions)]
|
||||||
pub struct NewRefreshToken {
|
pub struct NewUserSession {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub token_hash: String,
|
pub token_hash: String,
|
||||||
|
|||||||
@@ -68,10 +68,11 @@ pub mod schemas {
|
|||||||
DocumentVersionDetailResponse, DocumentVersionResponse,
|
DocumentVersionDetailResponse, DocumentVersionResponse,
|
||||||
};
|
};
|
||||||
pub use crate::documents::correspondents::DocumentCorrespondentResponse;
|
pub use crate::documents::correspondents::DocumentCorrespondentResponse;
|
||||||
|
pub use crate::models::ApiTokenCapability;
|
||||||
pub use crate::routes::auth::{
|
pub use crate::routes::auth::{
|
||||||
LoginRequest, LoginResponse, LoginResponseVariants, SignupFinishRequest,
|
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
|
||||||
SignupStartRequest, SignupStartResponse, TenantListResponse, TenantSelectionRequest,
|
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
||||||
TenantSelectionResponse, TenantSnippet,
|
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
|
||||||
};
|
};
|
||||||
pub use crate::routes::correspondents::{
|
pub use crate::routes::correspondents::{
|
||||||
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
|
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
|
||||||
@@ -91,8 +92,8 @@ pub mod schemas {
|
|||||||
FolderInfo, FolderResponse, UpdateFolderRequest,
|
FolderInfo, FolderResponse, UpdateFolderRequest,
|
||||||
};
|
};
|
||||||
pub use crate::routes::profile::{
|
pub use crate::routes::profile::{
|
||||||
CreateWebdavTokenRequest, RevokePasskeyQuery, WebdavTokenCreatedResponse,
|
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery,
|
||||||
WebdavTokenResponse,
|
UpdateApiTokenCapabilitiesRequest,
|
||||||
};
|
};
|
||||||
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
|
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-50
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{
|
auth::{
|
||||||
|
api_tokens::{find_active_token_by_secret, touch_api_token},
|
||||||
passkeys::{
|
passkeys::{
|
||||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||||
@@ -26,23 +27,25 @@ use crate::{
|
|||||||
},
|
},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{
|
models::{
|
||||||
MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, TenantStatus, User,
|
ApiTokenCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus,
|
||||||
|
User, UserSession,
|
||||||
},
|
},
|
||||||
schema::{
|
schema::{
|
||||||
magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl,
|
magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl,
|
||||||
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl,
|
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, user_sessions,
|
||||||
|
users::dsl,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
tenants::{
|
tenants::{
|
||||||
apply_refresh_token_hash, apply_tenant_guc, apply_user_guc, clear_refresh_token_hash,
|
apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc,
|
||||||
clear_user_guc,
|
clear_user_session_hash,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::schema::refresh_tokens::dsl as refresh_dsl;
|
use crate::schema::user_sessions::dsl as session_dsl;
|
||||||
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||||
|
|
||||||
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
const SESSION_COOKIE_NAME: &str = "refresh_token";
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
@@ -59,6 +62,11 @@ pub struct LoginRequest {
|
|||||||
pub preferred_tenant_id: Option<Uuid>,
|
pub preferred_tenant_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct ApiTokenExchangeRequest {
|
||||||
|
pub api_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, ToSchema)]
|
#[derive(Deserialize, Serialize, ToSchema)]
|
||||||
pub struct LoginResponse {
|
pub struct LoginResponse {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
@@ -120,6 +128,7 @@ pub enum LoginResponseVariants {
|
|||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
login,
|
login,
|
||||||
|
api_token_exchange,
|
||||||
signup_start,
|
signup_start,
|
||||||
signup_finish,
|
signup_finish,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -134,6 +143,7 @@ pub enum LoginResponseVariants {
|
|||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
|
ApiTokenExchangeRequest,
|
||||||
SignupStartRequest,
|
SignupStartRequest,
|
||||||
SignupStartResponse,
|
SignupStartResponse,
|
||||||
SignupFinishRequest,
|
SignupFinishRequest,
|
||||||
@@ -150,6 +160,7 @@ pub enum LoginResponseVariants {
|
|||||||
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
||||||
crate::auth::passkeys::PasskeyLoginStartPayload,
|
crate::auth::passkeys::PasskeyLoginStartPayload,
|
||||||
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
||||||
|
crate::models::ApiTokenCapability,
|
||||||
))
|
))
|
||||||
)]
|
)]
|
||||||
pub struct AuthApiDoc;
|
pub struct AuthApiDoc;
|
||||||
@@ -201,6 +212,69 @@ pub async fn login(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/exchange-api-token",
|
||||||
|
request_body = ApiTokenExchangeRequest,
|
||||||
|
responses((status = 200, description = "Access token issued", body = LoginResponse)),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub async fn api_token_exchange(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ApiTokenExchangeRequest>,
|
||||||
|
) -> AppResult<Json<LoginResponse>> {
|
||||||
|
let secret = payload.api_token.trim();
|
||||||
|
if secret.is_empty() {
|
||||||
|
return Err(AppError::bad_request("api_token must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
|
let token = find_active_token_by_secret(&mut conn, None, secret, ApiTokenCapability::Api)?
|
||||||
|
.ok_or_else(AppError::unauthorized)?;
|
||||||
|
|
||||||
|
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
|
||||||
|
|
||||||
|
apply_user_guc(&mut conn, user.id)?;
|
||||||
|
let membership = memberships_dsl::user_memberships
|
||||||
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||||
|
.select(memberships_dsl::tenant_id)
|
||||||
|
.first::<Uuid>(&mut conn)
|
||||||
|
.optional()?;
|
||||||
|
clear_user_guc(&mut conn)?;
|
||||||
|
|
||||||
|
if membership.is_none() {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
||||||
|
touch_api_token(&mut conn, token.id)?;
|
||||||
|
|
||||||
|
let access_token = state
|
||||||
|
.jwt
|
||||||
|
.generate_token(user.id, token.tenant_id, &user.username)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let tenant_name: String = tenant_dsl::tenants
|
||||||
|
.find(token.tenant_id)
|
||||||
|
.select(tenant_dsl::name)
|
||||||
|
.first(&mut conn)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let response = LoginResponse {
|
||||||
|
access_token,
|
||||||
|
token_type: "Bearer".to_string(),
|
||||||
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||||
|
tenant: TenantSnippet {
|
||||||
|
id: token.tenant_id,
|
||||||
|
name: tenant_name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/auth/signup/start",
|
path = "/api/auth/signup/start",
|
||||||
@@ -332,34 +406,34 @@ pub async fn refresh(
|
|||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
||||||
let refresh_value = cookies
|
let refresh_value = cookies
|
||||||
.get(REFRESH_COOKIE_NAME)
|
.get(SESSION_COOKIE_NAME)
|
||||||
.ok_or_else(AppError::unauthorized)?;
|
.ok_or_else(AppError::unauthorized)?;
|
||||||
|
|
||||||
let hashed = hash_refresh_token(refresh_value);
|
let hashed = hash_session_token(refresh_value);
|
||||||
let mut conn = state.db_unscoped()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let now_naive = now.naive_utc();
|
let now_naive = now.naive_utc();
|
||||||
|
|
||||||
apply_refresh_token_hash(&mut conn, &hashed)?;
|
apply_user_session_hash(&mut conn, &hashed)?;
|
||||||
let token = match refresh_dsl::refresh_tokens
|
let token = match session_dsl::user_sessions
|
||||||
.filter(refresh_dsl::token_hash.eq(&hashed))
|
.filter(session_dsl::token_hash.eq(&hashed))
|
||||||
.filter(refresh_dsl::revoked_at.is_null())
|
.filter(session_dsl::revoked_at.is_null())
|
||||||
.filter(refresh_dsl::expires_at.gt(now_naive))
|
.filter(session_dsl::expires_at.gt(now_naive))
|
||||||
.first::<RefreshToken>(&mut conn)
|
.first::<UserSession>(&mut conn)
|
||||||
{
|
{
|
||||||
Ok(token) => token,
|
Ok(token) => token,
|
||||||
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
|
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
|
||||||
Err(err) => return Err(AppError::from(err)),
|
Err(err) => return Err(AppError::from(err)),
|
||||||
};
|
};
|
||||||
|
|
||||||
clear_refresh_token_hash(&mut conn)?;
|
clear_user_session_hash(&mut conn)?;
|
||||||
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
||||||
clear_user_guc(&mut conn)?;
|
clear_user_guc(&mut conn)?;
|
||||||
|
|
||||||
diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id)))
|
diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id)))
|
||||||
.set((
|
.set((
|
||||||
refresh_dsl::revoked_at.eq(now_naive),
|
session_dsl::revoked_at.eq(now_naive),
|
||||||
refresh_dsl::updated_at.eq(now_naive),
|
session_dsl::updated_at.eq(now_naive),
|
||||||
))
|
))
|
||||||
.execute(&mut conn)?;
|
.execute(&mut conn)?;
|
||||||
|
|
||||||
@@ -445,17 +519,17 @@ pub async fn logout(
|
|||||||
let mut rows_affected = 0;
|
let mut rows_affected = 0;
|
||||||
|
|
||||||
if let Some(cookies) = jar {
|
if let Some(cookies) = jar {
|
||||||
if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) {
|
if let Some(value) = cookies.get(SESSION_COOKIE_NAME) {
|
||||||
let hashed = hash_refresh_token(value);
|
let hashed = hash_session_token(value);
|
||||||
rows_affected = diesel::update(
|
rows_affected = diesel::update(
|
||||||
refresh_dsl::refresh_tokens
|
session_dsl::user_sessions
|
||||||
.filter(refresh_dsl::token_hash.eq(hashed))
|
.filter(session_dsl::token_hash.eq(hashed))
|
||||||
.filter(refresh_dsl::user_id.eq(user.user_id))
|
.filter(session_dsl::user_id.eq(user.user_id))
|
||||||
.filter(refresh_dsl::revoked_at.is_null()),
|
.filter(session_dsl::revoked_at.is_null()),
|
||||||
)
|
)
|
||||||
.set((
|
.set((
|
||||||
refresh_dsl::revoked_at.eq(now),
|
session_dsl::revoked_at.eq(now),
|
||||||
refresh_dsl::updated_at.eq(now),
|
session_dsl::updated_at.eq(now),
|
||||||
))
|
))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
@@ -464,19 +538,19 @@ pub async fn logout(
|
|||||||
|
|
||||||
if rows_affected == 0 {
|
if rows_affected == 0 {
|
||||||
let _ = diesel::update(
|
let _ = diesel::update(
|
||||||
refresh_dsl::refresh_tokens
|
session_dsl::user_sessions
|
||||||
.filter(refresh_dsl::user_id.eq(user.user_id))
|
.filter(session_dsl::user_id.eq(user.user_id))
|
||||||
.filter(refresh_dsl::revoked_at.is_null()),
|
.filter(session_dsl::revoked_at.is_null()),
|
||||||
)
|
)
|
||||||
.set((
|
.set((
|
||||||
refresh_dsl::revoked_at.eq(now),
|
session_dsl::revoked_at.eq(now),
|
||||||
refresh_dsl::updated_at.eq(now),
|
session_dsl::updated_at.eq(now),
|
||||||
))
|
))
|
||||||
.execute(&mut conn);
|
.execute(&mut conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(SET_COOKIE, build_clear_refresh_cookie(&state));
|
headers.insert(SET_COOKIE, build_clear_session_cookie(&state));
|
||||||
Ok((headers, StatusCode::NO_CONTENT))
|
Ok((headers, StatusCode::NO_CONTENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,7 +854,7 @@ fn issue_session(
|
|||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
apply_tenant_guc(conn, tenant_id)?;
|
apply_tenant_guc(conn, tenant_id)?;
|
||||||
clear_user_guc(conn)?;
|
clear_user_guc(conn)?;
|
||||||
clear_refresh_token_hash(conn)?;
|
clear_user_session_hash(conn)?;
|
||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let access_token = state
|
let access_token = state
|
||||||
@@ -794,21 +868,21 @@ fn issue_session(
|
|||||||
.first(conn)
|
.first(conn)
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let refresh_value = generate_refresh_token();
|
let session_value = generate_session_token();
|
||||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
let session_hash = hash_session_token(&session_value);
|
||||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
let new_refresh = NewRefreshToken {
|
let new_session = NewUserSession {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash: refresh_hash,
|
token_hash: session_hash,
|
||||||
issued_at: now.naive_utc(),
|
issued_at: now.naive_utc(),
|
||||||
expires_at: refresh_expires_at.naive_utc(),
|
expires_at: refresh_expires_at.naive_utc(),
|
||||||
tenant_id,
|
tenant_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(refresh_tokens::table)
|
diesel::insert_into(user_sessions::table)
|
||||||
.values(&new_refresh)
|
.values(&new_session)
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
let mut response = Json(LoginResponse {
|
let mut response = Json(LoginResponse {
|
||||||
@@ -824,36 +898,36 @@ fn issue_session(
|
|||||||
|
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
SET_COOKIE,
|
SET_COOKIE,
|
||||||
build_refresh_cookie(state, &refresh_value, refresh_expires_at),
|
build_session_cookie(state, &session_value, refresh_expires_at),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_refresh_token(token: &str) -> String {
|
fn hash_session_token(token: &str) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(token.as_bytes());
|
hasher.update(token.as_bytes());
|
||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_magic_token(token: &str) -> String {
|
fn hash_magic_token(token: &str) -> String {
|
||||||
hash_refresh_token(token)
|
hash_session_token(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_refresh_token() -> String {
|
fn generate_session_token() -> String {
|
||||||
let mut bytes = [0u8; 32];
|
let mut bytes = [0u8; 32];
|
||||||
OsRng.fill_bytes(&mut bytes);
|
OsRng.fill_bytes(&mut bytes);
|
||||||
hex::encode(bytes)
|
hex::encode(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_refresh_cookie(
|
fn build_session_cookie(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
token: &str,
|
token: &str,
|
||||||
expires_at: chrono::DateTime<Utc>,
|
expires_at: chrono::DateTime<Utc>,
|
||||||
) -> HeaderValue {
|
) -> HeaderValue {
|
||||||
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
|
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
|
||||||
|
|
||||||
let mut parts = vec![format!("{}={}", REFRESH_COOKIE_NAME, token)];
|
let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token)];
|
||||||
parts.push("Path=/".into());
|
parts.push("Path=/".into());
|
||||||
parts.push("HttpOnly".into());
|
parts.push("HttpOnly".into());
|
||||||
parts.push("SameSite=Strict".into());
|
parts.push("SameSite=Strict".into());
|
||||||
@@ -866,11 +940,11 @@ fn build_refresh_cookie(
|
|||||||
parts.push(format!("Domain={}", domain));
|
parts.push(format!("Domain={}", domain));
|
||||||
}
|
}
|
||||||
|
|
||||||
HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
|
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue {
|
fn build_clear_session_cookie(state: &AppState) -> HeaderValue {
|
||||||
let mut parts = vec![format!("{}=", REFRESH_COOKIE_NAME)];
|
let mut parts = vec![format!("{}=", SESSION_COOKIE_NAME)];
|
||||||
parts.push("Path=/".into());
|
parts.push("Path=/".into());
|
||||||
parts.push("HttpOnly".into());
|
parts.push("HttpOnly".into());
|
||||||
parts.push("SameSite=Strict".into());
|
parts.push("SameSite=Strict".into());
|
||||||
@@ -883,5 +957,5 @@ fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue {
|
|||||||
parts.push(format!("Domain={}", domain));
|
parts.push(format!("Domain={}", domain));
|
||||||
}
|
}
|
||||||
|
|
||||||
HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
|
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ use crate::models::{
|
|||||||
};
|
};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
document_asset_objects, document_assets, document_correspondents, document_tags,
|
document_asset_objects, document_assets, document_correspondents, document_tags,
|
||||||
document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
|
document_versions, documents, folders, tags, user_sessions::dsl as session_dsl,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::utils::{
|
use crate::utils::{
|
||||||
@@ -303,7 +303,6 @@ struct UploadRequest {
|
|||||||
enum UploadOutcome {
|
enum UploadOutcome {
|
||||||
Created(DocumentDetailResponse),
|
Created(DocumentDetailResponse),
|
||||||
Reused(DocumentDetailResponse),
|
Reused(DocumentDetailResponse),
|
||||||
Skipped { document_id: Uuid },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ToSchema)]
|
#[derive(ToSchema)]
|
||||||
@@ -322,7 +321,7 @@ pub struct UploadDocumentForm {
|
|||||||
pub correspondents: Option<Vec<CorrespondentAssignmentInput>>,
|
pub correspondents: Option<Vec<CorrespondentAssignmentInput>>,
|
||||||
#[schema(nullable, example = "2024-01-01T00:00:00Z")]
|
#[schema(nullable, example = "2024-01-01T00:00:00Z")]
|
||||||
pub issued_at: Option<String>,
|
pub issued_at: Option<String>,
|
||||||
#[schema(nullable)]
|
#[schema(nullable, default = true)]
|
||||||
pub skip_existing: Option<bool>,
|
pub skip_existing: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +712,7 @@ pub async fn get_document(
|
|||||||
responses(
|
responses(
|
||||||
(status = 201, description = "Document created", body = DocumentDetailResponse),
|
(status = 201, description = "Document created", body = DocumentDetailResponse),
|
||||||
(status = 200, description = "Existing document reused", body = DocumentDetailResponse),
|
(status = 200, description = "Existing document reused", body = DocumentDetailResponse),
|
||||||
(status = 204, description = "Upload skipped because the document already exists")
|
(status = 409, description = "Document with identical contents already exists")
|
||||||
),
|
),
|
||||||
tag = "Documents"
|
tag = "Documents"
|
||||||
)]
|
)]
|
||||||
@@ -732,7 +731,7 @@ pub async fn upload_document(
|
|||||||
let mut tag_ids: Vec<Uuid> = Vec::new();
|
let mut tag_ids: Vec<Uuid> = Vec::new();
|
||||||
let mut correspondents: Vec<CorrespondentAssignmentInput> = Vec::new();
|
let mut correspondents: Vec<CorrespondentAssignmentInput> = Vec::new();
|
||||||
let mut issued_at_override: Option<NaiveDateTime> = None;
|
let mut issued_at_override: Option<NaiveDateTime> = None;
|
||||||
let mut skip_if_existing = false;
|
let mut skip_if_existing = true;
|
||||||
let mut title_override: Option<String> = None;
|
let mut title_override: Option<String> = None;
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await.map_err(|err| {
|
while let Some(field) = multipart.next_field().await.map_err(|err| {
|
||||||
@@ -913,10 +912,6 @@ pub async fn upload_document(
|
|||||||
);
|
);
|
||||||
(StatusCode::OK, Json(detail)).into_response()
|
(StatusCode::OK, Json(detail)).into_response()
|
||||||
}
|
}
|
||||||
UploadOutcome::Skipped { document_id } => {
|
|
||||||
info!(document_id = %document_id, "document upload skipped by client request");
|
|
||||||
StatusCode::NO_CONTENT.into_response()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
@@ -1113,10 +1108,13 @@ pub async fn get_document_asset(
|
|||||||
|
|
||||||
let mut object_responses = Vec::with_capacity(objects.len());
|
let mut object_responses = Vec::with_capacity(objects.len());
|
||||||
for object in objects {
|
for object in objects {
|
||||||
|
let response_disposition = presign_disposition_for_asset(&asset, &object);
|
||||||
|
|
||||||
let url = storage
|
let url = storage
|
||||||
.presign_get_object(
|
.presign_get_object(
|
||||||
&object.s3_key,
|
&object.s3_key,
|
||||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||||
|
response_disposition.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.storage_context("failed to generate asset URL")?;
|
.storage_context("failed to generate asset URL")?;
|
||||||
@@ -1135,6 +1133,14 @@ pub async fn get_document_asset(
|
|||||||
Ok(Json(to_asset_detail_response(asset, object_responses)))
|
Ok(Json(to_asset_detail_response(asset, object_responses)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn presign_disposition_for_asset(
|
||||||
|
asset: &DocumentAsset,
|
||||||
|
object: &DocumentAssetObject,
|
||||||
|
) -> Option<String> {
|
||||||
|
let filename = format!("{}-{}", asset.asset_type, object.ordinal);
|
||||||
|
inline_content_disposition(&filename)
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/documents/{id}/versions",
|
path = "/api/documents/{id}/versions",
|
||||||
@@ -1251,11 +1257,11 @@ pub async fn download_with_token(
|
|||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let has_active_refresh: bool = select(exists(
|
let has_active_refresh: bool = select(exists(
|
||||||
refresh_dsl::refresh_tokens
|
session_dsl::user_sessions
|
||||||
.filter(refresh_dsl::user_id.eq(claims.user_id))
|
.filter(session_dsl::user_id.eq(claims.user_id))
|
||||||
.filter(refresh_dsl::tenant_id.eq(claims.tenant_id))
|
.filter(session_dsl::tenant_id.eq(claims.tenant_id))
|
||||||
.filter(refresh_dsl::revoked_at.is_null())
|
.filter(session_dsl::revoked_at.is_null())
|
||||||
.filter(refresh_dsl::expires_at.gt(now)),
|
.filter(session_dsl::expires_at.gt(now)),
|
||||||
))
|
))
|
||||||
.get_result(&mut conn)?;
|
.get_result(&mut conn)?;
|
||||||
|
|
||||||
@@ -1267,10 +1273,13 @@ pub async fn download_with_token(
|
|||||||
|
|
||||||
let storage = state.storage_for_tenant(claims.tenant_id)?;
|
let storage = state.storage_for_tenant(claims.tenant_id)?;
|
||||||
|
|
||||||
|
let disposition = inline_content_disposition(&doc.filename);
|
||||||
|
|
||||||
let presigned_url = storage
|
let presigned_url = storage
|
||||||
.presign_get_object(
|
.presign_get_object(
|
||||||
&version.s3_key,
|
&version.s3_key,
|
||||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||||
|
disposition.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.storage_context("failed to generate download URL")?;
|
.storage_context("failed to generate download URL")?;
|
||||||
@@ -2126,11 +2135,15 @@ async fn process_upload(
|
|||||||
info!(
|
info!(
|
||||||
document_id = %document.id,
|
document_id = %document.id,
|
||||||
checksum = %checksum_hex,
|
checksum = %checksum_hex,
|
||||||
"upload skipped existing document due to skip flag",
|
"upload rejected because document already exists",
|
||||||
|
);
|
||||||
|
return Err(
|
||||||
|
AppError::conflict("a document with the same contents already exists")
|
||||||
|
.with_code("duplicate_document")
|
||||||
|
.with_details(json!({
|
||||||
|
"conflict_document_id": document.id,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
return Ok(UploadOutcome::Skipped {
|
|
||||||
document_id: document.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(issued_at) = issued_at_override {
|
if let Some(issued_at) = issued_at_override {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/signup/start", post(auth::signup_start))
|
.route("/signup/start", post(auth::signup_start))
|
||||||
.route("/signup/finish", post(auth::signup_finish))
|
.route("/signup/finish", post(auth::signup_finish))
|
||||||
.route("/login", post(auth::login))
|
.route("/login", post(auth::login))
|
||||||
|
.route("/exchange-api-token", post(auth::api_token_exchange))
|
||||||
.route("/refresh", post(auth::refresh))
|
.route("/refresh", post(auth::refresh))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
.route("/select-tenant", post(auth::select_tenant))
|
.route("/select-tenant", post(auth::select_tenant))
|
||||||
@@ -145,14 +146,17 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
|
|
||||||
let profile_routes = Router::new()
|
let profile_routes = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/webdav-tokens",
|
"/api-tokens",
|
||||||
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
get(profile::list_api_tokens).post(profile::create_api_token),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/webdav-tokens/:id/regenerate",
|
"/api-tokens/:id/regenerate",
|
||||||
post(profile::regenerate_webdav_token),
|
post(profile::regenerate_api_token),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api-tokens/:id",
|
||||||
|
patch(profile::update_api_token).delete(profile::delete_api_token),
|
||||||
)
|
)
|
||||||
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token))
|
|
||||||
.route("/passkeys", get(profile::list_passkeys))
|
.route("/passkeys", get(profile::list_passkeys))
|
||||||
.route("/passkeys/:id", delete(profile::delete_passkey));
|
.route("/passkeys/:id", delete(profile::delete_passkey));
|
||||||
|
|
||||||
|
|||||||
+110
-50
@@ -9,24 +9,26 @@ use utoipa::ToSchema;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
passkeys::PasskeySummary,
|
api_tokens::{
|
||||||
webdav_tokens::{
|
create_api_token as issue_token, list_api_tokens as load_tokens,
|
||||||
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
regenerate_api_token as rotate_token, revoke_api_token as revoke_token,
|
||||||
regenerate_webdav_token as rotate_token, revoke_webdav_token as revoke_token,
|
update_api_token_capabilities as update_capabilities,
|
||||||
},
|
},
|
||||||
|
passkeys::PasskeySummary,
|
||||||
TenantScopedConn,
|
TenantScopedConn,
|
||||||
};
|
};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::WebdavToken;
|
use crate::models::{ApiToken, ApiTokenCapability};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::utils::{db::no_content, time::to_iso};
|
use crate::utils::{db::no_content, time::to_iso};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
pub struct WebdavTokenResponse {
|
pub struct ApiTokenResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
|
pub capabilities: Vec<ApiTokenCapability>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub last_used_at: Option<String>,
|
pub last_used_at: Option<String>,
|
||||||
@@ -37,17 +39,25 @@ pub struct WebdavTokenResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
pub struct WebdavTokenCreatedResponse {
|
pub struct ApiTokenCreatedResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub token_info: WebdavTokenResponse,
|
pub token_info: ApiTokenResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
pub struct CreateWebdavTokenRequest {
|
pub struct CreateApiTokenRequest {
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub expires_at: Option<String>,
|
pub expires_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub capabilities: Option<Vec<ApiTokenCapability>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateApiTokenCapabilitiesRequest {
|
||||||
|
pub capabilities: Vec<ApiTokenCapability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
@@ -80,55 +90,60 @@ pub async fn list_passkeys(
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/profile/webdav-tokens",
|
path = "/api/profile/api-tokens",
|
||||||
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
|
responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])),
|
||||||
tag = "Profile"
|
tag = "Profile"
|
||||||
)]
|
)]
|
||||||
pub async fn list_webdav_tokens(
|
pub async fn list_api_tokens(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
user_id,
|
user_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
) -> AppResult<Json<Vec<WebdavTokenResponse>>> {
|
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
|
||||||
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
|
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
|
||||||
let responses = tokens.into_iter().map(webdav_token_to_response).collect();
|
let responses = tokens.into_iter().map(api_token_to_response).collect();
|
||||||
Ok(Json(responses))
|
Ok(Json(responses))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/profile/webdav-tokens",
|
path = "/api/profile/api-tokens",
|
||||||
request_body = CreateWebdavTokenRequest,
|
request_body = CreateApiTokenRequest,
|
||||||
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
|
responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)),
|
||||||
tag = "Profile"
|
tag = "Profile"
|
||||||
)]
|
)]
|
||||||
pub async fn create_webdav_token(
|
pub async fn create_api_token(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
user_id,
|
user_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateWebdavTokenRequest>,
|
Json(payload): Json<CreateApiTokenRequest>,
|
||||||
) -> AppResult<(StatusCode, Json<WebdavTokenCreatedResponse>)> {
|
) -> AppResult<(StatusCode, Json<ApiTokenCreatedResponse>)> {
|
||||||
let expires_at = match payload.expires_at {
|
let expires_at = match payload.expires_at {
|
||||||
Some(ref value) => Some(parse_timestamp(value)?),
|
Some(ref value) => Some(parse_timestamp(value)?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let capabilities = payload
|
||||||
|
.capabilities
|
||||||
|
.unwrap_or_else(|| vec![ApiTokenCapability::Webdav]);
|
||||||
|
|
||||||
let issued = issue_token(
|
let issued = issue_token(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
user_id,
|
user_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
payload.label.clone(),
|
payload.label.clone(),
|
||||||
expires_at,
|
expires_at,
|
||||||
|
capabilities,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let response = WebdavTokenCreatedResponse {
|
let response = ApiTokenCreatedResponse {
|
||||||
token: issued.token,
|
token: issued.token,
|
||||||
token_info: webdav_token_to_response(issued.record),
|
token_info: api_token_to_response(issued.record),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(response)))
|
Ok((StatusCode::CREATED, Json(response)))
|
||||||
@@ -136,12 +151,12 @@ pub async fn create_webdav_token(
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/profile/webdav-tokens/{id}/regenerate",
|
path = "/api/profile/api-tokens/{id}/regenerate",
|
||||||
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
params(("id" = Uuid, Path, description = "API token ID")),
|
||||||
responses((status = 200, description = "WebDAV token regenerated", body = WebdavTokenCreatedResponse)),
|
responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)),
|
||||||
tag = "Profile"
|
tag = "Profile"
|
||||||
)]
|
)]
|
||||||
pub async fn regenerate_webdav_token(
|
pub async fn regenerate_api_token(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -149,24 +164,53 @@ pub async fn regenerate_webdav_token(
|
|||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Path(token_id): Path<Uuid>,
|
Path(token_id): Path<Uuid>,
|
||||||
) -> AppResult<Json<WebdavTokenCreatedResponse>> {
|
) -> AppResult<Json<ApiTokenCreatedResponse>> {
|
||||||
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
|
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
|
||||||
let response = WebdavTokenCreatedResponse {
|
let response = ApiTokenCreatedResponse {
|
||||||
token: issued.token,
|
token: issued.token,
|
||||||
token_info: webdav_token_to_response(issued.record),
|
token_info: api_token_to_response(issued.record),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete,
|
patch,
|
||||||
path = "/api/profile/webdav-tokens/{id}",
|
path = "/api/profile/api-tokens/{id}",
|
||||||
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
params(("id" = Uuid, Path, description = "API token ID")),
|
||||||
responses((status = 204, description = "WebDAV token revoked")),
|
request_body = UpdateApiTokenCapabilitiesRequest,
|
||||||
|
responses((status = 200, description = "API token updated", body = ApiTokenResponse)),
|
||||||
tag = "Profile"
|
tag = "Profile"
|
||||||
)]
|
)]
|
||||||
pub async fn delete_webdav_token(
|
pub async fn update_api_token(
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
Path(token_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateApiTokenCapabilitiesRequest>,
|
||||||
|
) -> AppResult<Json<ApiTokenResponse>> {
|
||||||
|
let updated = update_capabilities(
|
||||||
|
&mut conn,
|
||||||
|
token_id,
|
||||||
|
user_id,
|
||||||
|
Some(tenant_id),
|
||||||
|
payload.capabilities,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Json(api_token_to_response(updated)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/profile/api-tokens/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "API token ID")),
|
||||||
|
responses((status = 204, description = "API token revoked")),
|
||||||
|
tag = "Profile"
|
||||||
|
)]
|
||||||
|
pub async fn delete_api_token(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn, user_id, ..
|
mut conn, user_id, ..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
@@ -210,15 +254,28 @@ pub async fn delete_passkey(
|
|||||||
no_content()
|
no_content()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
|
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
|
||||||
WebdavTokenResponse {
|
let ApiToken {
|
||||||
id: token.id,
|
id,
|
||||||
tenant_id: token.tenant_id,
|
tenant_id,
|
||||||
label: token.label,
|
label,
|
||||||
created_at: to_iso(token.created_at),
|
created_at,
|
||||||
last_used_at: token.last_used_at.map(to_iso),
|
last_used_at,
|
||||||
expires_at: token.expires_at.map(to_iso),
|
expires_at,
|
||||||
revoked_at: token.revoked_at.map(to_iso),
|
revoked_at,
|
||||||
|
capabilities,
|
||||||
|
..
|
||||||
|
} = token;
|
||||||
|
|
||||||
|
ApiTokenResponse {
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
label,
|
||||||
|
capabilities,
|
||||||
|
created_at: to_iso(created_at),
|
||||||
|
last_used_at: last_used_at.map(to_iso),
|
||||||
|
expires_at: expires_at.map(to_iso),
|
||||||
|
revoked_at: revoked_at.map(to_iso),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,17 +288,20 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
|||||||
#[derive(utoipa::OpenApi)]
|
#[derive(utoipa::OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
crate::routes::profile::list_webdav_tokens,
|
crate::routes::profile::list_api_tokens,
|
||||||
crate::routes::profile::create_webdav_token,
|
crate::routes::profile::create_api_token,
|
||||||
crate::routes::profile::regenerate_webdav_token,
|
crate::routes::profile::regenerate_api_token,
|
||||||
crate::routes::profile::delete_webdav_token,
|
crate::routes::profile::update_api_token,
|
||||||
|
crate::routes::profile::delete_api_token,
|
||||||
crate::routes::profile::list_passkeys,
|
crate::routes::profile::list_passkeys,
|
||||||
crate::routes::profile::delete_passkey
|
crate::routes::profile::delete_passkey
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
crate::routes::profile::WebdavTokenResponse,
|
crate::models::ApiTokenCapability,
|
||||||
crate::routes::profile::WebdavTokenCreatedResponse,
|
crate::routes::profile::ApiTokenResponse,
|
||||||
crate::routes::profile::CreateWebdavTokenRequest,
|
crate::routes::profile::ApiTokenCreatedResponse,
|
||||||
|
crate::routes::profile::CreateApiTokenRequest,
|
||||||
|
crate::routes::profile::UpdateApiTokenCapabilitiesRequest,
|
||||||
crate::routes::profile::RevokePasskeyQuery,
|
crate::routes::profile::RevokePasskeyQuery,
|
||||||
crate::auth::passkeys::PasskeySummary
|
crate::auth::passkeys::PasskeySummary
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
|||||||
use quick_xml::Writer;
|
use quick_xml::Writer;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::webdav_tokens::{find_active_token_by_secret, touch_webdav_token};
|
use crate::auth::api_tokens::{find_active_token_by_secret, touch_api_token};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::{Document, DocumentVersion, Folder, User};
|
use crate::models::{ApiTokenCapability, Document, DocumentVersion, Folder, User};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||||
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||||
@@ -320,6 +320,7 @@ async fn stream_document(
|
|||||||
.presign_get_object(
|
.presign_get_object(
|
||||||
&version.s3_key,
|
&version.s3_key,
|
||||||
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.storage_context("failed to presign document download")?;
|
.storage_context("failed to presign document download")?;
|
||||||
@@ -425,34 +426,40 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (username, secret) = match credential_str.split_once(':') {
|
let (presented_username, secret) = match credential_str.split_once(':') {
|
||||||
Some((username, secret)) if !username.is_empty() => (username, secret),
|
Some((username, secret)) if !username.is_empty() => (username, secret),
|
||||||
_ => return Ok(None),
|
_ => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::debug!(%username, "attempting webdav login");
|
tracing::debug!(presented_username = %presented_username, "attempting webdav login");
|
||||||
let mut conn = state.db_unscoped()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
let user: User = match users_dsl::users
|
let token = match find_active_token_by_secret(
|
||||||
.filter(users_dsl::username.eq(username))
|
&mut conn,
|
||||||
.first(&mut conn)
|
None,
|
||||||
{
|
secret,
|
||||||
|
ApiTokenCapability::Webdav,
|
||||||
|
)? {
|
||||||
|
Some(token) => token,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(presented_username = %presented_username, "webdav token invalid or expired");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user: User = match users_dsl::users.find(token.user_id).first(&mut conn) {
|
||||||
Ok(user) => user,
|
Ok(user) => user,
|
||||||
Err(diesel::result::Error::NotFound) => {
|
Err(diesel::result::Error::NotFound) => {
|
||||||
tracing::warn!(%username, "webdav user not found");
|
tracing::warn!(
|
||||||
|
presented_username = %presented_username,
|
||||||
|
user_id = %token.user_id,
|
||||||
|
"webdav token user missing"
|
||||||
|
);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Err(err) => return Err(AppError::from(err)),
|
Err(err) => return Err(AppError::from(err)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let token = match find_active_token_by_secret(&mut conn, user.id, None, secret)? {
|
|
||||||
Some(token) => token,
|
|
||||||
None => {
|
|
||||||
tracing::warn!(%username, "webdav token invalid or expired");
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
apply_user_guc(&mut conn, user.id)?;
|
apply_user_guc(&mut conn, user.id)?;
|
||||||
|
|
||||||
let membership_exists = memberships_dsl::user_memberships
|
let membership_exists = memberships_dsl::user_memberships
|
||||||
@@ -468,7 +475,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%username,
|
presented_username = %presented_username,
|
||||||
|
username = %user.username,
|
||||||
tenant_id = %token.tenant_id,
|
tenant_id = %token.tenant_id,
|
||||||
"webdav token tenant membership missing"
|
"webdav token tenant membership missing"
|
||||||
);
|
);
|
||||||
@@ -477,10 +485,11 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
};
|
};
|
||||||
|
|
||||||
apply_tenant_guc(&mut conn, tenant_id)?;
|
apply_tenant_guc(&mut conn, tenant_id)?;
|
||||||
touch_webdav_token(&mut conn, token.id)?;
|
touch_api_token(&mut conn, token.id)?;
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
%username,
|
presented_username = %presented_username,
|
||||||
|
username = %user.username,
|
||||||
tenant_id = %tenant_id,
|
tenant_id = %tenant_id,
|
||||||
token_id = %token.id,
|
token_id = %token.id,
|
||||||
"webdav token login success"
|
"webdav token login success"
|
||||||
|
|||||||
+16
-8
@@ -8,6 +8,10 @@ pub mod sql_types {
|
|||||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
#[diesel(postgres_type(name = "tenant_status"))]
|
#[diesel(postgres_type(name = "tenant_status"))]
|
||||||
pub struct TenantStatus;
|
pub struct TenantStatus;
|
||||||
|
|
||||||
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
|
#[diesel(postgres_type(name = "api_token_capability"))]
|
||||||
|
pub struct ApiTokenCapability;
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
@@ -151,7 +155,7 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
refresh_tokens (id) {
|
user_sessions (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
user_id -> Uuid,
|
user_id -> Uuid,
|
||||||
token_hash -> Text,
|
token_hash -> Text,
|
||||||
@@ -246,7 +250,10 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
webdav_tokens (id) {
|
use diesel::sql_types::*;
|
||||||
|
use super::sql_types::ApiTokenCapability;
|
||||||
|
|
||||||
|
api_tokens (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
user_id -> Uuid,
|
user_id -> Uuid,
|
||||||
tenant_id -> Uuid,
|
tenant_id -> Uuid,
|
||||||
@@ -257,6 +264,7 @@ diesel::table! {
|
|||||||
last_used_at -> Nullable<Timestamptz>,
|
last_used_at -> Nullable<Timestamptz>,
|
||||||
expires_at -> Nullable<Timestamptz>,
|
expires_at -> Nullable<Timestamptz>,
|
||||||
revoked_at -> Nullable<Timestamptz>,
|
revoked_at -> Nullable<Timestamptz>,
|
||||||
|
capabilities -> Array<ApiTokenCapability>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,15 +286,15 @@ diesel::joinable!(documents -> folders (folder_id));
|
|||||||
diesel::joinable!(documents -> tenants (tenant_id));
|
diesel::joinable!(documents -> tenants (tenant_id));
|
||||||
diesel::joinable!(folders -> tenants (tenant_id));
|
diesel::joinable!(folders -> tenants (tenant_id));
|
||||||
diesel::joinable!(jobs -> tenants (tenant_id));
|
diesel::joinable!(jobs -> tenants (tenant_id));
|
||||||
diesel::joinable!(refresh_tokens -> tenants (tenant_id));
|
diesel::joinable!(user_sessions -> tenants (tenant_id));
|
||||||
diesel::joinable!(refresh_tokens -> users (user_id));
|
diesel::joinable!(user_sessions -> users (user_id));
|
||||||
diesel::joinable!(tags -> tenants (tenant_id));
|
diesel::joinable!(tags -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||||
diesel::joinable!(user_memberships -> users (user_id));
|
diesel::joinable!(user_memberships -> users (user_id));
|
||||||
diesel::joinable!(user_passkeys -> users (user_id));
|
diesel::joinable!(user_passkeys -> users (user_id));
|
||||||
diesel::joinable!(webauthn_challenges -> users (user_id));
|
diesel::joinable!(webauthn_challenges -> users (user_id));
|
||||||
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
|
diesel::joinable!(api_tokens -> tenants (tenant_id));
|
||||||
diesel::joinable!(webdav_tokens -> users (user_id));
|
diesel::joinable!(api_tokens -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
correspondents,
|
correspondents,
|
||||||
@@ -299,12 +307,12 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
folders,
|
folders,
|
||||||
jobs,
|
jobs,
|
||||||
magic_tokens,
|
magic_tokens,
|
||||||
refresh_tokens,
|
user_sessions,
|
||||||
tags,
|
tags,
|
||||||
tenants,
|
tenants,
|
||||||
user_memberships,
|
user_memberships,
|
||||||
user_passkeys,
|
user_passkeys,
|
||||||
users,
|
users,
|
||||||
webauthn_challenges,
|
webauthn_challenges,
|
||||||
webdav_tokens,
|
api_tokens,
|
||||||
);
|
);
|
||||||
|
|||||||
+27
-9
@@ -20,7 +20,12 @@ pub trait ObjectStorage: Send + Sync + 'static {
|
|||||||
content_disposition: Option<String>,
|
content_disposition: Option<String>,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String>;
|
async fn presign_get_object(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
expires_in: Duration,
|
||||||
|
response_content_disposition: Option<&str>,
|
||||||
|
) -> Result<String>;
|
||||||
|
|
||||||
async fn get_object(&self, key: &str) -> Result<Vec<u8>>;
|
async fn get_object(&self, key: &str) -> Result<Vec<u8>>;
|
||||||
|
|
||||||
@@ -73,17 +78,23 @@ impl ObjectStorage for S3Storage {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
|
async fn presign_get_object(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
expires_in: Duration,
|
||||||
|
response_content_disposition: Option<&str>,
|
||||||
|
) -> Result<String> {
|
||||||
let presign_config = PresigningConfig::builder()
|
let presign_config = PresigningConfig::builder()
|
||||||
.expires_in(expires_in)
|
.expires_in(expires_in)
|
||||||
.build()
|
.build()
|
||||||
.context("failed to build S3 presigning config")?;
|
.context("failed to build S3 presigning config")?;
|
||||||
|
|
||||||
let presigned = self
|
let mut request = self.client.get_object().bucket(&self.bucket).key(key);
|
||||||
.client
|
if let Some(value) = response_content_disposition {
|
||||||
.get_object()
|
request = request.response_content_disposition(value);
|
||||||
.bucket(&self.bucket)
|
}
|
||||||
.key(key)
|
|
||||||
|
let presigned = request
|
||||||
.presigned(presign_config)
|
.presigned(presign_config)
|
||||||
.await
|
.await
|
||||||
.context("failed to generate presigned download URL")?;
|
.context("failed to generate presigned download URL")?;
|
||||||
@@ -157,9 +168,16 @@ impl TenantStorage {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
|
pub async fn presign_get_object(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
expires_in: Duration,
|
||||||
|
response_content_disposition: Option<&str>,
|
||||||
|
) -> Result<String> {
|
||||||
let qualified = self.qualify(key);
|
let qualified = self.qualify(key);
|
||||||
self.inner.presign_get_object(&qualified, expires_in).await
|
self.inner
|
||||||
|
.presign_get_object(&qualified, expires_in, response_content_disposition)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
|
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
|
||||||
|
|||||||
+10
-10
@@ -148,8 +148,8 @@ pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> {
|
|||||||
"SELECT \
|
"SELECT \
|
||||||
set_config('papercrate.tenant_id', '', false), \
|
set_config('papercrate.tenant_id', '', false), \
|
||||||
set_config('papercrate.user_id', '', false), \
|
set_config('papercrate.user_id', '', false), \
|
||||||
set_config('papercrate.refresh_token_hash', '', false), \
|
set_config('papercrate.user_session_hash', '', false), \
|
||||||
set_config('papercrate.webdav_token_prefix', '', false)",
|
set_config('papercrate.api_token_prefix', '', false)",
|
||||||
)
|
)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
@@ -163,31 +163,31 @@ pub fn clear_user_guc(conn: &mut PgConnection) -> AppResult<()> {
|
|||||||
.map_err(AppError::from)
|
.map_err(AppError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_refresh_token_hash(conn: &mut PgConnection, hash: &str) -> AppResult<()> {
|
pub fn apply_user_session_hash(conn: &mut PgConnection, hash: &str) -> AppResult<()> {
|
||||||
diesel::sql_query("SELECT set_config('papercrate.refresh_token_hash', $1, false)")
|
diesel::sql_query("SELECT set_config('papercrate.user_session_hash', $1, false)")
|
||||||
.bind::<Text, _>(hash)
|
.bind::<Text, _>(hash)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(AppError::from)
|
.map_err(AppError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_refresh_token_hash(conn: &mut PgConnection) -> AppResult<()> {
|
pub fn clear_user_session_hash(conn: &mut PgConnection) -> AppResult<()> {
|
||||||
diesel::sql_query("SELECT set_config('papercrate.refresh_token_hash', '', false)")
|
diesel::sql_query("SELECT set_config('papercrate.user_session_hash', '', false)")
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(AppError::from)
|
.map_err(AppError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_webdav_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> {
|
pub fn apply_api_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> {
|
||||||
diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', $1, false)")
|
diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', $1, false)")
|
||||||
.bind::<Text, _>(prefix)
|
.bind::<Text, _>(prefix)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(AppError::from)
|
.map_err(AppError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_webdav_token_prefix(conn: &mut PgConnection) -> AppResult<()> {
|
pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> {
|
||||||
diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', '', false)")
|
diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)")
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(AppError::from)
|
.map_err(AppError::from)
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
.put_object(
|
.put_object(
|
||||||
&s3_key,
|
&s3_key,
|
||||||
image.image_bytes.clone(),
|
image.image_bytes.clone(),
|
||||||
Some("image/png".into()),
|
Some("image/webp".into()),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -315,7 +315,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
.put_object(
|
.put_object(
|
||||||
&s3_key,
|
&s3_key,
|
||||||
image.image_bytes.clone(),
|
image.image_bytes.clone(),
|
||||||
Some("image/png".into()),
|
Some("image/webp".into()),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -609,7 +609,7 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, St
|
|||||||
let (width, height) = image.dimensions();
|
let (width, height) = image.dimensions();
|
||||||
let mut cursor = Cursor::new(Vec::new());
|
let mut cursor = Cursor::new(Vec::new());
|
||||||
image
|
image
|
||||||
.write_to(&mut cursor, ImageFormat::Png)
|
.write_to(&mut cursor, ImageFormat::WebP)
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
Ok(GeneratedImage {
|
Ok(GeneratedImage {
|
||||||
image_bytes: cursor.into_inner(),
|
image_bytes: cursor.into_inner(),
|
||||||
@@ -660,7 +660,7 @@ fn persist_assets_metadata(
|
|||||||
id: asset.asset_id,
|
id: asset.asset_id,
|
||||||
document_version_id: context.version.id,
|
document_version_id: context.version.id,
|
||||||
asset_type: asset.asset_type.to_string(),
|
asset_type: asset.asset_type.to_string(),
|
||||||
mime_type: "image/png".to_string(),
|
mime_type: "image/webp".to_string(),
|
||||||
metadata: json!({
|
metadata: json!({
|
||||||
"generated_at": Utc::now().to_rfc3339(),
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, Method, Request, StatusCode};
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use papercrate::models::{ApiToken, ApiTokenCapability};
|
||||||
|
use papercrate::routes::webdav;
|
||||||
|
use papercrate::schema::api_tokens;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TokenInfo {
|
||||||
|
id: Uuid,
|
||||||
|
label: Option<String>,
|
||||||
|
last_used_at: Option<String>,
|
||||||
|
revoked_at: Option<String>,
|
||||||
|
capabilities: Vec<ApiTokenCapability>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CreateTokenResponse {
|
||||||
|
token: String,
|
||||||
|
#[serde(rename = "token_info")]
|
||||||
|
info: TokenInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LoginResponseView {
|
||||||
|
access_token: String,
|
||||||
|
token_type: String,
|
||||||
|
expires_in: i64,
|
||||||
|
tenant: TenantView,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TenantView {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_token_crud_flow() -> Result<()> {
|
||||||
|
let _guard = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let username = "alice";
|
||||||
|
let password = "correct horse battery";
|
||||||
|
app.insert_user(username, password, "admin").await?;
|
||||||
|
let access_token = app.login_token(username, password).await?;
|
||||||
|
|
||||||
|
let created = create_token(&app, &access_token, json!({ "label": "dav" })).await?;
|
||||||
|
let token_id = created.info.id;
|
||||||
|
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
||||||
|
assert!(created.info.last_used_at.is_none());
|
||||||
|
assert_eq!(created.info.capabilities, vec![ApiTokenCapability::Webdav]);
|
||||||
|
|
||||||
|
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
|
||||||
|
assert_eq!(regenerated.info.id, token_id);
|
||||||
|
assert_ne!(regenerated.token, created.token);
|
||||||
|
assert!(regenerated.info.last_used_at.is_none());
|
||||||
|
|
||||||
|
let updated = update_token_capabilities(
|
||||||
|
&app,
|
||||||
|
&access_token,
|
||||||
|
token_id,
|
||||||
|
json!({ "capabilities": ["webdav", "api"] }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(updated.capabilities.len(), 2);
|
||||||
|
assert!(updated.capabilities.contains(&ApiTokenCapability::Webdav));
|
||||||
|
assert!(updated.capabilities.contains(&ApiTokenCapability::Api));
|
||||||
|
|
||||||
|
let listed = list_tokens(&app, &access_token).await?;
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].id, token_id);
|
||||||
|
|
||||||
|
let tenant_id_for_token = app
|
||||||
|
.with_conn(move |conn| {
|
||||||
|
let tenant_id = api_tokens::table
|
||||||
|
.find(token_id)
|
||||||
|
.select(api_tokens::tenant_id)
|
||||||
|
.first::<Uuid>(conn)?;
|
||||||
|
Ok::<_, anyhow::Error>(tenant_id)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let exchange = exchange_token(&app, ®enerated.token).await?;
|
||||||
|
assert_eq!(exchange.token_type, "Bearer");
|
||||||
|
assert!(!exchange.access_token.is_empty());
|
||||||
|
assert!(exchange.expires_in > 0);
|
||||||
|
assert_eq!(exchange.tenant.id, tenant_id_for_token);
|
||||||
|
assert!(!exchange.tenant.name.is_empty());
|
||||||
|
|
||||||
|
delete_token(&app, &access_token, token_id).await?;
|
||||||
|
|
||||||
|
let listed_after = list_tokens(&app, &access_token).await?;
|
||||||
|
assert_eq!(listed_after.len(), 1);
|
||||||
|
assert_eq!(listed_after[0].id, token_id);
|
||||||
|
assert!(listed_after[0].revoked_at.is_some());
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
|
||||||
|
let _guard = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let username = "bruce";
|
||||||
|
let password = "wayne";
|
||||||
|
app.insert_user(username, password, "admin").await?;
|
||||||
|
let access_token = app.login_token(username, password).await?;
|
||||||
|
|
||||||
|
let created = create_token(&app, &access_token, json!({ "label": "webdav" })).await?;
|
||||||
|
let token_id = created.info.id;
|
||||||
|
|
||||||
|
let router = webdav::create_router().with_state(app.state.clone());
|
||||||
|
let original_secret = created.token.clone();
|
||||||
|
let auth_header = format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64.encode(format!("{}:{}", username, original_secret))
|
||||||
|
);
|
||||||
|
|
||||||
|
let propfind = Method::from_bytes(b"PROPFIND")?;
|
||||||
|
let success_request = Request::builder()
|
||||||
|
.method(propfind.clone())
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, auth_header.clone())
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let response = router.clone().oneshot(success_request).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
||||||
|
|
||||||
|
let used = app
|
||||||
|
.with_conn(move |conn| {
|
||||||
|
let record = api_tokens::table.find(token_id).first::<ApiToken>(conn)?;
|
||||||
|
Ok::<_, anyhow::Error>(record.last_used_at)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert!(used.is_some());
|
||||||
|
|
||||||
|
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
|
||||||
|
assert_ne!(regenerated.token, original_secret);
|
||||||
|
|
||||||
|
let unused_after_regen = app
|
||||||
|
.with_conn(move |conn| {
|
||||||
|
let record = api_tokens::table.find(token_id).first::<ApiToken>(conn)?;
|
||||||
|
Ok::<_, anyhow::Error>(record.last_used_at)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert!(unused_after_regen.is_none());
|
||||||
|
|
||||||
|
let old_secret_request = Request::builder()
|
||||||
|
.method(propfind.clone())
|
||||||
|
.uri("/")
|
||||||
|
.header(
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64.encode(format!("{}:{}", username, original_secret))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let old_secret_response = router.clone().oneshot(old_secret_request).await?;
|
||||||
|
assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let new_secret_header = format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64.encode(format!("{}:{}", username, regenerated.token))
|
||||||
|
);
|
||||||
|
|
||||||
|
let success_request = Request::builder()
|
||||||
|
.method(propfind.clone())
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, new_secret_header.clone())
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let response = router.clone().oneshot(success_request).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
||||||
|
|
||||||
|
delete_token(&app, &access_token, token_id).await?;
|
||||||
|
|
||||||
|
let failure_request = Request::builder()
|
||||||
|
.method(propfind)
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, new_secret_header)
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let failure_response = router.oneshot(failure_request).await?;
|
||||||
|
assert_eq!(failure_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_token(
|
||||||
|
app: &TestApp,
|
||||||
|
access_token: &str,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<CreateTokenResponse> {
|
||||||
|
let response = app
|
||||||
|
.post_json("/api/profile/api-tokens", &payload, Some(access_token))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn regenerate_token(
|
||||||
|
app: &TestApp,
|
||||||
|
access_token: &str,
|
||||||
|
token_id: Uuid,
|
||||||
|
) -> Result<CreateTokenResponse> {
|
||||||
|
let response = app
|
||||||
|
.post_json(
|
||||||
|
&format!("/api/profile/api-tokens/{token_id}/regenerate"),
|
||||||
|
&json!({}),
|
||||||
|
Some(access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_token_capabilities(
|
||||||
|
app: &TestApp,
|
||||||
|
access_token: &str,
|
||||||
|
token_id: Uuid,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<TokenInfo> {
|
||||||
|
let response = app
|
||||||
|
.patch_json(
|
||||||
|
&format!("/api/profile/api-tokens/{token_id}"),
|
||||||
|
&payload,
|
||||||
|
Some(access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_tokens(app: &TestApp, access_token: &str) -> Result<Vec<TokenInfo>> {
|
||||||
|
let response = app
|
||||||
|
.get("/api/profile/api-tokens", Some(access_token))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Result<()> {
|
||||||
|
let response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/api-tokens/{token_id}"),
|
||||||
|
Some(access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn exchange_token(app: &TestApp, api_token: &str) -> Result<LoginResponseView> {
|
||||||
|
let response = app
|
||||||
|
.post_json(
|
||||||
|
"/api/auth/exchange-api-token",
|
||||||
|
&json!({ "api_token": api_token }),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
+11
-11
@@ -9,9 +9,9 @@ use papercrate::auth::passkeys::{
|
|||||||
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
||||||
RegistrationChallengeResponse,
|
RegistrationChallengeResponse,
|
||||||
};
|
};
|
||||||
use papercrate::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
|
use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey};
|
||||||
use papercrate::openapi::schemas::PasskeySummary;
|
use papercrate::openapi::schemas::PasskeySummary;
|
||||||
use papercrate::schema::{refresh_tokens, tenants, user_memberships, users};
|
use papercrate::schema::{tenants, user_memberships, user_sessions, users};
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -598,21 +598,21 @@ async fn login_with_session(
|
|||||||
.generate_token(user.id, tenant.id, &user.username)
|
.generate_token(user.id, tenant.id, &user.username)
|
||||||
.map_err(|err| anyhow!(err))?;
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
let refresh_value = generate_refresh_token();
|
let session_value = generate_session_token();
|
||||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
let session_hash = hash_session_token(&session_value);
|
||||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
let new_refresh = NewRefreshToken {
|
let new_session = NewUserSession {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash: refresh_hash,
|
token_hash: session_hash,
|
||||||
issued_at: now.naive_utc(),
|
issued_at: now.naive_utc(),
|
||||||
expires_at: refresh_expires_at.naive_utc(),
|
expires_at: refresh_expires_at.naive_utc(),
|
||||||
tenant_id: tenant.id,
|
tenant_id: tenant.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(refresh_tokens::table)
|
diesel::insert_into(user_sessions::table)
|
||||||
.values(&new_refresh)
|
.values(&new_session)
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
let login = LoginResponse {
|
let login = LoginResponse {
|
||||||
@@ -623,7 +623,7 @@ async fn login_with_session(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let cookie = format!("refresh_token={refresh_value}");
|
let cookie = format!("refresh_token={session_value}");
|
||||||
Ok((login, cookie))
|
Ok((login, cookie))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -643,13 +643,13 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
|||||||
Ok(cookie)
|
Ok(cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_refresh_token() -> String {
|
fn generate_session_token() -> String {
|
||||||
let mut bytes = [0u8; 32];
|
let mut bytes = [0u8; 32];
|
||||||
OsRng.fill_bytes(&mut bytes);
|
OsRng.fill_bytes(&mut bytes);
|
||||||
hex::encode(bytes)
|
hex::encode(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_refresh_token(value: &str) -> String {
|
fn hash_session_token(value: &str) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(value.as_bytes());
|
hasher.update(value.as_bytes());
|
||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
|
|||||||
+13
-13
@@ -20,11 +20,11 @@ use papercrate::auth::jwt::JwtService;
|
|||||||
use papercrate::config::AppConfig;
|
use papercrate::config::AppConfig;
|
||||||
use papercrate::db::{self, PgPool};
|
use papercrate::db::{self, PgPool};
|
||||||
use papercrate::models::{
|
use papercrate::models::{
|
||||||
Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User,
|
Job, NewUser, NewUserMembership, NewUserPasskey, NewUserSession, Tenant, TenantStatus, User,
|
||||||
UserMembership,
|
UserMembership,
|
||||||
};
|
};
|
||||||
use papercrate::routes;
|
use papercrate::routes;
|
||||||
use papercrate::schema::refresh_tokens::dsl as refresh_dsl;
|
use papercrate::schema::user_sessions::dsl as session_dsl;
|
||||||
use papercrate::state::AppState;
|
use papercrate::state::AppState;
|
||||||
use papercrate::storage::ObjectStorage;
|
use papercrate::storage::ObjectStorage;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
@@ -359,25 +359,25 @@ impl TestApp {
|
|||||||
.generate_token(user.id, tenant.id, &user.username)
|
.generate_token(user.id, tenant.id, &user.username)
|
||||||
.map_err(|err| anyhow!(err))?;
|
.map_err(|err| anyhow!(err))?;
|
||||||
|
|
||||||
let refresh_value = generate_refresh_token();
|
let session_value = generate_session_token();
|
||||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
let session_hash = hash_session_token(&session_value);
|
||||||
let refresh_expires_at =
|
let refresh_expires_at =
|
||||||
now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||||
|
|
||||||
let new_refresh = NewRefreshToken {
|
let new_session = NewUserSession {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash: refresh_hash,
|
token_hash: session_hash,
|
||||||
issued_at: now.naive_utc(),
|
issued_at: now.naive_utc(),
|
||||||
expires_at: refresh_expires_at.naive_utc(),
|
expires_at: refresh_expires_at.naive_utc(),
|
||||||
tenant_id: tenant.id,
|
tenant_id: tenant.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(refresh_dsl::refresh_tokens)
|
diesel::insert_into(session_dsl::user_sessions)
|
||||||
.values(&new_refresh)
|
.values(&new_session)
|
||||||
.execute(conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
let cookie = format!("refresh_token={refresh_value}");
|
let cookie = format!("refresh_token={session_value}");
|
||||||
Ok((access_token, cookie, tenant.id))
|
Ok((access_token, cookie, tenant.id))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -787,9 +787,9 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
|||||||
tenant.documents, \
|
tenant.documents, \
|
||||||
tenant.folders, \
|
tenant.folders, \
|
||||||
shared.jobs, \
|
shared.jobs, \
|
||||||
tenant.refresh_tokens, \
|
tenant.user_sessions, \
|
||||||
tenant.tags, \
|
tenant.tags, \
|
||||||
tenant.webdav_tokens, \
|
tenant.api_tokens, \
|
||||||
shared.webauthn_challenges, \
|
shared.webauthn_challenges, \
|
||||||
shared.user_passkeys, \
|
shared.user_passkeys, \
|
||||||
tenant.user_memberships, \
|
tenant.user_memberships, \
|
||||||
@@ -802,13 +802,13 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_refresh_token() -> String {
|
fn generate_session_token() -> String {
|
||||||
let mut bytes = [0u8; 32];
|
let mut bytes = [0u8; 32];
|
||||||
OsRng.fill_bytes(&mut bytes);
|
OsRng.fill_bytes(&mut bytes);
|
||||||
hex::encode(bytes)
|
hex::encode(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_refresh_token(value: &str) -> String {
|
fn hash_session_token(value: &str) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(value.as_bytes());
|
hasher.update(value.as_bytes());
|
||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
mod common;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{header, Method, Request, StatusCode};
|
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
||||||
use base64::Engine;
|
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use papercrate::models::WebdavToken;
|
|
||||||
use papercrate::routes::webdav;
|
|
||||||
use papercrate::schema::webdav_tokens;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
|
||||||
use tower::ServiceExt;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TokenInfo {
|
|
||||||
id: Uuid,
|
|
||||||
label: Option<String>,
|
|
||||||
last_used_at: Option<String>,
|
|
||||||
revoked_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CreateTokenResponse {
|
|
||||||
token: String,
|
|
||||||
#[serde(rename = "token_info")]
|
|
||||||
info: TokenInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn webdav_token_api_crud() -> Result<()> {
|
|
||||||
let _guard = acquire_db_lock().await;
|
|
||||||
let app = TestApp::new().await?;
|
|
||||||
|
|
||||||
let username = "alice";
|
|
||||||
let password = "correct horse battery";
|
|
||||||
app.insert_user(username, password, "admin").await?;
|
|
||||||
let access_token = app.login_token(username, password).await?;
|
|
||||||
|
|
||||||
let create_response = app
|
|
||||||
.post_json(
|
|
||||||
"/api/profile/webdav-tokens",
|
|
||||||
&json!({ "label": "dav" }),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
|
||||||
let create_body = body_to_vec(create_response.into_body()).await?;
|
|
||||||
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
|
|
||||||
let token_id = created.info.id;
|
|
||||||
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
|
||||||
assert!(created.info.last_used_at.is_none());
|
|
||||||
|
|
||||||
let regenerate_response = app
|
|
||||||
.post_json(
|
|
||||||
&format!("/api/profile/webdav-tokens/{token_id}/regenerate"),
|
|
||||||
&json!({}),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(regenerate_response.status(), StatusCode::OK);
|
|
||||||
let regenerate_body = body_to_vec(regenerate_response.into_body()).await?;
|
|
||||||
let regenerated: CreateTokenResponse = serde_json::from_slice(®enerate_body)?;
|
|
||||||
assert_eq!(regenerated.info.id, token_id);
|
|
||||||
assert_ne!(regenerated.token, created.token);
|
|
||||||
assert!(regenerated.info.last_used_at.is_none());
|
|
||||||
|
|
||||||
let list_response = app
|
|
||||||
.get("/api/profile/webdav-tokens", Some(&access_token))
|
|
||||||
.await?;
|
|
||||||
assert_eq!(list_response.status(), StatusCode::OK);
|
|
||||||
let list_body = body_to_vec(list_response.into_body()).await?;
|
|
||||||
let listed: Vec<TokenInfo> = serde_json::from_slice(&list_body)?;
|
|
||||||
assert_eq!(listed.len(), 1);
|
|
||||||
assert_eq!(listed[0].id, token_id);
|
|
||||||
|
|
||||||
let delete_response = app
|
|
||||||
.delete(
|
|
||||||
&format!("/api/profile/webdav-tokens/{token_id}"),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
|
||||||
|
|
||||||
let list_after = app
|
|
||||||
.get("/api/profile/webdav-tokens", Some(&access_token))
|
|
||||||
.await?;
|
|
||||||
let list_after_body = body_to_vec(list_after.into_body()).await?;
|
|
||||||
let listed_after: Vec<TokenInfo> = serde_json::from_slice(&list_after_body)?;
|
|
||||||
assert_eq!(listed_after.len(), 1);
|
|
||||||
assert_eq!(listed_after[0].id, token_id);
|
|
||||||
assert!(listed_after[0].revoked_at.is_some());
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
|
||||||
let _guard = acquire_db_lock().await;
|
|
||||||
let app = TestApp::new().await?;
|
|
||||||
|
|
||||||
let username = "bruce";
|
|
||||||
let password = "wayne";
|
|
||||||
app.insert_user(username, password, "admin").await?;
|
|
||||||
let access_token = app.login_token(username, password).await?;
|
|
||||||
|
|
||||||
let create_response = app
|
|
||||||
.post_json(
|
|
||||||
"/api/profile/webdav-tokens",
|
|
||||||
&json!({ "label": "webdav" }),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let create_body = body_to_vec(create_response.into_body()).await?;
|
|
||||||
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
|
|
||||||
let token_id = created.info.id;
|
|
||||||
|
|
||||||
let router = webdav::create_router().with_state(app.state.clone());
|
|
||||||
let original_secret = created.token.clone();
|
|
||||||
let auth_header = format!(
|
|
||||||
"Basic {}",
|
|
||||||
BASE64.encode(format!("{}:{}", username, original_secret))
|
|
||||||
);
|
|
||||||
|
|
||||||
let propfind = Method::from_bytes(b"PROPFIND")?;
|
|
||||||
let success_request = Request::builder()
|
|
||||||
.method(propfind.clone())
|
|
||||||
.uri("/")
|
|
||||||
.header(header::AUTHORIZATION, auth_header.clone())
|
|
||||||
.header("depth", "0")
|
|
||||||
.body(Body::empty())?;
|
|
||||||
let response = router.clone().oneshot(success_request).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
|
||||||
|
|
||||||
let used = app
|
|
||||||
.with_conn(move |conn| {
|
|
||||||
let record = webdav_tokens::table
|
|
||||||
.find(token_id)
|
|
||||||
.first::<WebdavToken>(conn)?;
|
|
||||||
Ok::<_, anyhow::Error>(record.last_used_at)
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
assert!(used.is_some());
|
|
||||||
|
|
||||||
let regenerate_response = app
|
|
||||||
.post_json(
|
|
||||||
&format!("/api/profile/webdav-tokens/{token_id}/regenerate"),
|
|
||||||
&json!({}),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(regenerate_response.status(), StatusCode::OK);
|
|
||||||
let regenerate_body = body_to_vec(regenerate_response.into_body()).await?;
|
|
||||||
let regenerated: CreateTokenResponse = serde_json::from_slice(®enerate_body)?;
|
|
||||||
assert_ne!(regenerated.token, original_secret);
|
|
||||||
|
|
||||||
let unused_after_regen = app
|
|
||||||
.with_conn(move |conn| {
|
|
||||||
let record = webdav_tokens::table
|
|
||||||
.find(token_id)
|
|
||||||
.first::<WebdavToken>(conn)?;
|
|
||||||
Ok::<_, anyhow::Error>(record.last_used_at)
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
assert!(unused_after_regen.is_none());
|
|
||||||
|
|
||||||
let old_secret_request = Request::builder()
|
|
||||||
.method(propfind.clone())
|
|
||||||
.uri("/")
|
|
||||||
.header(
|
|
||||||
header::AUTHORIZATION,
|
|
||||||
format!(
|
|
||||||
"Basic {}",
|
|
||||||
BASE64.encode(format!("{}:{}", username, original_secret))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.header("depth", "0")
|
|
||||||
.body(Body::empty())?;
|
|
||||||
let old_secret_response = router.clone().oneshot(old_secret_request).await?;
|
|
||||||
assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
|
|
||||||
let new_secret_header = format!(
|
|
||||||
"Basic {}",
|
|
||||||
BASE64.encode(format!("{}:{}", username, regenerated.token))
|
|
||||||
);
|
|
||||||
|
|
||||||
let success_request = Request::builder()
|
|
||||||
.method(propfind.clone())
|
|
||||||
.uri("/")
|
|
||||||
.header(header::AUTHORIZATION, new_secret_header.clone())
|
|
||||||
.header("depth", "0")
|
|
||||||
.body(Body::empty())?;
|
|
||||||
let response = router.clone().oneshot(success_request).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
|
||||||
|
|
||||||
let delete_response = app
|
|
||||||
.delete(
|
|
||||||
&format!("/api/profile/webdav-tokens/{token_id}"),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
|
||||||
|
|
||||||
let failure_request = Request::builder()
|
|
||||||
.method(propfind)
|
|
||||||
.uri("/")
|
|
||||||
.header(header::AUTHORIZATION, new_secret_header)
|
|
||||||
.header("depth", "0")
|
|
||||||
.body(Body::empty())?;
|
|
||||||
let response = router.oneshot(failure_request).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -75,7 +75,7 @@ objects under a single logical asset.
|
|||||||
- `document_tags` and `document_correspondents` provide many-to-many
|
- `document_tags` and `document_correspondents` provide many-to-many
|
||||||
relationships for categorisation.
|
relationships for categorisation.
|
||||||
- `jobs` records background work (OCR, thumbnails, indexing) keyed by tenant.
|
- `jobs` records background work (OCR, thumbnails, indexing) keyed by tenant.
|
||||||
- `webdav_tokens`, `refresh_tokens`, and `user_passkeys` live alongside but do
|
- `api_tokens`, `user_sessions`, and `user_passkeys` live alongside but do
|
||||||
not alter the document schema directly.
|
not alter the document schema directly.
|
||||||
|
|
||||||
## Lifecycle summary
|
## Lifecycle summary
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 683 KiB |
@@ -0,0 +1,56 @@
|
|||||||
|
# Bucket CORS for Presigned Asset Fetches
|
||||||
|
|
||||||
|
The frontend loads certain assets (e.g. OCR text) with `fetch()` against their presigned URLs
|
||||||
|
(see `frontend/src/preview/DocumentViewerPanel.jsx`). Browsers will block that request unless
|
||||||
|
the storage bucket sends CORS headers that allow the frontend origin. Configure a rule that
|
||||||
|
includes:
|
||||||
|
|
||||||
|
* the list of allowed origins (your production, staging, or local domains)
|
||||||
|
* `GET` (and optionally other methods you expose)
|
||||||
|
* permissive request headers (usually `"*"` is fine for presigned URLs)
|
||||||
|
* exposed response headers if the frontend needs them (`etag`, `content-length`, etc.)
|
||||||
|
|
||||||
|
## Example CORS document
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"CORSRules": [
|
||||||
|
{
|
||||||
|
"AllowedOrigins": ["https://app.example"],
|
||||||
|
"AllowedMethods": ["GET"],
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"ExposeHeaders": ["etag", "content-length", "content-type"],
|
||||||
|
"MaxAgeSeconds": 300
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `https://app.example` with each domain that must fetch presigned assets. Add additional
|
||||||
|
rules if different origins require different methods.
|
||||||
|
|
||||||
|
## Applying the rule
|
||||||
|
|
||||||
|
### AWS S3 CLI
|
||||||
|
```bash
|
||||||
|
aws s3api put-bucket-cors \
|
||||||
|
--bucket <bucket-name> \
|
||||||
|
--cors-configuration file://cors.json \
|
||||||
|
[--endpoint-url <custom-endpoint>]
|
||||||
|
```
|
||||||
|
Save the JSON payload as `cors.json`. When targeting S3-compatible providers (e.g. Hetzner, Ceph RGW),
|
||||||
|
pass their endpoint via `--endpoint-url`.
|
||||||
|
|
||||||
|
### s3cmd (Ceph RGW / generic S3)
|
||||||
|
```bash
|
||||||
|
s3cmd setcors cors.json s3://<bucket-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### MinIO Client (`mc`)
|
||||||
|
```bash
|
||||||
|
mc alias set storage <endpoint> <access-key> <secret-key>
|
||||||
|
mc anonymous set-json storage/<bucket-name> cors.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Most dashboards expose a similar form—paste the JSON rule into the CORS section for the bucket.
|
||||||
|
Once the rule is active, browsers will allow the frontend to read presigned assets with fetch().
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -69,7 +69,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desk-item.is-tag-pending .desk-item__card {
|
.desk-item.is-tag-pending .desk-item__card {
|
||||||
opacity: 0.6;
|
outline: 0.25rem solid var(--accent-outline);
|
||||||
|
outline-offset: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desk-item.is-filtered-out {
|
.desk-item.is-filtered-out {
|
||||||
@@ -80,6 +81,22 @@
|
|||||||
z-index: 0 !important;
|
z-index: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desk-item.is-selected {
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-item.is-selected .desk-item__card {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0.18rem color-mix(in oklch, var(--accent) 45%, transparent),
|
||||||
|
0 0 0.35rem 0 color-mix(in oklch, var(--accent) 28%, transparent),
|
||||||
|
0 12px 28px -14px color-mix(in oklch, var(--accent) 20%, transparent),
|
||||||
|
0 10px 24px var(--shadow-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-item.is-selected .desk-item__title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.desk-item__tags {
|
.desk-item__tags {
|
||||||
--tag-scale: 1;
|
--tag-scale: 1;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -103,6 +120,100 @@
|
|||||||
box-shadow: 2px 2px 4px var(--shadow-medium);
|
box-shadow: 2px 2px 4px var(--shadow-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1400;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(1.5rem, 4vw, 3rem);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--surface-overlay);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__content {
|
||||||
|
position: relative;
|
||||||
|
width: min(640px, 92vw);
|
||||||
|
max-height: min(80vh, 640px);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 24px 64px var(--shadow-strong);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
pointer-events: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__header,
|
||||||
|
.desk-help-overlay__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__body {
|
||||||
|
padding: 1rem 1.25rem 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__body p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__list li {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__body kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-help-overlay__footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-chip--draggable:active {
|
.tag-chip--draggable:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
@@ -201,6 +312,9 @@ body.desk-cursor-remove * {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desk-item__card--empty {
|
.desk-item__card--empty {
|
||||||
|
|||||||
+648
-142
File diff suppressed because it is too large
Load Diff
+377
-654
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Sidebar from '../sidebar/Sidebar';
|
import Sidebar from '../sidebar/Sidebar';
|
||||||
|
import { useSidebarContext } from '../sidebar/SidebarContext';
|
||||||
|
|
||||||
const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => (
|
const DocumentsLayout = ({ sidebarProps, children }) => {
|
||||||
<main className={`documents-main${sidebarCollapsed ? ' documents-main--sidebar-collapsed' : ''}`}>
|
const { collapsed } = useSidebarContext();
|
||||||
{!sidebarCollapsed ? <Sidebar {...sidebarProps} /> : null}
|
return (
|
||||||
{children}
|
<main className={`documents-main${collapsed ? ' documents-main--sidebar-collapsed' : ''}`}>
|
||||||
</main>
|
{!collapsed ? <Sidebar {...sidebarProps} /> : null}
|
||||||
);
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default DocumentsLayout;
|
export default DocumentsLayout;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppShell } from '../appShellContext';
|
import { useAppShell } from '../appShellContext';
|
||||||
import DocumentsLayout from './DocumentsLayout';
|
import DocumentsLayout from './DocumentsLayout';
|
||||||
import { useWorkspaceSurface } from './useWorkspaceSurface';
|
import { useWorkspaceSurface } from './useWorkspaceSurface';
|
||||||
import PanelHeader from '../ui/PanelHeader';
|
import PanelHeader from '../ui/PanelHeader';
|
||||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||||
|
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
|
||||||
|
|
||||||
const DocumentsRoute = () => {
|
const DocumentsRouteContent = () => {
|
||||||
const {
|
const {
|
||||||
sidebarProps,
|
sidebarProps,
|
||||||
documentsTableProps,
|
documentsTableProps,
|
||||||
@@ -18,6 +19,7 @@ const DocumentsRoute = () => {
|
|||||||
openCorrespondentsModal,
|
openCorrespondentsModal,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
|
previewDocumentId,
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
handleThumbnailRegeneration,
|
handleThumbnailRegeneration,
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
@@ -27,19 +29,17 @@ const DocumentsRoute = () => {
|
|||||||
notifyApiError,
|
notifyApiError,
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { collapsed: sidebarCollapsed, setCollapsed } = useSidebarContext();
|
||||||
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const expandSidebar = useCallback(() => setCollapsed(false), [setCollapsed]);
|
||||||
const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []);
|
|
||||||
const expandSidebar = useCallback(() => setSidebarCollapsed(false), []);
|
|
||||||
|
|
||||||
const sidebarPropsWithActions = useMemo(
|
const sidebarPropsWithActions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...sidebarProps,
|
...sidebarProps,
|
||||||
onManageTags: openTagsModal,
|
onManageTags: openTagsModal,
|
||||||
onManageCorrespondents: openCorrespondentsModal,
|
onManageCorrespondents: openCorrespondentsModal,
|
||||||
onCollapse: collapseSidebar,
|
|
||||||
}),
|
}),
|
||||||
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
|
[sidebarProps, openTagsModal, openCorrespondentsModal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
|
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
|
||||||
@@ -78,6 +78,7 @@ const DocumentsRoute = () => {
|
|||||||
deskWorkspaceProps,
|
deskWorkspaceProps,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
|
previewDocumentId,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
@@ -91,10 +92,7 @@ const DocumentsRoute = () => {
|
|||||||
|
|
||||||
if (!surface) {
|
if (!surface) {
|
||||||
return (
|
return (
|
||||||
<DocumentsLayout
|
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||||
sidebarProps={sidebarPropsWithActions}
|
|
||||||
sidebarCollapsed={sidebarCollapsed}
|
|
||||||
>
|
|
||||||
<div className="main-content main-content--documents">
|
<div className="main-content main-content--documents">
|
||||||
<div className="main-content__body main-content__body--documents" />
|
<div className="main-content__body main-content__body--documents" />
|
||||||
</div>
|
</div>
|
||||||
@@ -140,10 +138,7 @@ const DocumentsRoute = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentsLayout
|
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||||
sidebarProps={sidebarPropsWithActions}
|
|
||||||
sidebarCollapsed={sidebarCollapsed}
|
|
||||||
>
|
|
||||||
<div className={mainContentClass}>
|
<div className={mainContentClass}>
|
||||||
{header ? (
|
{header ? (
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
@@ -161,4 +156,10 @@ const DocumentsRoute = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DocumentsRoute = () => (
|
||||||
|
<SidebarProvider>
|
||||||
|
<DocumentsRouteContent />
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
|
||||||
export default DocumentsRoute;
|
export default DocumentsRoute;
|
||||||
|
|||||||
@@ -2,21 +2,15 @@ import React, { useEffect, useCallback } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import SettingsModal from '../settings/SettingsModal';
|
import SettingsModal from '../settings/SettingsModal';
|
||||||
import { useAppShell } from '../appShellContext';
|
import { useAppShell } from '../appShellContext';
|
||||||
|
import useApiTokens from '../settings/useApiTokens';
|
||||||
|
import { api } from './appState';
|
||||||
|
|
||||||
const SettingsRoute = () => {
|
const SettingsRoute = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const {
|
||||||
webdavTokens,
|
token,
|
||||||
webdavTokensLoading,
|
notifyApiError,
|
||||||
creatingWebdavToken,
|
setStatusMessage,
|
||||||
deletingWebdavTokenId,
|
|
||||||
regeneratingWebdavTokenId,
|
|
||||||
refreshWebdavTokens,
|
|
||||||
createWebdavToken,
|
|
||||||
deleteWebdavToken,
|
|
||||||
regenerateWebdavToken,
|
|
||||||
webdavTokenSecret,
|
|
||||||
dismissCreatedWebdavToken,
|
|
||||||
passkeys,
|
passkeys,
|
||||||
passkeysSupported,
|
passkeysSupported,
|
||||||
passkeysLoading,
|
passkeysLoading,
|
||||||
@@ -27,15 +21,31 @@ const SettingsRoute = () => {
|
|||||||
revokePasskey,
|
revokePasskey,
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
|
|
||||||
|
const {
|
||||||
|
tokens,
|
||||||
|
loading,
|
||||||
|
creating,
|
||||||
|
deletingId,
|
||||||
|
regeneratingId,
|
||||||
|
updatingId,
|
||||||
|
createdSecret,
|
||||||
|
refresh,
|
||||||
|
create,
|
||||||
|
revoke,
|
||||||
|
regenerate,
|
||||||
|
updateCapabilities,
|
||||||
|
dismissSecret,
|
||||||
|
} = useApiTokens({ api, token, notifyApiError, setStatusMessage });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshWebdavTokens();
|
refresh();
|
||||||
refreshPasskeys();
|
refreshPasskeys();
|
||||||
}, [refreshWebdavTokens, refreshPasskeys]);
|
}, [refresh, refreshPasskeys]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
dismissCreatedWebdavToken();
|
dismissSecret();
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
}, [dismissCreatedWebdavToken, navigate]);
|
}, [dismissSecret, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
@@ -49,24 +59,26 @@ const SettingsRoute = () => {
|
|||||||
}, [handleClose]);
|
}, [handleClose]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
dismissCreatedWebdavToken();
|
dismissSecret();
|
||||||
}, [dismissCreatedWebdavToken]);
|
}, [dismissSecret]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
open
|
open
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
tokens={webdavTokens}
|
tokens={tokens}
|
||||||
loading={webdavTokensLoading}
|
loading={loading}
|
||||||
creating={creatingWebdavToken}
|
creating={creating}
|
||||||
deletingId={deletingWebdavTokenId}
|
deletingId={deletingId}
|
||||||
regeneratingId={regeneratingWebdavTokenId}
|
regeneratingId={regeneratingId}
|
||||||
onRefresh={refreshWebdavTokens}
|
updatingId={updatingId}
|
||||||
onCreate={createWebdavToken}
|
onRefresh={refresh}
|
||||||
onDelete={deleteWebdavToken}
|
onCreate={create}
|
||||||
onRegenerate={regenerateWebdavToken}
|
onDelete={revoke}
|
||||||
createdToken={webdavTokenSecret}
|
onRegenerate={regenerate}
|
||||||
onDismissCreatedToken={dismissCreatedWebdavToken}
|
onUpdateCapabilities={updateCapabilities}
|
||||||
|
createdToken={createdSecret}
|
||||||
|
onDismissCreatedToken={dismissSecret}
|
||||||
passkeys={passkeys}
|
passkeys={passkeys}
|
||||||
passkeysSupported={passkeysSupported}
|
passkeysSupported={passkeysSupported}
|
||||||
passkeysLoading={passkeysLoading}
|
passkeysLoading={passkeysLoading}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export const useDetailPanel = ({
|
||||||
|
documentLookup,
|
||||||
|
orderedSelectedDocuments,
|
||||||
|
selectionOrder,
|
||||||
|
documentsViewMode,
|
||||||
|
getRowId,
|
||||||
|
isDocumentRowKey,
|
||||||
|
}) => {
|
||||||
|
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
|
||||||
|
const [detailPanelDocIds, setDetailPanelDocIds] = useState([]);
|
||||||
|
const [detailPanelDocs, setDetailPanelDocs] = useState([]);
|
||||||
|
const lastScrolledDetailDocRef = useRef(null);
|
||||||
|
const latestOrderedDocsRef = useRef([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
latestOrderedDocsRef.current = orderedSelectedDocuments;
|
||||||
|
if (detailPanelOpen && orderedSelectedDocuments.length) {
|
||||||
|
const snapshotIds = orderedSelectedDocuments
|
||||||
|
.map((doc) => doc?.id)
|
||||||
|
.filter((id) => typeof id === 'string' || typeof id === 'number');
|
||||||
|
if (snapshotIds.length) {
|
||||||
|
setDetailPanelDocIds(snapshotIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [orderedSelectedDocuments, detailPanelOpen]);
|
||||||
|
|
||||||
|
const resolveDocsForIds = useCallback(
|
||||||
|
(ids, fallbackDocs = []) => {
|
||||||
|
if (!ids?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const fallbackMap = new Map((fallbackDocs || []).map((doc) => [doc?.id, doc]));
|
||||||
|
return ids
|
||||||
|
.map((id) => documentLookup.get(id) || fallbackMap.get(id) || null)
|
||||||
|
.filter(Boolean);
|
||||||
|
},
|
||||||
|
[documentLookup],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailPanelDocIds.length) {
|
||||||
|
setDetailPanelDocs((prev) => (prev.length ? [] : prev));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDetailPanelDocs((prevDocs) => {
|
||||||
|
const resolved = resolveDocsForIds(detailPanelDocIds, prevDocs);
|
||||||
|
if (resolved.length === prevDocs.length && resolved.every((doc, index) => doc === prevDocs[index])) {
|
||||||
|
return prevDocs;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
});
|
||||||
|
}, [detailPanelDocIds, resolveDocsForIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailPanelOpen) {
|
||||||
|
lastScrolledDetailDocRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orderedSelectedDocuments.length) {
|
||||||
|
lastScrolledDetailDocRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastSelectedId = null;
|
||||||
|
for (let index = selectionOrder.length - 1; index >= 0; index -= 1) {
|
||||||
|
const key = selectionOrder[index];
|
||||||
|
if (isDocumentRowKey(key)) {
|
||||||
|
lastSelectedId = getRowId(key);
|
||||||
|
if (lastSelectedId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastSelectedId && orderedSelectedDocuments.length) {
|
||||||
|
const fallbackDoc = orderedSelectedDocuments[orderedSelectedDocuments.length - 1];
|
||||||
|
lastSelectedId = fallbackDoc?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastSelectedId || lastScrolledDetailDocRef.current === lastSelectedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetElement =
|
||||||
|
document.getElementById(`document-row-${lastSelectedId}`)
|
||||||
|
|| document.getElementById(`document-card-${lastSelectedId}`);
|
||||||
|
|
||||||
|
if (!targetElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrolledDetailDocRef.current = lastSelectedId;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
detailPanelOpen,
|
||||||
|
orderedSelectedDocuments,
|
||||||
|
selectionOrder,
|
||||||
|
documentsViewMode,
|
||||||
|
getRowId,
|
||||||
|
isDocumentRowKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const detailPanelSelectedDocuments = detailPanelDocs;
|
||||||
|
|
||||||
|
const openDetailPanel = useCallback(
|
||||||
|
({ documentIds: explicitIds, documents: explicitDocs } = {}) => {
|
||||||
|
let sourceDocs = Array.isArray(explicitDocs) ? explicitDocs : null;
|
||||||
|
let snapshotIds = Array.isArray(explicitIds)
|
||||||
|
? explicitIds.filter((id) => typeof id === 'string' || typeof id === 'number')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!snapshotIds?.length) {
|
||||||
|
if (!sourceDocs || !sourceDocs.length) {
|
||||||
|
sourceDocs = latestOrderedDocsRef.current;
|
||||||
|
}
|
||||||
|
snapshotIds = Array.isArray(sourceDocs)
|
||||||
|
? sourceDocs
|
||||||
|
.map((doc) => doc?.id)
|
||||||
|
.filter((id) => typeof id === 'string' || typeof id === 'number')
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueIds = [];
|
||||||
|
snapshotIds.forEach((id) => {
|
||||||
|
if (!uniqueIds.includes(id)) {
|
||||||
|
uniqueIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedDocs = resolveDocsForIds(uniqueIds, sourceDocs || latestOrderedDocsRef.current);
|
||||||
|
|
||||||
|
setDetailPanelDocIds(uniqueIds);
|
||||||
|
setDetailPanelDocs(resolvedDocs);
|
||||||
|
setDetailPanelOpen(true);
|
||||||
|
},
|
||||||
|
[resolveDocsForIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeDetailPanel = useCallback(() => {
|
||||||
|
setDetailPanelOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
detailPanelOpen,
|
||||||
|
detailPanelSelectedDocuments,
|
||||||
|
openDetailPanel,
|
||||||
|
closeDetailPanel,
|
||||||
|
setDetailPanelOpen,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDetailPanel;
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const DEFAULT_INITIAL_ENTRIES = [];
|
||||||
|
|
||||||
|
export const useDocumentSelection = ({
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
isDocumentRowKey,
|
||||||
|
isFolderRowKey,
|
||||||
|
getRowId,
|
||||||
|
initialEntries = DEFAULT_INITIAL_ENTRIES,
|
||||||
|
}) => {
|
||||||
|
const [selectedEntries, setSelectedEntries] = useState(initialEntries);
|
||||||
|
const [selectionOrder, setSelectionOrder] = useState(initialEntries);
|
||||||
|
const selectionOrderRef = useRef(initialEntries);
|
||||||
|
const selectionAnchorRef = useRef(null);
|
||||||
|
const selectionInitializedRef = useRef(false);
|
||||||
|
const [focusedDocumentId, setFocusedDocumentId] = useState(null);
|
||||||
|
const [focusedRowKey, setFocusedRowKey] = useState(null);
|
||||||
|
|
||||||
|
const visibleRowKeySetRef = useRef(new Set());
|
||||||
|
const navigableRowKeysRef = useRef([]);
|
||||||
|
|
||||||
|
const configureSelectionEnvironment = useCallback(({
|
||||||
|
visibleRowKeySet,
|
||||||
|
navigableRowKeys,
|
||||||
|
}) => {
|
||||||
|
if (visibleRowKeySet) {
|
||||||
|
visibleRowKeySetRef.current = visibleRowKeySet;
|
||||||
|
}
|
||||||
|
if (Array.isArray(navigableRowKeys)) {
|
||||||
|
navigableRowKeysRef.current = navigableRowKeys;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => {
|
||||||
|
const nextSet = new Set(nextSelection);
|
||||||
|
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
|
||||||
|
const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index);
|
||||||
|
|
||||||
|
const base = previousOrder.filter((id) => !interacted.includes(id));
|
||||||
|
const result = [...base];
|
||||||
|
|
||||||
|
interacted.forEach((id) => {
|
||||||
|
if (nextSet.has(id) && !result.includes(id)) {
|
||||||
|
result.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextSelection.forEach((id) => {
|
||||||
|
if (!result.includes(id)) {
|
||||||
|
result.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
result.length !== selectionOrderRef.current.length
|
||||||
|
|| result.some((id, index) => selectionOrderRef.current[index] !== id)
|
||||||
|
) {
|
||||||
|
selectionOrderRef.current = result;
|
||||||
|
setSelectionOrder(result);
|
||||||
|
} else {
|
||||||
|
selectionOrderRef.current = result;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applySelection = useCallback(
|
||||||
|
(rowKeys, { anchor, interactedKeys = [] } = {}) => {
|
||||||
|
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||||
|
const unique = [];
|
||||||
|
|
||||||
|
(rowKeys || []).forEach((key) => {
|
||||||
|
if (!key) return;
|
||||||
|
let canonicalKey = null;
|
||||||
|
if (visibleRowKeySet.has(key)) {
|
||||||
|
canonicalKey = key;
|
||||||
|
} else if (isDocumentRowKey(key)) {
|
||||||
|
const id = getRowId(key);
|
||||||
|
canonicalKey = id ? resolveDocumentRowKey(id) : null;
|
||||||
|
} else if (isFolderRowKey(key)) {
|
||||||
|
const id = getRowId(key);
|
||||||
|
canonicalKey = id ? resolveFolderRowKey(id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canonicalKey || !visibleRowKeySet.has(canonicalKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unique.includes(canonicalKey)) {
|
||||||
|
unique.push(canonicalKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let resolvedAnchor = anchor;
|
||||||
|
if (resolvedAnchor && !unique.includes(resolvedAnchor)) {
|
||||||
|
resolvedAnchor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedEntries(unique);
|
||||||
|
updateSelectionOrder(unique, interactedKeys);
|
||||||
|
|
||||||
|
const nextFocusedDocumentId = (() => {
|
||||||
|
if (focusedDocumentId) {
|
||||||
|
const focusKey = resolveDocumentRowKey(focusedDocumentId);
|
||||||
|
if (focusKey && unique.includes(focusKey)) {
|
||||||
|
return focusedDocumentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) {
|
||||||
|
return getRowId(resolvedAnchor) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastDocKey = [...unique].reverse().find(isDocumentRowKey);
|
||||||
|
return lastDocKey ? getRowId(lastDocKey) || null : null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
setFocusedDocumentId(nextFocusedDocumentId);
|
||||||
|
|
||||||
|
if (resolvedAnchor) {
|
||||||
|
selectionAnchorRef.current = resolvedAnchor;
|
||||||
|
} else if (!unique.length) {
|
||||||
|
selectionAnchorRef.current = null;
|
||||||
|
} else if (!selectionAnchorRef.current || !unique.includes(selectionAnchorRef.current)) {
|
||||||
|
selectionAnchorRef.current = unique[unique.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { selection: unique, focusKey: selectionAnchorRef.current };
|
||||||
|
},
|
||||||
|
[
|
||||||
|
focusedDocumentId,
|
||||||
|
getRowId,
|
||||||
|
isDocumentRowKey,
|
||||||
|
isFolderRowKey,
|
||||||
|
resolveDocumentRowKey,
|
||||||
|
resolveFolderRowKey,
|
||||||
|
updateSelectionOrder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setFocusedRowKey(null);
|
||||||
|
applySelection([], { anchor: null, interactedKeys: [] });
|
||||||
|
}, [applySelection]);
|
||||||
|
|
||||||
|
const handleRowSelection = useCallback(
|
||||||
|
(rowKey, event) => {
|
||||||
|
const visibleRowKeySet = visibleRowKeySetRef.current;
|
||||||
|
const navigableRowKeys = navigableRowKeysRef.current;
|
||||||
|
if (!rowKey || !visibleRowKeySet.has(rowKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocusedRowKey(rowKey);
|
||||||
|
|
||||||
|
const shiftKey = Boolean(event?.shiftKey);
|
||||||
|
const metaKey = Boolean(event?.metaKey);
|
||||||
|
const ctrlKey = Boolean(event?.ctrlKey);
|
||||||
|
const additive = metaKey || ctrlKey;
|
||||||
|
|
||||||
|
if (shiftKey) {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
let anchorKey = selectionAnchorRef.current;
|
||||||
|
if (!anchorKey && shiftKey && selectedEntries.length) {
|
||||||
|
anchorKey = selectedEntries[selectedEntries.length - 1];
|
||||||
|
}
|
||||||
|
if (!anchorKey) {
|
||||||
|
anchorKey = rowKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextKeys = [];
|
||||||
|
let interactedKeys = [];
|
||||||
|
|
||||||
|
if (shiftKey && anchorKey) {
|
||||||
|
const anchorIndex = navigableRowKeys.indexOf(anchorKey);
|
||||||
|
const targetIndex = navigableRowKeys.indexOf(rowKey);
|
||||||
|
if (anchorIndex !== -1 && targetIndex !== -1) {
|
||||||
|
const [start, end] = anchorIndex <= targetIndex
|
||||||
|
? [anchorIndex, targetIndex]
|
||||||
|
: [targetIndex, anchorIndex];
|
||||||
|
const range = navigableRowKeys.slice(start, end + 1);
|
||||||
|
nextKeys = range;
|
||||||
|
|
||||||
|
const previousSet = new Set(selectedEntries);
|
||||||
|
interactedKeys = range.filter((key) => key === rowKey || !previousSet.has(key));
|
||||||
|
if (!interactedKeys.includes(rowKey)) {
|
||||||
|
interactedKeys.push(rowKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextKeys = [rowKey];
|
||||||
|
interactedKeys = [rowKey];
|
||||||
|
}
|
||||||
|
} else if (additive) {
|
||||||
|
if (selectedEntries.includes(rowKey)) {
|
||||||
|
nextKeys = selectedEntries.filter((key) => key !== rowKey);
|
||||||
|
interactedKeys = [];
|
||||||
|
} else {
|
||||||
|
nextKeys = [...selectedEntries, rowKey];
|
||||||
|
interactedKeys = [rowKey];
|
||||||
|
}
|
||||||
|
anchorKey = rowKey;
|
||||||
|
} else {
|
||||||
|
nextKeys = [rowKey];
|
||||||
|
interactedKeys = [rowKey];
|
||||||
|
anchorKey = rowKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySelection(nextKeys, { anchor: anchorKey, interactedKeys });
|
||||||
|
},
|
||||||
|
[applySelection, selectedEntries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const promoteSelectionOrder = useCallback(
|
||||||
|
(docId) => {
|
||||||
|
if (!docId) return;
|
||||||
|
const rowKey = resolveDocumentRowKey(docId);
|
||||||
|
if (!rowKey) return;
|
||||||
|
|
||||||
|
if (!selectedEntries.includes(rowKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectionOrder(selectedEntries, [rowKey]);
|
||||||
|
},
|
||||||
|
[resolveDocumentRowKey, selectedEntries, updateSelectionOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedEntries,
|
||||||
|
setSelectedEntries,
|
||||||
|
selectionOrder,
|
||||||
|
setSelectionOrder,
|
||||||
|
selectionOrderRef,
|
||||||
|
selectionAnchorRef,
|
||||||
|
selectionInitializedRef,
|
||||||
|
focusedDocumentId,
|
||||||
|
setFocusedDocumentId,
|
||||||
|
focusedRowKey,
|
||||||
|
setFocusedRowKey,
|
||||||
|
applySelection,
|
||||||
|
clearSelection,
|
||||||
|
handleRowSelection,
|
||||||
|
promoteSelectionOrder,
|
||||||
|
configureSelectionEnvironment,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDocumentSelection;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { ChevronsRightIcon } from '../ui/icons';
|
import { SidebarExpandIcon } from '../ui/icons';
|
||||||
import { createDocumentsSurface } from '../documents/DocumentsTable';
|
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
||||||
import { createPreviewSurface } from '../preview/PreviewWorkspace';
|
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
||||||
import { createDesktopSurface } from '../DesktopWorkspace';
|
import { createDesktopSurface } from '../DesktopWorkspace';
|
||||||
|
|
||||||
export const useWorkspaceSurface = ({
|
export const useWorkspaceSurface = ({
|
||||||
@@ -14,6 +14,7 @@ export const useWorkspaceSurface = ({
|
|||||||
deskWorkspaceProps,
|
deskWorkspaceProps,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
|
previewDocumentId,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
@@ -36,7 +37,7 @@ export const useWorkspaceSurface = ({
|
|||||||
aria-label="Expand sidebar"
|
aria-label="Expand sidebar"
|
||||||
title="Expand sidebar"
|
title="Expand sidebar"
|
||||||
>
|
>
|
||||||
<ChevronsRightIcon />
|
<SidebarExpandIcon />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}, [sidebarCollapsed, onExpandSidebar]);
|
}, [sidebarCollapsed, onExpandSidebar]);
|
||||||
@@ -62,13 +63,28 @@ export const useWorkspaceSurface = ({
|
|||||||
detailPanelOpen,
|
detailPanelOpen,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const showPreviewWorkspace = viewMode !== 'desk' && Boolean(previewWorkspaceDocument);
|
const showPreviewWorkspace = Boolean(previewDocumentId);
|
||||||
|
|
||||||
const previewSurface = useMemo(() => {
|
const previewSurface = useMemo(() => {
|
||||||
if (!showPreviewWorkspace || !previewWorkspaceDocument) {
|
if (!showPreviewWorkspace) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return createPreviewSurface({
|
const detailExtras = detailPanelProps || {};
|
||||||
|
const {
|
||||||
|
tagLookupById,
|
||||||
|
tags: tagOptions,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
correspondents,
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued,
|
||||||
|
resolveFolderPath,
|
||||||
|
onFolderNavigate,
|
||||||
|
} = detailExtras;
|
||||||
|
return createDocumentViewerSurface({
|
||||||
|
documentId: previewDocumentId,
|
||||||
document: previewWorkspaceDocument,
|
document: previewWorkspaceDocument,
|
||||||
previewEntry: previewWorkspaceEntry,
|
previewEntry: previewWorkspaceEntry,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
@@ -79,10 +95,22 @@ export const useWorkspaceSurface = ({
|
|||||||
onRegenerate: handleThumbnailRegeneration,
|
onRegenerate: handleThumbnailRegeneration,
|
||||||
onClose: closeDocumentPreview,
|
onClose: closeDocumentPreview,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
|
tagLookupById,
|
||||||
|
tagOptions,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
correspondents,
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued,
|
||||||
|
resolveFolderPath,
|
||||||
|
onFolderNavigate,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
showPreviewWorkspace,
|
showPreviewWorkspace,
|
||||||
previewWorkspaceDocument,
|
previewWorkspaceDocument,
|
||||||
|
previewDocumentId,
|
||||||
previewWorkspaceEntry,
|
previewWorkspaceEntry,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
@@ -92,6 +120,7 @@ export const useWorkspaceSurface = ({
|
|||||||
handleThumbnailRegeneration,
|
handleThumbnailRegeneration,
|
||||||
closeDocumentPreview,
|
closeDocumentPreview,
|
||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
|
detailPanelProps,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const workspaceSurface = useMemo(() => {
|
const workspaceSurface = useMemo(() => {
|
||||||
@@ -103,8 +132,18 @@ export const useWorkspaceSurface = ({
|
|||||||
renderSidebarToggle,
|
renderSidebarToggle,
|
||||||
parentBreadcrumb,
|
parentBreadcrumb,
|
||||||
onNavigateParent: parentBreadcrumb ? onNavigateParent : null,
|
onNavigateParent: parentBreadcrumb ? onNavigateParent : null,
|
||||||
|
detailProps: detailPanelProps,
|
||||||
|
detailOpen: detailPanelOpen,
|
||||||
});
|
});
|
||||||
}, [viewMode, deskWorkspaceProps, renderSidebarToggle, parentBreadcrumb, onNavigateParent]);
|
}, [
|
||||||
|
viewMode,
|
||||||
|
deskWorkspaceProps,
|
||||||
|
renderSidebarToggle,
|
||||||
|
parentBreadcrumb,
|
||||||
|
onNavigateParent,
|
||||||
|
detailPanelProps,
|
||||||
|
detailPanelOpen,
|
||||||
|
]);
|
||||||
|
|
||||||
const surface = useMemo(() => {
|
const surface = useMemo(() => {
|
||||||
if (showPreviewWorkspace) {
|
if (showPreviewWorkspace) {
|
||||||
|
|||||||
@@ -2,9 +2,18 @@ import { useCallback, useRef } from 'react';
|
|||||||
import { useDesktopContext } from './context';
|
import { useDesktopContext } from './context';
|
||||||
import { preventAll } from './events';
|
import { preventAll } from './events';
|
||||||
import { clamp, formatTransform } from './math';
|
import { clamp, formatTransform } from './math';
|
||||||
|
import usePointerTap from '../ui/usePointerTap';
|
||||||
|
|
||||||
const DRAG_HYSTERESIS_PX = 4;
|
const DRAG_HYSTERESIS_PX = 4;
|
||||||
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX;
|
||||||
|
const MIN_TIMESTEP = 1 / 120;
|
||||||
|
const MAX_TIMESTEP = 1 / 20;
|
||||||
|
const MAX_DYNAMIC_ROTATION = 4;
|
||||||
|
const MAX_ANGULAR_VELOCITY = 180;
|
||||||
|
const ANGULAR_DAMPING = 11;
|
||||||
|
const TORQUE_TO_ACCELERATION = 0.006;
|
||||||
|
const SETTLE_ANGULAR_VELOCITY = 1.2;
|
||||||
|
const EDGE_COLLISION_THRESHOLD = 0.5;
|
||||||
|
|
||||||
const useDocumentDrag = () => {
|
const useDocumentDrag = () => {
|
||||||
const {
|
const {
|
||||||
@@ -20,11 +29,207 @@ const useDocumentDrag = () => {
|
|||||||
openOverlayForDoc,
|
openOverlayForDoc,
|
||||||
recalcVisibleDocIds,
|
recalcVisibleDocIds,
|
||||||
settings,
|
settings,
|
||||||
|
containerRef,
|
||||||
|
onDocumentOpen,
|
||||||
|
onInspectDocument,
|
||||||
|
onDocumentStackSelect,
|
||||||
|
selectedDocumentIds,
|
||||||
|
markLayoutDirty,
|
||||||
} = useDesktopContext();
|
} = useDesktopContext();
|
||||||
|
|
||||||
|
const applyTransform = useCallback(
|
||||||
|
(docId, centerX, centerY, width, height, rotation, scale = 1) => {
|
||||||
|
const node = itemRefs.current.get(docId);
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.style.transform = formatTransform(
|
||||||
|
centerX - width / 2,
|
||||||
|
centerY - height / 2,
|
||||||
|
rotation,
|
||||||
|
scale,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[itemRefs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalizeGroupDrag = useCallback(
|
||||||
|
(dragState) => {
|
||||||
|
if (!dragState?.groupItems) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragState.groupItems.forEach((item) => {
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryItem = layoutRef.current.get(item.docId) || {};
|
||||||
|
const centerX = item.currentCenterX ?? entryItem.centerX ?? dragState.originCenterX;
|
||||||
|
const centerY = item.currentCenterY ?? entryItem.centerY ?? dragState.originCenterY;
|
||||||
|
const rotation = item.displayRotation ?? entryItem.rotation ?? 0;
|
||||||
|
|
||||||
|
layoutRef.current.set(item.docId, {
|
||||||
|
...entryItem,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
rotation,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyTransform(
|
||||||
|
item.docId,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
item.width,
|
||||||
|
item.height,
|
||||||
|
rotation,
|
||||||
|
item.docId === dragState.docKey ? dragState.dragScale || 1 : 1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
markLayoutDirty?.();
|
||||||
|
},
|
||||||
|
[applyTransform, layoutRef, markLayoutDirty],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tapHandler = usePointerTap({
|
||||||
|
delay: 220,
|
||||||
|
onSingle: ({ data, event }) => {
|
||||||
|
if (!data || !data.docId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof onInspectDocument === 'function') {
|
||||||
|
onInspectDocument(data.docId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDocumentOpen?.(data.docId);
|
||||||
|
},
|
||||||
|
onDouble: ({ data, event }) => {
|
||||||
|
if (!data || !data.docId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openOverlayForDoc(data.docId, data.originInfo);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const dragStateRef = useRef(null);
|
const dragStateRef = useRef(null);
|
||||||
|
const inertiaAnimationsRef = useRef(new Map());
|
||||||
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
|
const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings;
|
||||||
|
|
||||||
|
const cancelInertiaAnimation = useCallback((docId) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
inertiaAnimationsRef.current.delete(docId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = inertiaAnimationsRef.current.get(docId);
|
||||||
|
if (existing && typeof window.cancelAnimationFrame === 'function') {
|
||||||
|
window.cancelAnimationFrame(existing.frameId);
|
||||||
|
}
|
||||||
|
inertiaAnimationsRef.current.delete(docId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const integrateRotation = useCallback(
|
||||||
|
(simulationState, dt, torque = 0, dampingOverride = null) => {
|
||||||
|
const { docId } = simulationState;
|
||||||
|
const entry = layoutRef.current.get(docId);
|
||||||
|
if (!entry) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerX = Number(entry.centerX);
|
||||||
|
const centerY = Number(entry.centerY);
|
||||||
|
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const torqueAcceleration = torque * TORQUE_TO_ACCELERATION;
|
||||||
|
let angularVelocity = simulationState.angularVelocity + torqueAcceleration * dt;
|
||||||
|
angularVelocity = clamp(angularVelocity, -MAX_ANGULAR_VELOCITY, MAX_ANGULAR_VELOCITY);
|
||||||
|
|
||||||
|
const dampingConstant = typeof dampingOverride === 'number' ? dampingOverride : ANGULAR_DAMPING;
|
||||||
|
const dampingFactor = Math.exp(-dampingConstant * dt);
|
||||||
|
angularVelocity *= dampingFactor;
|
||||||
|
|
||||||
|
let dynamicRotation = simulationState.dynamicRotation + angularVelocity * dt;
|
||||||
|
if (dynamicRotation > MAX_DYNAMIC_ROTATION) {
|
||||||
|
dynamicRotation = MAX_DYNAMIC_ROTATION;
|
||||||
|
angularVelocity = Math.min(angularVelocity, 0);
|
||||||
|
} else if (dynamicRotation < -MAX_DYNAMIC_ROTATION) {
|
||||||
|
dynamicRotation = -MAX_DYNAMIC_ROTATION;
|
||||||
|
angularVelocity = Math.max(angularVelocity, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
simulationState.angularVelocity = angularVelocity;
|
||||||
|
simulationState.dynamicRotation = dynamicRotation;
|
||||||
|
simulationState.rotation = simulationState.restRotation + dynamicRotation;
|
||||||
|
|
||||||
|
const rotation = simulationState.rotation;
|
||||||
|
layoutRef.current.set(docId, { ...entry, rotation });
|
||||||
|
|
||||||
|
const node = itemRefs.current.get(docId);
|
||||||
|
if (node) {
|
||||||
|
node.style.transform = formatTransform(
|
||||||
|
centerX - simulationState.width / 2,
|
||||||
|
centerY - simulationState.height / 2,
|
||||||
|
rotation,
|
||||||
|
simulationState.dragScale || 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSettled = Math.abs(angularVelocity) < SETTLE_ANGULAR_VELOCITY;
|
||||||
|
return isSettled;
|
||||||
|
},
|
||||||
|
[itemRefs, layoutRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startInertiaAnimation = useCallback(
|
||||||
|
(docId, baseState) => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelInertiaAnimation(docId);
|
||||||
|
const now =
|
||||||
|
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
const simulationState = {
|
||||||
|
...baseState,
|
||||||
|
docId,
|
||||||
|
dragScale: baseState.dragScale || 1,
|
||||||
|
lastTimestamp: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const step = (timestamp) => {
|
||||||
|
const safeTimestamp = Number.isFinite(timestamp) ? timestamp : now + 16;
|
||||||
|
const previous = simulationState.lastTimestamp;
|
||||||
|
let dt = (safeTimestamp - previous) / 1000;
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0) {
|
||||||
|
dt = MIN_TIMESTEP;
|
||||||
|
}
|
||||||
|
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
|
||||||
|
simulationState.lastTimestamp = safeTimestamp;
|
||||||
|
|
||||||
|
const settled = integrateRotation(simulationState, dt, 0);
|
||||||
|
if (settled) {
|
||||||
|
inertiaAnimationsRef.current.delete(docId);
|
||||||
|
syncLayoutSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
simulationState.frameId = window.requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
simulationState.frameId = window.requestAnimationFrame(step);
|
||||||
|
inertiaAnimationsRef.current.set(docId, simulationState);
|
||||||
|
},
|
||||||
|
[cancelInertiaAnimation, integrateRotation, syncLayoutSnapshot],
|
||||||
|
);
|
||||||
|
|
||||||
const finishDrag = useCallback(
|
const finishDrag = useCallback(
|
||||||
(pointerId) => {
|
(pointerId) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
@@ -49,11 +254,11 @@ const useDocumentDrag = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(event, docId) => {
|
(event, docIdInput, options = {}) => {
|
||||||
if (debugDrag) {
|
if (debugDrag) {
|
||||||
console.log(
|
console.log(
|
||||||
'[desk] handlePointerDown fired for doc',
|
'[desk] handlePointerDown fired for doc',
|
||||||
docId,
|
docIdInput,
|
||||||
'button',
|
'button',
|
||||||
event.button,
|
event.button,
|
||||||
'pointerType',
|
'pointerType',
|
||||||
@@ -63,21 +268,91 @@ const useDocumentDrag = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
preventAll(event);
|
preventAll(event);
|
||||||
|
|
||||||
|
const docId = docIdInput != null ? docIdInput : null;
|
||||||
const docKey = docId != null ? String(docId) : null;
|
const docKey = docId != null ? String(docId) : null;
|
||||||
const doc = docKey ? documentLookup.get(docKey) : null;
|
if (!docKey) {
|
||||||
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelInertiaAnimation(docId);
|
||||||
|
|
||||||
|
const doc = documentLookup.get(docKey);
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stackDocIdsOption = Array.isArray(options?.stackDocIds)
|
||||||
|
? options.stackDocIds
|
||||||
|
.map((value) => (value != null ? String(value) : null))
|
||||||
|
.filter(Boolean)
|
||||||
|
: null;
|
||||||
|
const stackSelectionAppliedInitial = Boolean(options?.stackSelectionApplied);
|
||||||
|
|
||||||
|
let selectionIds = Array.isArray(selectedDocumentIds)
|
||||||
|
? selectedDocumentIds.map((id) => String(id))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (stackDocIdsOption && stackDocIdsOption.length) {
|
||||||
|
selectionIds = stackDocIdsOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaOrCtrl = event.metaKey || event.ctrlKey;
|
||||||
|
if (!stackDocIdsOption && metaOrCtrl && !selectionIds.includes(docKey)) {
|
||||||
|
selectionIds = [...selectionIds, docKey];
|
||||||
|
}
|
||||||
|
let groupDocIds = [];
|
||||||
|
if (stackDocIdsOption && stackDocIdsOption.length) {
|
||||||
|
groupDocIds = stackDocIdsOption.filter((id, index, array) => {
|
||||||
|
const unique = array.indexOf(id) === index;
|
||||||
|
return unique && documentLookup.has(id);
|
||||||
|
});
|
||||||
|
} else if (selectionIds.includes(docKey) && selectionIds.length > 1) {
|
||||||
|
groupDocIds = selectionIds
|
||||||
|
.map((id) => String(id))
|
||||||
|
.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
|
||||||
|
}
|
||||||
|
if (!groupDocIds.includes(docKey)) {
|
||||||
|
groupDocIds.unshift(docKey);
|
||||||
|
}
|
||||||
|
groupDocIds = groupDocIds.filter((id, index, array) => array.indexOf(id) === index);
|
||||||
|
if (!groupDocIds.length) {
|
||||||
|
groupDocIds = [docKey];
|
||||||
|
}
|
||||||
|
const isGroupDrag = groupDocIds.length > 1;
|
||||||
|
|
||||||
|
if (isGroupDrag) {
|
||||||
|
groupDocIds.forEach((id) => {
|
||||||
|
if (id !== docKey) {
|
||||||
|
cancelInertiaAnimation(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeInfo = ensureDocumentSize(doc) || { width: 0, height: 0 };
|
||||||
|
const docWidth = sizeInfo.width || 320;
|
||||||
|
const docHeight = sizeInfo.height || 240;
|
||||||
const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight);
|
const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight);
|
||||||
const normalizedBaseScale =
|
const normalizedBaseScale =
|
||||||
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
|
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
|
||||||
|
|
||||||
|
const entry = layoutRef.current.get(docKey) || null;
|
||||||
const defaultCenterX = canvasPadding + docWidth / 2;
|
const defaultCenterX = canvasPadding + docWidth / 2;
|
||||||
const defaultCenterY = canvasPadding + docHeight / 2;
|
const defaultCenterY = canvasPadding + docHeight / 2;
|
||||||
bringToFront(docId);
|
|
||||||
const entry = layoutRef.current.get(docId) || null;
|
|
||||||
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
|
const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX;
|
||||||
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
|
const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY;
|
||||||
|
|
||||||
|
const modifierPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||||
|
if (!modifierPressed) {
|
||||||
|
if (isGroupDrag) {
|
||||||
|
groupDocIds.forEach((id) => bringToFront(id));
|
||||||
|
} else {
|
||||||
|
bringToFront(docId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
|
if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) {
|
||||||
layoutRef.current.set(docId, { ...entry, centerX, centerY });
|
layoutRef.current.set(docKey, { ...entry, centerX, centerY });
|
||||||
}
|
}
|
||||||
|
|
||||||
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||||
@@ -90,14 +365,73 @@ const useDocumentDrag = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const containerRect = containerRef?.current?.getBoundingClientRect?.() || null;
|
||||||
|
const containerLeft = containerRect?.left || 0;
|
||||||
|
const containerTop = containerRect?.top || 0;
|
||||||
|
const pointerCanvasX = event.clientX - containerLeft;
|
||||||
|
const pointerCanvasY = event.clientY - containerTop;
|
||||||
|
const pointerOffsetX = pointerCanvasX - centerX;
|
||||||
|
const pointerOffsetY = pointerCanvasY - centerY;
|
||||||
|
const initialRotationDeg = entry?.rotation ?? 0;
|
||||||
|
const initialRotationRad = (initialRotationDeg * Math.PI) / 180;
|
||||||
|
const cosInitial = Math.cos(-initialRotationRad);
|
||||||
|
const sinInitial = Math.sin(-initialRotationRad);
|
||||||
|
const localPointerOffsetX = pointerOffsetX * cosInitial - pointerOffsetY * sinInitial;
|
||||||
|
const localPointerOffsetY = pointerOffsetX * sinInitial + pointerOffsetY * cosInitial;
|
||||||
|
|
||||||
|
const stackRandom = () => Math.random();
|
||||||
|
const groupItems = groupDocIds.map((id, index) => {
|
||||||
|
const itemDoc = documentLookup.get(id);
|
||||||
|
const itemSize = ensureDocumentSize(itemDoc) || sizeInfo;
|
||||||
|
const itemWidth = itemSize.width || docWidth;
|
||||||
|
const itemHeight = itemSize.height || docHeight;
|
||||||
|
const itemEntry = layoutRef.current.get(id) || null;
|
||||||
|
const itemCenterX =
|
||||||
|
typeof itemEntry?.centerX === 'number' ? itemEntry.centerX : canvasPadding + itemWidth / 2;
|
||||||
|
const itemCenterY =
|
||||||
|
typeof itemEntry?.centerY === 'number' ? itemEntry.centerY : canvasPadding + itemHeight / 2;
|
||||||
|
const radius = index === 0 ? 0 : 24 + index * 8;
|
||||||
|
const offsetAngle = (index * 1.618 + stackRandom() * 0.5) * Math.PI;
|
||||||
|
const offsetX = Math.cos(offsetAngle) * radius;
|
||||||
|
const offsetY = Math.sin(offsetAngle) * radius;
|
||||||
|
const initialRotation = itemEntry?.rotation ?? 0;
|
||||||
|
const targetRotation = initialRotation;
|
||||||
|
return {
|
||||||
|
docId: id,
|
||||||
|
width: itemWidth,
|
||||||
|
height: itemHeight,
|
||||||
|
currentCenterX: itemCenterX,
|
||||||
|
currentCenterY: itemCenterY,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
initialRotation,
|
||||||
|
displayRotation: initialRotation,
|
||||||
|
targetRotation,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventTimestamp =
|
||||||
|
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
|
||||||
|
? event.timeStamp
|
||||||
|
: typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
const hasStackSource = Array.isArray(stackDocIdsOption) && stackDocIdsOption.length > 1;
|
||||||
|
|
||||||
dragStateRef.current = {
|
dragStateRef.current = {
|
||||||
docId,
|
docId,
|
||||||
|
docKey,
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
originCenterX: centerX,
|
originCenterX: centerX,
|
||||||
originCenterY: centerY,
|
originCenterY: centerY,
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
rotation: entry?.rotation ?? 0,
|
rotation: entry?.rotation ?? 0,
|
||||||
|
restRotation: entry?.rotation ?? 0,
|
||||||
|
dynamicRotation: 0,
|
||||||
|
angularVelocity: 0,
|
||||||
moved: false,
|
moved: false,
|
||||||
locked: false,
|
locked: false,
|
||||||
width: docWidth,
|
width: docWidth,
|
||||||
@@ -105,42 +439,225 @@ const useDocumentDrag = () => {
|
|||||||
dragScale: 1,
|
dragScale: 1,
|
||||||
baseScale: normalizedBaseScale,
|
baseScale: normalizedBaseScale,
|
||||||
capturedTarget,
|
capturedTarget,
|
||||||
|
lastClientX: event.clientX,
|
||||||
|
lastClientY: event.clientY,
|
||||||
|
lastTimestamp: eventTimestamp,
|
||||||
|
localPointerOffsetX,
|
||||||
|
localPointerOffsetY,
|
||||||
|
containerRectLeft: containerLeft,
|
||||||
|
containerRectTop: containerTop,
|
||||||
|
isGroup: isGroupDrag,
|
||||||
|
groupDocIds,
|
||||||
|
groupItems,
|
||||||
|
groupElevated: !isGroupDrag,
|
||||||
|
stackDocIds: hasStackSource ? stackDocIdsOption : null,
|
||||||
|
stackSelectionApplied: stackSelectionAppliedInitial || !hasStackSource,
|
||||||
};
|
};
|
||||||
setDraggingId(docId);
|
|
||||||
},
|
setDraggingId(docId);
|
||||||
[
|
|
||||||
bringToFront,
|
if (isGroupDrag) {
|
||||||
|
groupItems.forEach((item) => {
|
||||||
|
if (item.docId === docKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const node = itemRefs.current.get(item.docId);
|
||||||
|
if (node) {
|
||||||
|
item.displayRotation = item.initialRotation;
|
||||||
|
node.style.transform = formatTransform(
|
||||||
|
item.currentCenterX - item.width / 2,
|
||||||
|
item.currentCenterY - item.height / 2,
|
||||||
|
item.displayRotation,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
bringToFront,
|
||||||
canvasPadding,
|
canvasPadding,
|
||||||
|
cancelInertiaAnimation,
|
||||||
|
containerRef,
|
||||||
documentLookup,
|
documentLookup,
|
||||||
ensureDocumentSize,
|
ensureDocumentSize,
|
||||||
layoutRef,
|
layoutRef,
|
||||||
resolveBaseMetrics,
|
resolveBaseMetrics,
|
||||||
setDraggingId,
|
selectedDocumentIds,
|
||||||
debugDrag,
|
setDraggingId,
|
||||||
],
|
debugDrag,
|
||||||
);
|
itemRefs,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
const handlePointerMove = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (!state) {
|
if (!state) {
|
||||||
if (debugDrag) {
|
if (debugDrag) {
|
||||||
console.log('[desk] handlePointerMove: no drag state for pointer', event.pointerId);
|
console.log('[desk] handlePointerMove: no drag state for pointer', event.pointerId);
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (state.pointerId !== event.pointerId) {
|
return;
|
||||||
if (debugDrag) {
|
}
|
||||||
console.log(
|
if (state.pointerId !== event.pointerId) {
|
||||||
'[desk] handlePointerMove: pointer mismatch expected',
|
if (debugDrag) {
|
||||||
state.pointerId,
|
console.log(
|
||||||
'got',
|
'[desk] handlePointerMove: pointer mismatch expected',
|
||||||
event.pointerId,
|
state.pointerId,
|
||||||
);
|
'got',
|
||||||
}
|
event.pointerId,
|
||||||
return;
|
);
|
||||||
}
|
}
|
||||||
preventAll(event);
|
return;
|
||||||
|
}
|
||||||
|
preventAll(event);
|
||||||
|
|
||||||
|
if (state.isGroup) {
|
||||||
|
const containerRect = containerRef?.current?.getBoundingClientRect?.();
|
||||||
|
if (containerRect) {
|
||||||
|
state.containerRectLeft = containerRect.left;
|
||||||
|
state.containerRectTop = containerRect.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointerCanvasX = event.clientX - state.containerRectLeft;
|
||||||
|
const pointerCanvasY = event.clientY - state.containerRectTop;
|
||||||
|
const deltaX = event.clientX - state.startX;
|
||||||
|
const deltaY = event.clientY - state.startY;
|
||||||
|
|
||||||
|
if (!state.moved) {
|
||||||
|
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||||
|
if (distanceSquared < DRAG_HYSTERESIS_SQUARED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.moved = true;
|
||||||
|
if (
|
||||||
|
state.isGroup
|
||||||
|
&& !state.stackSelectionApplied
|
||||||
|
&& Array.isArray(state.stackDocIds)
|
||||||
|
&& state.stackDocIds.length > 1
|
||||||
|
) {
|
||||||
|
if (typeof onDocumentStackSelect === 'function') {
|
||||||
|
onDocumentStackSelect(state.stackDocIds);
|
||||||
|
}
|
||||||
|
state.stackSelectionApplied = true;
|
||||||
|
}
|
||||||
|
if (!state.groupElevated) {
|
||||||
|
const layout = layoutRef.current;
|
||||||
|
const sortedGroup = state.groupDocIds
|
||||||
|
.filter((id) => id !== state.docKey)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aZ = layout.get(a)?.z ?? 0;
|
||||||
|
const bZ = layout.get(b)?.z ?? 0;
|
||||||
|
return aZ - bZ;
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedGroup.forEach((id) => {
|
||||||
|
bringToFront(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
bringToFront(state.docId);
|
||||||
|
state.groupElevated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const docWidth = state.width;
|
||||||
|
const docHeight = state.height;
|
||||||
|
const halfWidth = docWidth / 2;
|
||||||
|
const halfHeight = docHeight / 2;
|
||||||
|
const canvasWidth = canvasSize.width || defaultCanvasWidth;
|
||||||
|
const canvasHeight = canvasSize.height || defaultCanvasHeight;
|
||||||
|
const minCenterX = canvasPadding + halfWidth;
|
||||||
|
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
|
||||||
|
const minCenterY = canvasPadding + halfHeight;
|
||||||
|
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
|
||||||
|
|
||||||
|
const desiredCenterX = pointerCanvasX - state.localPointerOffsetX;
|
||||||
|
const desiredCenterY = pointerCanvasY - state.localPointerOffsetY;
|
||||||
|
const centerX = clamp(desiredCenterX, minCenterX, maxCenterX);
|
||||||
|
const centerY = clamp(desiredCenterY, minCenterY, maxCenterY);
|
||||||
|
|
||||||
|
const primaryEntry = layoutRef.current.get(state.docKey) || {};
|
||||||
|
const primaryRotation = state.rotation ?? primaryEntry.rotation ?? 0;
|
||||||
|
layoutRef.current.set(state.docKey, {
|
||||||
|
...primaryEntry,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
});
|
||||||
|
const primaryNode = itemRefs.current.get(state.docId);
|
||||||
|
if (primaryNode) {
|
||||||
|
primaryNode.style.transform = formatTransform(
|
||||||
|
centerX - docWidth / 2,
|
||||||
|
centerY - docHeight / 2,
|
||||||
|
primaryRotation,
|
||||||
|
state.dragScale || 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.groupItems.forEach((item) => {
|
||||||
|
const isPrimary = item.docId === state.docKey;
|
||||||
|
|
||||||
|
if (isPrimary) {
|
||||||
|
item.currentCenterX = centerX;
|
||||||
|
item.currentCenterY = centerY;
|
||||||
|
item.offsetX *= 0.92;
|
||||||
|
item.offsetY *= 0.92;
|
||||||
|
item.displayRotation = state.rotation ?? item.displayRotation ?? 0;
|
||||||
|
} else {
|
||||||
|
item.offsetX *= 0.92;
|
||||||
|
item.offsetY *= 0.92;
|
||||||
|
if (Math.abs(item.offsetX) < 1) item.offsetX = 0;
|
||||||
|
if (Math.abs(item.offsetY) < 1) item.offsetY = 0;
|
||||||
|
|
||||||
|
const targetX = centerX + item.offsetX;
|
||||||
|
const targetY = centerY + item.offsetY;
|
||||||
|
const smoothing = 0.18;
|
||||||
|
item.currentCenterX += (targetX - item.currentCenterX) * smoothing;
|
||||||
|
item.currentCenterY += (targetY - item.currentCenterY) * smoothing;
|
||||||
|
|
||||||
|
const halfW = item.width / 2;
|
||||||
|
const halfH = item.height / 2;
|
||||||
|
const minX = canvasPadding + halfW;
|
||||||
|
const maxX = Math.max(minX, canvasWidth - canvasPadding - halfW);
|
||||||
|
const minY = canvasPadding + halfH;
|
||||||
|
const maxY = Math.max(minY, canvasHeight - canvasPadding - halfH);
|
||||||
|
item.currentCenterX = clamp(item.currentCenterX, minX, maxX);
|
||||||
|
item.currentCenterY = clamp(item.currentCenterY, minY, maxY);
|
||||||
|
|
||||||
|
const rotationBlend = 0.16;
|
||||||
|
item.displayRotation += (item.targetRotation - item.displayRotation) * rotationBlend;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryItem = layoutRef.current.get(item.docId) || {};
|
||||||
|
layoutRef.current.set(item.docId, {
|
||||||
|
...entryItem,
|
||||||
|
centerX: item.currentCenterX,
|
||||||
|
centerY: item.currentCenterY,
|
||||||
|
rotation: item.displayRotation ?? entryItem.rotation ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyTransform(
|
||||||
|
item.docId,
|
||||||
|
item.currentCenterX,
|
||||||
|
item.currentCenterY,
|
||||||
|
item.width,
|
||||||
|
item.height,
|
||||||
|
item.displayRotation ?? entryItem.rotation ?? 0,
|
||||||
|
isPrimary ? state.dragScale || 1 : 1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.lastClientX = event.clientX;
|
||||||
|
state.lastClientY = event.clientY;
|
||||||
|
state.lastTimestamp =
|
||||||
|
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
|
||||||
|
? event.timeStamp
|
||||||
|
: typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
recalcVisibleDocIds();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (state.locked) {
|
if (state.locked) {
|
||||||
if (debugDrag) {
|
if (debugDrag) {
|
||||||
console.log('[desk] handlePointerMove: locked drag for doc', state.docId);
|
console.log('[desk] handlePointerMove: locked drag for doc', state.docId);
|
||||||
@@ -155,21 +672,59 @@ const useDocumentDrag = () => {
|
|||||||
|
|
||||||
const deltaX = event.clientX - state.startX;
|
const deltaX = event.clientX - state.startX;
|
||||||
const deltaY = event.clientY - state.startY;
|
const deltaY = event.clientY - state.startY;
|
||||||
const nextCenterX = state.originCenterX + deltaX;
|
|
||||||
const nextCenterY = state.originCenterY + deltaY;
|
|
||||||
|
|
||||||
const docWidth = state.width;
|
const docWidth = state.width;
|
||||||
const docHeight = state.height;
|
const docHeight = state.height;
|
||||||
const halfWidth = docWidth / 2;
|
const halfWidth = docWidth / 2;
|
||||||
const halfHeight = docHeight / 2;
|
const halfHeight = docHeight / 2;
|
||||||
|
|
||||||
|
const containerRect = containerRef?.current?.getBoundingClientRect?.();
|
||||||
|
if (containerRect) {
|
||||||
|
state.containerRectLeft = containerRect.left;
|
||||||
|
state.containerRectTop = containerRect.top;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerLeft = state.containerRectLeft;
|
||||||
|
const containerTop = state.containerRectTop;
|
||||||
|
const pointerCanvasX = event.clientX - containerLeft;
|
||||||
|
const pointerCanvasY = event.clientY - containerTop;
|
||||||
|
|
||||||
|
const rotationDeg = entry?.rotation ?? 0;
|
||||||
|
const rotationRad = (rotationDeg * Math.PI) / 180;
|
||||||
|
const cosRot = Math.cos(rotationRad);
|
||||||
|
const sinRot = Math.sin(rotationRad);
|
||||||
|
const rotatedOffsetX =
|
||||||
|
state.localPointerOffsetX * cosRot - state.localPointerOffsetY * sinRot;
|
||||||
|
const rotatedOffsetY =
|
||||||
|
state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot;
|
||||||
|
|
||||||
|
const previousCenterX = Number.isFinite(entry.centerX) ? entry.centerX : state.originCenterX;
|
||||||
|
const previousCenterY = Number.isFinite(entry.centerY) ? entry.centerY : state.originCenterY;
|
||||||
|
|
||||||
|
const absCos = Math.abs(cosRot);
|
||||||
|
const absSin = Math.abs(sinRot);
|
||||||
|
const rotatedHalfWidth = absCos * halfWidth + absSin * halfHeight;
|
||||||
|
const rotatedHalfHeight = absSin * halfWidth + absCos * halfHeight;
|
||||||
const canvasWidth = canvasSize.width || defaultCanvasWidth;
|
const canvasWidth = canvasSize.width || defaultCanvasWidth;
|
||||||
const canvasHeight = canvasSize.height || defaultCanvasHeight;
|
const canvasHeight = canvasSize.height || defaultCanvasHeight;
|
||||||
const minCenterX = canvasPadding + halfWidth;
|
const minCenterX = canvasPadding + rotatedHalfWidth;
|
||||||
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - halfWidth);
|
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - rotatedHalfWidth);
|
||||||
const minCenterY = canvasPadding + halfHeight;
|
const minCenterY = canvasPadding + rotatedHalfHeight;
|
||||||
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - halfHeight);
|
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - rotatedHalfHeight);
|
||||||
const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
|
|
||||||
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
|
const pointerRelativePrevX = pointerCanvasX - previousCenterX;
|
||||||
|
const pointerRelativePrevY = pointerCanvasY - previousCenterY;
|
||||||
|
const cosInversePrev = Math.cos(-rotationRad);
|
||||||
|
const sinInversePrev = Math.sin(-rotationRad);
|
||||||
|
const pointerLocalPrevX = pointerRelativePrevX * cosInversePrev - pointerRelativePrevY * sinInversePrev;
|
||||||
|
const pointerLocalPrevY = pointerRelativePrevX * sinInversePrev + pointerRelativePrevY * cosInversePrev;
|
||||||
|
const pointerInsideRelativeToPrev =
|
||||||
|
Math.abs(pointerLocalPrevX) <= halfWidth && Math.abs(pointerLocalPrevY) <= halfHeight;
|
||||||
|
|
||||||
|
const desiredCenterX = pointerCanvasX - rotatedOffsetX;
|
||||||
|
const desiredCenterY = pointerCanvasY - rotatedOffsetY;
|
||||||
|
const clampedCenterX = clamp(desiredCenterX, minCenterX, maxCenterX);
|
||||||
|
const clampedCenterY = clamp(desiredCenterY, minCenterY, maxCenterY);
|
||||||
|
|
||||||
if (!state.moved) {
|
if (!state.moved) {
|
||||||
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||||
@@ -180,20 +735,83 @@ const useDocumentDrag = () => {
|
|||||||
state.moved = true;
|
state.moved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY };
|
const collidedWithHorizontalEdge =
|
||||||
|
Math.abs(desiredCenterY - clampedCenterY) > EDGE_COLLISION_THRESHOLD;
|
||||||
|
const collidedWithVerticalEdge =
|
||||||
|
Math.abs(desiredCenterX - clampedCenterX) > EDGE_COLLISION_THRESHOLD;
|
||||||
|
const collidedWithEdge = collidedWithHorizontalEdge || collidedWithVerticalEdge;
|
||||||
|
|
||||||
|
let currentCenterX = clampedCenterX;
|
||||||
|
let currentCenterY = clampedCenterY;
|
||||||
|
if (collidedWithEdge && !pointerInsideRelativeToPrev) {
|
||||||
|
currentCenterX = previousCenterX;
|
||||||
|
currentCenterY = previousCenterY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY };
|
||||||
layoutRef.current.set(state.docId, updated);
|
layoutRef.current.set(state.docId, updated);
|
||||||
|
|
||||||
const node = itemRefs.current.get(state.docId);
|
applyTransform(
|
||||||
if (node) {
|
state.docId,
|
||||||
node.style.transform = formatTransform(
|
currentCenterX,
|
||||||
clampedCenterX - state.width / 2,
|
currentCenterY,
|
||||||
clampedCenterY - state.height / 2,
|
state.width,
|
||||||
state.rotation,
|
state.height,
|
||||||
|
rotationDeg,
|
||||||
|
state.dragScale || 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryNode = itemRefs.current.get(state.docId);
|
||||||
|
if (primaryNode) {
|
||||||
|
primaryNode.style.transform = formatTransform(
|
||||||
|
currentCenterX - state.width / 2,
|
||||||
|
currentCenterY - state.height / 2,
|
||||||
|
rotationDeg,
|
||||||
state.dragScale || 1,
|
state.dragScale || 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const offsetX = pointerCanvasX - currentCenterX;
|
||||||
|
const offsetY = pointerCanvasY - currentCenterY;
|
||||||
|
const cosInverseCurrent = Math.cos(-rotationRad);
|
||||||
|
const sinInverseCurrent = Math.sin(-rotationRad);
|
||||||
|
const pointerLocalX = offsetX * cosInverseCurrent - offsetY * sinInverseCurrent;
|
||||||
|
const pointerLocalY = offsetX * sinInverseCurrent + offsetY * cosInverseCurrent;
|
||||||
|
const pointerInsideCard =
|
||||||
|
Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight;
|
||||||
|
|
||||||
|
const currentTimestamp =
|
||||||
|
typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp)
|
||||||
|
? event.timeStamp
|
||||||
|
: typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
const previousTimestamp = state.lastTimestamp ?? currentTimestamp;
|
||||||
|
let dt = (currentTimestamp - previousTimestamp) / 1000;
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0) {
|
||||||
|
dt = MIN_TIMESTEP;
|
||||||
|
}
|
||||||
|
dt = clamp(dt, MIN_TIMESTEP, MAX_TIMESTEP);
|
||||||
|
|
||||||
|
state.lastClientX = event.clientX;
|
||||||
|
state.lastClientY = event.clientY;
|
||||||
|
state.lastTimestamp = currentTimestamp;
|
||||||
|
|
||||||
|
const rotationForOffsetDeg = state.rotation || 0;
|
||||||
|
const rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180;
|
||||||
|
const cosInverse = Math.cos(-rotationForOffsetRad);
|
||||||
|
const sinInverse = Math.sin(-rotationForOffsetRad);
|
||||||
|
const pointerRelativeX = pointerCanvasX - currentCenterX;
|
||||||
|
const pointerRelativeY = pointerCanvasY - currentCenterY;
|
||||||
|
const updatedLocalOffsetX = pointerRelativeX * cosInverse - pointerRelativeY * sinInverse;
|
||||||
|
const updatedLocalOffsetY = pointerRelativeX * sinInverse + pointerRelativeY * cosInverse;
|
||||||
|
if (!collidedWithEdge || pointerInsideCard) {
|
||||||
|
state.localPointerOffsetX = updatedLocalOffsetX;
|
||||||
|
state.localPointerOffsetY = updatedLocalOffsetY;
|
||||||
|
}
|
||||||
|
|
||||||
if (debugDrag) {
|
if (debugDrag) {
|
||||||
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
|
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY);
|
||||||
}
|
}
|
||||||
recalcVisibleDocIds();
|
recalcVisibleDocIds();
|
||||||
},
|
},
|
||||||
@@ -204,44 +822,106 @@ const useDocumentDrag = () => {
|
|||||||
canvasSize.width,
|
canvasSize.width,
|
||||||
defaultCanvasHeight,
|
defaultCanvasHeight,
|
||||||
defaultCanvasWidth,
|
defaultCanvasWidth,
|
||||||
itemRefs,
|
containerRef,
|
||||||
layoutRef,
|
layoutRef,
|
||||||
|
itemRefs,
|
||||||
|
applyTransform,
|
||||||
recalcVisibleDocIds,
|
recalcVisibleDocIds,
|
||||||
debugDrag,
|
debugDrag,
|
||||||
|
onDocumentStackSelect,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerUp = useCallback(
|
const handlePointerUp = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
const state = dragStateRef.current;
|
const state = dragStateRef.current;
|
||||||
if (state && state.pointerId === event.pointerId) {
|
if (!state || state.pointerId !== event.pointerId) {
|
||||||
if (state.moved) {
|
|
||||||
finishDrag(event.pointerId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const docId = state.docId;
|
|
||||||
bringToFront(docId);
|
|
||||||
const originInfo = {
|
|
||||||
rotation: state.rotation || 0,
|
|
||||||
scale: state.baseScale || 1,
|
|
||||||
width: state.width,
|
|
||||||
height: state.height,
|
|
||||||
};
|
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
openOverlayForDoc(docId, originInfo);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.isGroup) {
|
||||||
|
finalizeGroupDrag(state);
|
||||||
|
finishDrag(event.pointerId);
|
||||||
|
recalcVisibleDocIds();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.moved) {
|
||||||
|
const inertiaState = {
|
||||||
|
restRotation: state.restRotation,
|
||||||
|
dynamicRotation: state.dynamicRotation,
|
||||||
|
angularVelocity: state.angularVelocity,
|
||||||
|
rotation: state.rotation,
|
||||||
|
width: state.width,
|
||||||
|
height: state.height,
|
||||||
|
dragScale: state.dragScale || 1,
|
||||||
|
};
|
||||||
|
const docId = state.docId;
|
||||||
|
finishDrag(event.pointerId);
|
||||||
|
startInertiaAnimation(docId, inertiaState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docId = state.docId;
|
||||||
|
const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
||||||
|
if (!metaPressed) {
|
||||||
|
bringToFront(docId);
|
||||||
|
}
|
||||||
|
const originInfo = {
|
||||||
|
rotation: state.rotation || 0,
|
||||||
|
scale: state.baseScale || 1,
|
||||||
|
width: state.width,
|
||||||
|
height: state.height,
|
||||||
|
};
|
||||||
|
const docKey = docId != null ? String(docId) : null;
|
||||||
|
const doc = docKey ? documentLookup.get(docKey) : null;
|
||||||
|
tapHandler(event, {
|
||||||
|
docId,
|
||||||
|
originInfo,
|
||||||
|
docTitle: doc?.title || 'document',
|
||||||
|
});
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
},
|
},
|
||||||
[bringToFront, finishDrag, openOverlayForDoc],
|
[
|
||||||
|
bringToFront,
|
||||||
|
documentLookup,
|
||||||
|
finishDrag,
|
||||||
|
finalizeGroupDrag,
|
||||||
|
startInertiaAnimation,
|
||||||
|
recalcVisibleDocIds,
|
||||||
|
tapHandler,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerCancel = useCallback(
|
const handlePointerCancel = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
|
const state = dragStateRef.current;
|
||||||
|
if (state && state.pointerId === event.pointerId && state.moved) {
|
||||||
|
if (state.isGroup) {
|
||||||
|
finalizeGroupDrag(state);
|
||||||
|
finishDrag(event.pointerId);
|
||||||
|
recalcVisibleDocIds();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inertiaState = {
|
||||||
|
restRotation: state.restRotation,
|
||||||
|
dynamicRotation: state.dynamicRotation,
|
||||||
|
angularVelocity: state.angularVelocity,
|
||||||
|
rotation: state.rotation,
|
||||||
|
width: state.width,
|
||||||
|
height: state.height,
|
||||||
|
dragScale: state.dragScale || 1,
|
||||||
|
};
|
||||||
|
const docId = state.docId;
|
||||||
|
finishDrag(event.pointerId);
|
||||||
|
startInertiaAnimation(docId, inertiaState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
finishDrag(event.pointerId);
|
finishDrag(event.pointerId);
|
||||||
},
|
},
|
||||||
[finishDrag],
|
[finalizeGroupDrag, finishDrag, recalcVisibleDocIds, startInertiaAnimation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+146
-514
@@ -1,22 +1,26 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EditIcon,
|
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ChevronsRightIcon,
|
DetailPanelCollapseIcon,
|
||||||
AnalyzeIcon,
|
AnalyzeIcon,
|
||||||
WindowMaximizeIcon,
|
WindowMaximizeIcon,
|
||||||
TextScanIcon,
|
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
import PanelHeader from '../ui/PanelHeader';
|
import PanelHeader from '../ui/PanelHeader';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
|
||||||
import { formatFileSize } from '../utils/format';
|
import { formatFileSize } from '../utils/format';
|
||||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||||
import { createDocumentActionState } from '../documents/documentActions';
|
import { createDocumentActionState } from '../documents/documentActions';
|
||||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||||
|
import DocumentSummarySection, {
|
||||||
|
TagSection,
|
||||||
|
CorrespondentSection,
|
||||||
|
sortCorrespondents,
|
||||||
|
buildCorrespondentOptions,
|
||||||
|
} from '../documents/DocumentSummarySection';
|
||||||
|
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
|
||||||
@@ -29,156 +33,6 @@ const derivePreviewOrientation = (metadata) => {
|
|||||||
return 'landscape';
|
return 'landscape';
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortCorrespondents = (entries = []) =>
|
|
||||||
entries
|
|
||||||
.filter((entry) => entry && entry.name)
|
|
||||||
.map(({ id, name, count }) => ({ id, name, count }))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
const CorrespondentPills = ({ entries = [], onRemove, showCount = false }) => (
|
|
||||||
<div className="correspondent-list">
|
|
||||||
{entries.length ? (
|
|
||||||
entries.map((entry, index) => {
|
|
||||||
const key = entry.id ?? `${entry.name}-${index}`;
|
|
||||||
return (
|
|
||||||
<span key={key} className="correspondent-pill">
|
|
||||||
<span className="correspondent-pill__label">
|
|
||||||
<span>
|
|
||||||
{entry.name}
|
|
||||||
{showCount && entry.count ? ` (${entry.count})` : ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{onRemove ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="correspondent-pill__remove"
|
|
||||||
onClick={() => onRemove(entry)}
|
|
||||||
aria-label={`Remove ${entry.name}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<span className="meta">No correspondents yet.</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const TagSection = ({
|
|
||||||
title,
|
|
||||||
tags = [],
|
|
||||||
onRemove,
|
|
||||||
onAdd,
|
|
||||||
emptyMessage = 'No tags yet.',
|
|
||||||
addPlaceholder = 'Add or create tag',
|
|
||||||
addButtonLabel = 'Add',
|
|
||||||
datalistId,
|
|
||||||
datalistOptions = [],
|
|
||||||
className,
|
|
||||||
}) => (
|
|
||||||
<div className={className}>
|
|
||||||
<dt>{title}</dt>
|
|
||||||
<div className="tag-list">
|
|
||||||
{tags.length ? (
|
|
||||||
tags.map((tag) => {
|
|
||||||
const key = tag.id ?? tag.label;
|
|
||||||
const style = getTagColorStyle(tag.color);
|
|
||||||
const removable = Boolean(onRemove);
|
|
||||||
const className = removable ? 'badge tag-chip tag-chip--removable' : 'badge tag-chip';
|
|
||||||
return (
|
|
||||||
<span key={key} className={className} style={style || undefined}>
|
|
||||||
<span className="tag-chip__label">{tag.label}</span>
|
|
||||||
{removable ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tag-chip__remove"
|
|
||||||
onClick={() => onRemove(tag)}
|
|
||||||
aria-label={`Remove tag ${tag.label}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<span className="meta">{emptyMessage}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{onAdd ? (
|
|
||||||
<form
|
|
||||||
className="inline"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const input = event.currentTarget.elements.tag;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (!value) return;
|
|
||||||
onAdd({ value, input });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input name="tag" placeholder={addPlaceholder} list={datalistId} />
|
|
||||||
<button type="submit">{addButtonLabel}</button>
|
|
||||||
{datalistId ? (
|
|
||||||
<datalist id={datalistId}>
|
|
||||||
{datalistOptions.map((option) => (
|
|
||||||
<option key={option.id} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CorrespondentSection = ({
|
|
||||||
title,
|
|
||||||
entries = [],
|
|
||||||
onRemove,
|
|
||||||
onAdd,
|
|
||||||
showCount = false,
|
|
||||||
addPlaceholder = 'Add or create correspondent',
|
|
||||||
addButtonLabel = 'Add',
|
|
||||||
datalistId,
|
|
||||||
datalistOptions = [],
|
|
||||||
className,
|
|
||||||
}) => (
|
|
||||||
<div className={className}>
|
|
||||||
<dt>{title}</dt>
|
|
||||||
<CorrespondentPills entries={entries} onRemove={onRemove} showCount={showCount} />
|
|
||||||
{onAdd ? (
|
|
||||||
<form
|
|
||||||
className="correspondent-form"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = event.currentTarget;
|
|
||||||
const nameInput = form.elements.correspondent;
|
|
||||||
const value = nameInput.value.trim();
|
|
||||||
if (!value) return;
|
|
||||||
onAdd({ name: value, input: nameInput });
|
|
||||||
form.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="correspondent"
|
|
||||||
placeholder={addPlaceholder}
|
|
||||||
list={datalistId}
|
|
||||||
/>
|
|
||||||
<button type="submit">{addButtonLabel}</button>
|
|
||||||
{datalistId ? (
|
|
||||||
<datalist id={datalistId}>
|
|
||||||
{datalistOptions.map((name) => (
|
|
||||||
<option key={name} value={name} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
) : null}
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const computeStackAngle = (docId, index) => {
|
const computeStackAngle = (docId, index) => {
|
||||||
if (index === 0) return 0;
|
if (index === 0) return 0;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
@@ -195,6 +49,7 @@ const PreviewStack = ({
|
|||||||
items = [],
|
items = [],
|
||||||
maxItems = MAX_PREVIEW_STACK_ITEMS,
|
maxItems = MAX_PREVIEW_STACK_ITEMS,
|
||||||
emptyMessage = 'Preview unavailable',
|
emptyMessage = 'Preview unavailable',
|
||||||
|
emptyContent = null,
|
||||||
onItemActivate,
|
onItemActivate,
|
||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
onZoomPreview,
|
onZoomPreview,
|
||||||
@@ -211,7 +66,11 @@ const PreviewStack = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!limited.length) {
|
if (!limited.length) {
|
||||||
return <span className="meta">{emptyMessage}</span>;
|
return (
|
||||||
|
<div className="preview-stack preview-stack--empty">
|
||||||
|
{emptyContent || <span className="meta">{emptyMessage}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -294,6 +153,7 @@ const DetailPanel = ({
|
|||||||
onBulkCorrespondentRemove,
|
onBulkCorrespondentRemove,
|
||||||
onPromoteSelection,
|
onPromoteSelection,
|
||||||
onUpdateTitle = async () => false,
|
onUpdateTitle = async () => false,
|
||||||
|
onUpdateIssued = async () => false,
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
ensurePreviewData = () => Promise.resolve(),
|
ensurePreviewData = () => Promise.resolve(),
|
||||||
@@ -313,7 +173,7 @@ const DetailPanel = ({
|
|||||||
[selectedDocuments],
|
[selectedDocuments],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { downloadHref: singleDownloadHref, hasOcr: singleHasOcr, openOcr } = useMemo(
|
const { downloadHref: singleDownloadHref } = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createDocumentActionState({
|
createDocumentActionState({
|
||||||
document: singleDoc,
|
document: singleDoc,
|
||||||
@@ -337,10 +197,38 @@ const DetailPanel = ({
|
|||||||
return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||||
}, [selectedCount, detailSummary]);
|
}, [selectedCount, detailSummary]);
|
||||||
|
|
||||||
const [titleEditDocId, setTitleEditDocId] = useState(null);
|
const headerBreadcrumbs = useMemo(() => {
|
||||||
const [titleDraft, setTitleDraft] = useState('');
|
if (selectedCount !== 1 || !singleDoc || typeof resolveFolderPath !== 'function') {
|
||||||
const [titleSaving, setTitleSaving] = useState(false);
|
return null;
|
||||||
const [titleError, setTitleError] = useState(null);
|
}
|
||||||
|
|
||||||
|
const handleNavigate = (folderId) => {
|
||||||
|
if (!folderId || typeof onFolderNavigate !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFolderNavigate(folderId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const folderSegments = resolveFolderPath(singleDoc.folder_id);
|
||||||
|
const normalizedSegments = Array.isArray(folderSegments)
|
||||||
|
? folderSegments
|
||||||
|
.filter((segment) => segment && segment.id && segment.name)
|
||||||
|
.map((segment) => ({
|
||||||
|
id: segment.id,
|
||||||
|
label: segment.name,
|
||||||
|
onClick: segment.id ? () => handleNavigate(segment.id) : null,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
...normalizedSegments,
|
||||||
|
{
|
||||||
|
id: singleDoc.id || 'current-document',
|
||||||
|
label: detailSummary.title,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [selectedCount, singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
|
||||||
|
|
||||||
const [zoomedPreview, setZoomedPreview] = useState(null);
|
const [zoomedPreview, setZoomedPreview] = useState(null);
|
||||||
|
|
||||||
const bulkDocumentIds = useMemo(
|
const bulkDocumentIds = useMemo(
|
||||||
@@ -348,67 +236,10 @@ const DetailPanel = ({
|
|||||||
[selectedDocuments],
|
[selectedDocuments],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!singleDoc) {
|
|
||||||
setTitleEditDocId(null);
|
|
||||||
setTitleDraft('');
|
|
||||||
setTitleError(null);
|
|
||||||
setTitleSaving(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (titleEditDocId && titleEditDocId !== singleDoc.id) {
|
|
||||||
setTitleEditDocId(null);
|
|
||||||
setTitleDraft('');
|
|
||||||
setTitleError(null);
|
|
||||||
setTitleSaving(false);
|
|
||||||
}
|
|
||||||
}, [singleDoc, titleEditDocId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setZoomedPreview(null);
|
setZoomedPreview(null);
|
||||||
}, [selectionKey]);
|
}, [selectionKey]);
|
||||||
|
|
||||||
const startTitleEdit = useCallback(() => {
|
|
||||||
if (!singleDoc) return;
|
|
||||||
setTitleEditDocId(singleDoc.id);
|
|
||||||
setTitleDraft(singleDoc.title || singleDoc.original_name);
|
|
||||||
setTitleError(null);
|
|
||||||
}, [singleDoc]);
|
|
||||||
|
|
||||||
const cancelTitleEdit = useCallback(() => {
|
|
||||||
setTitleEditDocId(null);
|
|
||||||
setTitleDraft('');
|
|
||||||
setTitleError(null);
|
|
||||||
setTitleSaving(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const submitTitleEdit = useCallback(
|
|
||||||
async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!singleDoc) return;
|
|
||||||
const trimmed = titleDraft.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
setTitleError('Title cannot be empty.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTitleSaving(true);
|
|
||||||
try {
|
|
||||||
const ok = await onUpdateTitle(singleDoc.id, trimmed);
|
|
||||||
if (ok) {
|
|
||||||
setTitleEditDocId(null);
|
|
||||||
setTitleDraft('');
|
|
||||||
setTitleError(null);
|
|
||||||
} else {
|
|
||||||
setTitleError('Failed to update title.');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setTitleSaving(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[singleDoc, titleDraft, onUpdateTitle],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePreviewActivate = useCallback(
|
const handlePreviewActivate = useCallback(
|
||||||
(docId) => {
|
(docId) => {
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
@@ -569,134 +400,16 @@ const DetailPanel = ({
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [stackPreviews, selectedDocuments]);
|
}, [stackPreviews, selectedDocuments]);
|
||||||
|
|
||||||
const availableCorrespondents = useMemo(
|
const correspondentOptions = useMemo(
|
||||||
() => (Array.isArray(correspondents) ? correspondents : []),
|
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
|
||||||
[correspondents],
|
[correspondents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const correspondentOptions = useMemo(() => {
|
|
||||||
const seen = new Set();
|
|
||||||
return availableCorrespondents.reduce((options, entry) => {
|
|
||||||
if (typeof entry?.name !== 'string') {
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
const name = entry.name.trim();
|
|
||||||
if (!name) {
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
const lower = name.toLowerCase();
|
|
||||||
if (seen.has(lower)) {
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
seen.add(lower);
|
|
||||||
options.push(name);
|
|
||||||
return options;
|
|
||||||
}, []);
|
|
||||||
}, [availableCorrespondents]);
|
|
||||||
|
|
||||||
const singleCorrespondents = useMemo(() => {
|
const singleCorrespondents = useMemo(() => {
|
||||||
if (!singleDoc) return [];
|
if (!singleDoc) return [];
|
||||||
return sortCorrespondents(singleDoc.correspondents || []);
|
return sortCorrespondents(singleDoc.correspondents || []);
|
||||||
}, [singleDoc]);
|
}, [singleDoc]);
|
||||||
|
|
||||||
const singleFolderPath = useMemo(() => {
|
|
||||||
if (!singleDoc?.folder_id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof resolveFolderPath !== 'function') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const segments = resolveFolderPath(singleDoc.folder_id);
|
|
||||||
if (!Array.isArray(segments) || !segments.some((segment) => segment?.id && segment.id !== 'root')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return segments;
|
|
||||||
}, [singleDoc?.folder_id, resolveFolderPath]);
|
|
||||||
|
|
||||||
const folderLabel = detailSummary.folderLabel;
|
|
||||||
|
|
||||||
const folderDisplayNode = useMemo(() => {
|
|
||||||
if (!singleDoc) {
|
|
||||||
return folderLabel || '—';
|
|
||||||
}
|
|
||||||
if (!singleFolderPath?.length) {
|
|
||||||
return folderLabel || '—';
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="detail-folder-path">
|
|
||||||
{singleFolderPath.map((segment, index) => {
|
|
||||||
const label = segment?.name || '…';
|
|
||||||
const targetId = segment?.id || null;
|
|
||||||
const key = `${targetId || label}-${index}`;
|
|
||||||
const isClickable = Boolean(targetId) && typeof onFolderNavigate === 'function';
|
|
||||||
const href = !isClickable
|
|
||||||
? null
|
|
||||||
: targetId === 'root'
|
|
||||||
? '/documents'
|
|
||||||
: `/documents/folder/${targetId}`;
|
|
||||||
return (
|
|
||||||
<React.Fragment key={key}>
|
|
||||||
{index > 0 ? <span className="detail-folder-path__separator">/</span> : null}
|
|
||||||
{isClickable ? (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
className="detail-folder-path__link"
|
|
||||||
onClick={(event) => {
|
|
||||||
if (
|
|
||||||
event.button !== 0 ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.shiftKey ||
|
|
||||||
event.altKey
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
onFolderNavigate(targetId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="detail-folder-path__segment">{label}</span>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}, [singleDoc, singleFolderPath, folderLabel, onFolderNavigate]);
|
|
||||||
|
|
||||||
const detailInfoRows = useMemo(() => {
|
|
||||||
if (!singleDoc) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const allowedKeys = new Set(['uploaded', 'size', 'type', 'issued', 'pages', 'created', 'updated', 'folder']);
|
|
||||||
const rows = detailSummary.summaryRows
|
|
||||||
.filter((row) => {
|
|
||||||
if (!allowedKeys.has(row.key)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (row.key === 'pages') {
|
|
||||||
return Number.isFinite(detailSummary.pageCount);
|
|
||||||
}
|
|
||||||
if (row.key === 'folder') {
|
|
||||||
return Boolean(singleFolderPath?.length);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map((row) => (row.key === 'folder' ? { ...row, value: folderDisplayNode } : row));
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
key: 'original-name',
|
|
||||||
label: 'Original filename',
|
|
||||||
value: singleDoc.original_name || '—',
|
|
||||||
});
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}, [singleDoc, detailSummary, folderDisplayNode, singleFolderPath]);
|
|
||||||
|
|
||||||
const bulkCorrespondents = useMemo(() => {
|
const bulkCorrespondents = useMemo(() => {
|
||||||
if (selectedDocuments.length <= 1) {
|
if (selectedDocuments.length <= 1) {
|
||||||
const doc = selectedDocuments[0];
|
const doc = selectedDocuments[0];
|
||||||
@@ -971,15 +684,35 @@ const DetailPanel = ({
|
|||||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = singleDoc.title || singleDoc.original_name;
|
const effectiveCardinality =
|
||||||
const isEditingTitle = titleEditDocId === singleDoc.id;
|
singlePreviewNavigator.cardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
|
||||||
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
|
||||||
const metadata =
|
|
||||||
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
|
||||||
const effectiveCardinality = singleEffectiveCardinality;
|
|
||||||
const canGoPrev = singlePreviewNavigator.canGoPrev;
|
const canGoPrev = singlePreviewNavigator.canGoPrev;
|
||||||
const canGoNext = singlePreviewNavigator.canGoNext;
|
const canGoNext = singlePreviewNavigator.canGoNext;
|
||||||
const hasPreviewImage = singleHasPreview;
|
const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl);
|
||||||
|
const previewMissingAsset = !singleNavigatorAsset;
|
||||||
|
const displayContentType = singleDoc.content_type || 'this file type';
|
||||||
|
const displayFilename =
|
||||||
|
singleDoc.filename || singleDoc.original_name || singleDoc.title || 'download';
|
||||||
|
const previewFallback = previewMissingAsset ? (
|
||||||
|
<div className="preview-pane__unsupported">
|
||||||
|
<div className="preview-pane__unsupported-message">
|
||||||
|
Preview not available for {displayContentType} files.
|
||||||
|
</div>
|
||||||
|
<div className="preview-pane__unsupported-filename">{displayFilename}</div>
|
||||||
|
{singleDownloadHref ? (
|
||||||
|
<a
|
||||||
|
className="button-link preview-pane__unsupported-download"
|
||||||
|
href={singleDownloadHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
<span>Download</span>
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
const emptyMessage = previewMissingAsset ? 'Preview unavailable' : 'Preview loading…';
|
||||||
const interceptNavPointer = (event) => {
|
const interceptNavPointer = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -991,148 +724,64 @@ const DetailPanel = ({
|
|||||||
<PreviewStack
|
<PreviewStack
|
||||||
items={singlePreviewItems}
|
items={singlePreviewItems}
|
||||||
maxItems={1}
|
maxItems={1}
|
||||||
emptyMessage="Preview loading…"
|
emptyMessage={emptyMessage}
|
||||||
|
emptyContent={previewFallback}
|
||||||
onItemActivate={handlePreviewActivate}
|
onItemActivate={handlePreviewActivate}
|
||||||
onOpenPreview={onOpenPreview}
|
onOpenPreview={onOpenPreview}
|
||||||
onZoomPreview={handleSingleZoom}
|
onZoomPreview={handleSingleZoom}
|
||||||
/>
|
/>
|
||||||
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
||||||
<div className="preview-pane__nav preview-pane__nav--overlay">
|
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="preview-pane__nav-button preview-pane__nav-button--prev"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
singlePreviewNavigator.goPrev();
|
|
||||||
}}
|
|
||||||
onPointerDown={interceptNavPointer}
|
|
||||||
onPointerUp={interceptNavPointer}
|
|
||||||
onMouseDown={interceptNavPointer}
|
|
||||||
onMouseUp={interceptNavPointer}
|
|
||||||
disabled={!canGoPrev}
|
|
||||||
aria-label="Previous preview"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="preview-pane__nav-button preview-pane__nav-button--next"
|
className="preview-pane__nav-button preview-pane__nav-button--prev"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
singlePreviewNavigator.goNext();
|
singlePreviewNavigator.goPrev();
|
||||||
}}
|
}}
|
||||||
onPointerDown={interceptNavPointer}
|
onPointerDown={interceptNavPointer}
|
||||||
onPointerUp={interceptNavPointer}
|
onPointerUp={interceptNavPointer}
|
||||||
onMouseDown={interceptNavPointer}
|
onMouseDown={interceptNavPointer}
|
||||||
onMouseUp={interceptNavPointer}
|
onMouseUp={interceptNavPointer}
|
||||||
disabled={!canGoNext}
|
disabled={!canGoPrev}
|
||||||
aria-label="Next preview"
|
aria-label="Previous preview"
|
||||||
>
|
>
|
||||||
<ArrowRightIcon />
|
<ArrowLeftIcon />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-pane__nav-button preview-pane__nav-button--next"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
singlePreviewNavigator.goNext();
|
||||||
|
}}
|
||||||
|
onPointerDown={interceptNavPointer}
|
||||||
|
onPointerUp={interceptNavPointer}
|
||||||
|
onMouseDown={interceptNavPointer}
|
||||||
|
onMouseUp={interceptNavPointer}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
aria-label="Next preview"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="doc-title-row">
|
<DocumentSummarySection
|
||||||
{isEditingTitle ? (
|
document={singleDoc}
|
||||||
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
tagLookupById={tagLookupById}
|
||||||
<input
|
tagOptions={tags}
|
||||||
value={titleDraft}
|
onTagAdd={(doc, value, extras) => onTagAdd(doc, value, extras)}
|
||||||
onChange={(event) => {
|
onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)}
|
||||||
setTitleDraft(event.target.value);
|
correspondents={singleCorrespondents}
|
||||||
if (titleError) {
|
correspondentOptions={correspondentOptions}
|
||||||
setTitleError(null);
|
onCorrespondentAdd={onCorrespondentAdd}
|
||||||
}
|
onCorrespondentRemove={onCorrespondentRemove}
|
||||||
}}
|
onUpdateTitle={onUpdateTitle}
|
||||||
onKeyDown={(event) => {
|
onUpdateIssued={onUpdateIssued}
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
cancelTitleEdit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Document title"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button type="submit" disabled={titleSaving}>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary"
|
|
||||||
onClick={cancelTitleEdit}
|
|
||||||
disabled={titleSaving}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h3 style={{ margin: 0 }}>{displayName}</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={startTitleEdit}
|
|
||||||
aria-label="Edit title"
|
|
||||||
title="Edit title"
|
|
||||||
>
|
|
||||||
<EditIcon className="icon-inline" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
|
||||||
<div className="meta">
|
|
||||||
{detailInfoRows.map((row) => {
|
|
||||||
const rawValue = row.value;
|
|
||||||
const displayValue =
|
|
||||||
rawValue === null || rawValue === undefined || rawValue === '' ? '—' : rawValue;
|
|
||||||
return (
|
|
||||||
<div key={row.key}>
|
|
||||||
<strong>{row.label}:</strong>{' '}
|
|
||||||
{displayValue}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<TagSection
|
|
||||||
title="Tags"
|
|
||||||
tags={tagsForDoc.map((tag) => ({
|
|
||||||
id: tag.id,
|
|
||||||
label: tag.label,
|
|
||||||
color: tag.color || tagLookupById.get(tag.id)?.color,
|
|
||||||
}))}
|
|
||||||
onRemove={(tag) => onTagRemove(singleDoc.id, tag.id)}
|
|
||||||
onAdd={({ value, input }) => onTagAdd(singleDoc, value, input)}
|
|
||||||
datalistId="tag-catalog-single"
|
|
||||||
datalistOptions={tags}
|
|
||||||
/>
|
/>
|
||||||
<CorrespondentSection
|
|
||||||
title="Correspondents"
|
|
||||||
entries={singleCorrespondents}
|
|
||||||
onRemove={(entry) =>
|
|
||||||
onCorrespondentRemove?.({
|
|
||||||
documentId: singleDoc.id,
|
|
||||||
correspondentId: entry.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onAdd={({ name, input }) =>
|
|
||||||
onCorrespondentAdd?.({
|
|
||||||
document: singleDoc,
|
|
||||||
name,
|
|
||||||
input,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
datalistId="correspondent-catalog-single"
|
|
||||||
datalistOptions={correspondentOptions}
|
|
||||||
/>
|
|
||||||
{metadata && (
|
|
||||||
<div>
|
|
||||||
<dt>Metadata</dt>
|
|
||||||
<pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1140,6 +789,7 @@ const DetailPanel = ({
|
|||||||
const renderBulk = () => {
|
const renderBulk = () => {
|
||||||
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||||
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
||||||
|
const headerLabel = `${countLabel}${sizeLabel ? ` (${sizeLabel})` : ''}`;
|
||||||
const topDocIdLocal = topDocId;
|
const topDocIdLocal = topDocId;
|
||||||
const topCardinalityLocal = topEffectiveCardinality;
|
const topCardinalityLocal = topEffectiveCardinality;
|
||||||
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
||||||
@@ -1200,35 +850,26 @@ const DetailPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
<h3 style={{ margin: 0 }}>{headerLabel}</h3>
|
||||||
<div className="meta">
|
|
||||||
<div>
|
|
||||||
<strong>Total size (stack):</strong> {sizeLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TagSection
|
<TagSection
|
||||||
title="Tags"
|
|
||||||
tags={bulkTagUnion}
|
tags={bulkTagUnion}
|
||||||
emptyMessage="No tags assigned."
|
emptyMessage="No tags assigned."
|
||||||
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label, documentIds })}
|
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label, documentIds })}
|
||||||
onAdd={({ value, input }) =>
|
onAdd={({ value }) =>
|
||||||
onBulkTagAdd?.({ label: value, input, documentIds })
|
onBulkTagAdd?.({ label: value, input: null, documentIds })
|
||||||
}
|
}
|
||||||
addPlaceholder="Add tag to selection"
|
addPlaceholder="Add tag to selection"
|
||||||
addButtonLabel="Add tag"
|
addButtonLabel="Add tag"
|
||||||
datalistId="tag-catalog-bulk"
|
|
||||||
datalistOptions={tags}
|
datalistOptions={tags}
|
||||||
className="bulk-tags"
|
className="bulk-tags"
|
||||||
/>
|
/>
|
||||||
<CorrespondentSection
|
<CorrespondentSection
|
||||||
title="Correspondents"
|
|
||||||
entries={bulkCorrespondents}
|
entries={bulkCorrespondents}
|
||||||
onRemove={handleBulkCorrespondentRemove}
|
onRemove={handleBulkCorrespondentRemove}
|
||||||
onAdd={({ name, input }) =>
|
onAdd={({ name }) =>
|
||||||
onBulkCorrespondentAdd?.({ name, input, documentIds })
|
onBulkCorrespondentAdd?.({ name, input: null, documentIds })
|
||||||
}
|
}
|
||||||
addPlaceholder="Add correspondent to selection"
|
addPlaceholder="Add correspondent to selection"
|
||||||
datalistId="correspondent-catalog-bulk"
|
|
||||||
datalistOptions={correspondentOptions}
|
datalistOptions={correspondentOptions}
|
||||||
showCount
|
showCount
|
||||||
className="bulk-correspondents"
|
className="bulk-correspondents"
|
||||||
@@ -1238,7 +879,6 @@ const DetailPanel = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isBulkSelection = selectedCount > 1;
|
const isBulkSelection = selectedCount > 1;
|
||||||
const showOcrAction = Boolean(singleDoc && singleHasOcr);
|
|
||||||
|
|
||||||
const headerLeading = [
|
const headerLeading = [
|
||||||
(
|
(
|
||||||
@@ -1250,7 +890,7 @@ const DetailPanel = ({
|
|||||||
aria-label="Close detail panel"
|
aria-label="Close detail panel"
|
||||||
title="Close detail panel"
|
title="Close detail panel"
|
||||||
>
|
>
|
||||||
<ChevronsRightIcon />
|
<DetailPanelCollapseIcon />
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -1265,11 +905,10 @@ const DetailPanel = ({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onOpenPreview(singleDoc.id);
|
onOpenPreview(singleDoc.id);
|
||||||
}}
|
}}
|
||||||
aria-label="Open preview"
|
aria-label="Maximize"
|
||||||
title="Open preview"
|
title="Maximize"
|
||||||
disabled={!singleHasPreview}
|
|
||||||
>
|
>
|
||||||
<WindowMaximizeIcon />
|
<WindowMaximizeIcon className="icon--flip-y" />
|
||||||
</button>,
|
</button>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1311,24 +950,6 @@ const DetailPanel = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showOcrAction) {
|
|
||||||
headerActions.push(
|
|
||||||
<button
|
|
||||||
key="ocr"
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
openOcr().catch(() => {});
|
|
||||||
}}
|
|
||||||
aria-label="View OCR text"
|
|
||||||
title="View OCR text"
|
|
||||||
>
|
|
||||||
<TextScanIcon />
|
|
||||||
</button>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (singleDoc) {
|
if (singleDoc) {
|
||||||
headerActions.push(
|
headerActions.push(
|
||||||
<button
|
<button
|
||||||
@@ -1352,7 +973,18 @@ const DetailPanel = ({
|
|||||||
<aside className="detail-panel panel">
|
<aside className="detail-panel panel">
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
leading={headerLeading}
|
leading={headerLeading}
|
||||||
title={headerTitle}
|
title={
|
||||||
|
headerBreadcrumbs ? (
|
||||||
|
<BreadcrumbTrail
|
||||||
|
entries={headerBreadcrumbs}
|
||||||
|
separator="/"
|
||||||
|
className="panel-header__breadcrumbs"
|
||||||
|
truncateFromStart
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
headerTitle
|
||||||
|
)
|
||||||
|
}
|
||||||
titleTag="h3"
|
titleTag="h3"
|
||||||
actions={headerActions.length ? headerActions : null}
|
actions={headerActions.length ? headerActions : null}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||||
|
import usePointerTap from '../ui/usePointerTap';
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ const ensureDocumentRoot = () => {
|
|||||||
return document.body;
|
return document.body;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CLICK_DELAY_MS = 240;
|
||||||
|
|
||||||
const PreviewZoomOverlay = ({
|
const PreviewZoomOverlay = ({
|
||||||
open = false,
|
open = false,
|
||||||
display = null,
|
display = null,
|
||||||
@@ -170,13 +173,33 @@ const PreviewZoomOverlay = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'Escape') {
|
const key = event.key;
|
||||||
|
|
||||||
|
if (key === ' ' || key === 'Space' || key === 'Spacebar') {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
const tag = target.tagName ? target.tagName.toLowerCase() : '';
|
||||||
|
if (
|
||||||
|
target.isContentEditable
|
||||||
|
|| tag === 'input'
|
||||||
|
|| tag === 'textarea'
|
||||||
|
|| tag === 'select'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'ArrowLeft') {
|
if (key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowLeft') {
|
||||||
if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) {
|
if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
activeDisplay.goPrev();
|
activeDisplay.goPrev();
|
||||||
@@ -184,7 +207,7 @@ const PreviewZoomOverlay = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'ArrowRight') {
|
if (key === 'ArrowRight') {
|
||||||
if (activeDisplay?.canGoNext && activeDisplay?.goNext) {
|
if (activeDisplay?.canGoNext && activeDisplay?.goNext) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
activeDisplay.goNext();
|
activeDisplay.goNext();
|
||||||
@@ -192,6 +215,39 @@ const PreviewZoomOverlay = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleZoomAtPoint = (clientX, clientY) => {
|
||||||
|
const img = imageRef.current;
|
||||||
|
setIsNativeScale((current) => {
|
||||||
|
if (!current && img) {
|
||||||
|
const rect = img.getBoundingClientRect();
|
||||||
|
const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5;
|
||||||
|
const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5;
|
||||||
|
focusRef.current = {
|
||||||
|
xRatio: clamp(xRatio, 0, 1),
|
||||||
|
yRatio: clamp(yRatio, 0, 1),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
focusRef.current = null;
|
||||||
|
}
|
||||||
|
return !current;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointerTapHandler = usePointerTap({
|
||||||
|
delay: CLICK_DELAY_MS,
|
||||||
|
onSingle: () => {
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onDouble: ({ clientX, clientY }) => {
|
||||||
|
toggleZoomAtPoint(clientX, clientY);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleImagePointerDown = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
pointerTapHandler(event);
|
||||||
|
};
|
||||||
|
|
||||||
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
|
if (!renderBackdrop || !activeDisplay?.url || !portalTarget) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -225,11 +281,13 @@ const PreviewZoomOverlay = ({
|
|||||||
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
|
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
|
||||||
maxWidth: 'none',
|
maxWidth: 'none',
|
||||||
maxHeight: 'none',
|
maxHeight: 'none',
|
||||||
|
touchAction: 'manipulation',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
cursor: 'zoom-in',
|
cursor: 'zoom-in',
|
||||||
maxWidth: '95vw',
|
maxWidth: '95vw',
|
||||||
maxHeight: '95vh',
|
maxHeight: '95vh',
|
||||||
|
touchAction: 'manipulation',
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -263,26 +321,7 @@ const PreviewZoomOverlay = ({
|
|||||||
height: event.currentTarget.naturalHeight || null,
|
height: event.currentTarget.naturalHeight || null,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onPointerDown={handleImagePointerDown}
|
||||||
event.stopPropagation();
|
|
||||||
if (!isNativeScale) {
|
|
||||||
const img = imageRef.current;
|
|
||||||
if (img) {
|
|
||||||
const rect = img.getBoundingClientRect();
|
|
||||||
const xRatio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;
|
|
||||||
const yRatio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;
|
|
||||||
focusRef.current = {
|
|
||||||
xRatio: clamp(xRatio, 0, 1),
|
|
||||||
yRatio: clamp(yRatio, 0, 1),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
focusRef.current = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
focusRef.current = null;
|
|
||||||
}
|
|
||||||
setIsNativeScale((current) => !current);
|
|
||||||
}}
|
|
||||||
style={imageStyle}
|
style={imageStyle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const NBSP = String.fromCharCode(160);
|
||||||
|
|
||||||
|
const CorrespondentLinks = ({
|
||||||
|
correspondents,
|
||||||
|
activeCorrespondentIdSet,
|
||||||
|
onCorrespondentClick,
|
||||||
|
}) => {
|
||||||
|
if (!Array.isArray(correspondents) || correspondents.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSet = activeCorrespondentIdSet || new Set();
|
||||||
|
const handleClick = (event, correspondent) => {
|
||||||
|
if (!onCorrespondentClick || correspondent.id == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
onCorrespondentClick(correspondent.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return correspondents.map((correspondent, index) => {
|
||||||
|
const isActive = correspondent.id != null && activeSet.has(correspondent.id);
|
||||||
|
const hasHandler = Boolean(onCorrespondentClick) && correspondent.id != null;
|
||||||
|
const classNames = ['doc-correspondent-link'];
|
||||||
|
if (isActive) classNames.push('is-active');
|
||||||
|
if (!hasHandler) classNames.push('is-static');
|
||||||
|
const isLast = index === correspondents.length - 1;
|
||||||
|
const label = isLast ? `${correspondent.name}:${NBSP}` : correspondent.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment
|
||||||
|
key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classNames.join(' ')}
|
||||||
|
aria-disabled={hasHandler ? undefined : true}
|
||||||
|
onClick={(event) => handleClick(event, correspondent)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!hasHandler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleClick(event, correspondent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{isLast ? null : <span className="doc-correspondent-link__separator">, </span>}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CorrespondentLinks;
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
|
||||||
|
import QuickAddMenu from '../ui/QuickAddMenu';
|
||||||
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
import { describeDocumentSummary } from './documentSummary';
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toDateInputValue = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const timezoneOffset = date.getTimezoneOffset();
|
||||||
|
const localDate = new Date(date.getTime() - timezoneOffset * 60000);
|
||||||
|
return localDate.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toIssuedTimestamp = (dateString, fallback) => {
|
||||||
|
if (!dateString) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const base = fallback ? new Date(fallback) : new Date();
|
||||||
|
if (Number.isNaN(base.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [year, month, day] = dateString.split('-').map((part) => Number.parseInt(part, 10));
|
||||||
|
if (!year || !month || !day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = new Date(base);
|
||||||
|
candidate.setUTCFullYear(year, month - 1, day);
|
||||||
|
return candidate.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortCorrespondents = (entries = []) =>
|
||||||
|
entries
|
||||||
|
.filter((entry) => entry && entry.name)
|
||||||
|
.map(({ id, name, count }) => ({ id, name, count }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
export const buildCorrespondentOptions = (entries = []) => {
|
||||||
|
const seen = new Set();
|
||||||
|
return entries.reduce((options, entry) => {
|
||||||
|
const name = typeof entry?.name === 'string' ? entry.name.trim() : '';
|
||||||
|
if (!name) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
options.push(name);
|
||||||
|
return options;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeOptions = (options) => (Array.isArray(options) ? options : []);
|
||||||
|
|
||||||
|
export const TagSection = ({
|
||||||
|
tags = [],
|
||||||
|
onRemove,
|
||||||
|
onAdd,
|
||||||
|
emptyMessage = 'No tags yet.',
|
||||||
|
addPlaceholder = 'Add or create tag',
|
||||||
|
addButtonLabel = 'Add',
|
||||||
|
datalistOptions = [],
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const handleCreate = useCallback(
|
||||||
|
(label) => onAdd?.({ value: label, input: null }),
|
||||||
|
[onAdd],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(option) => {
|
||||||
|
if (!onAdd) return;
|
||||||
|
const label =
|
||||||
|
(option && typeof option === 'object' && option.label) ||
|
||||||
|
(typeof option === 'string' ? option : '');
|
||||||
|
if (!label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onAdd({ value: label, option });
|
||||||
|
},
|
||||||
|
[onAdd],
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedOptions = useMemo(() => normalizeOptions(datalistOptions), [datalistOptions]);
|
||||||
|
const containerClass = className ? `tag-list ${className}` : 'tag-list';
|
||||||
|
const showQuickAdd = Boolean(onAdd);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClass}>
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const key = tag.id ?? tag.label;
|
||||||
|
const style = getTagColorStyle(tag.color);
|
||||||
|
return (
|
||||||
|
<span key={key} className="badge tag-chip" style={style || undefined}>
|
||||||
|
<span className="tag-chip__label">{tag.label}</span>
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tag-chip__remove"
|
||||||
|
onClick={() => onRemove(tag)}
|
||||||
|
aria-label={`Remove tag ${tag.label}`}
|
||||||
|
>
|
||||||
|
<IconX className="icon-inline" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showQuickAdd ? (
|
||||||
|
<QuickAddMenu
|
||||||
|
options={normalizedOptions}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
onSelectOption={(original, normalized) => handleSelect(normalized || original)}
|
||||||
|
placeholder={addPlaceholder}
|
||||||
|
createLabel={addButtonLabel}
|
||||||
|
triggerAriaLabel="Add tag"
|
||||||
|
triggerTitle={addButtonLabel}
|
||||||
|
triggerClassName="quick-add__chip quick-add__trigger"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="quick-add__chip-label">
|
||||||
|
<PlusIcon className="icon-inline" aria-hidden="true" /> Add tag
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CorrespondentSection = ({
|
||||||
|
entries = [],
|
||||||
|
onRemove,
|
||||||
|
onAdd,
|
||||||
|
showCount = false,
|
||||||
|
addPlaceholder = 'Add or create correspondent',
|
||||||
|
addButtonLabel = 'Add',
|
||||||
|
datalistOptions = [],
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const handleCreate = useCallback(
|
||||||
|
(name) => onAdd?.({ name, input: null }),
|
||||||
|
[onAdd],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(original, normalized) => {
|
||||||
|
if (!onAdd) return;
|
||||||
|
const source = normalized && typeof normalized === 'object' ? normalized : original;
|
||||||
|
const resolvedName =
|
||||||
|
(source && typeof source.name === 'string' && source.name.trim()) ||
|
||||||
|
(typeof source === 'string' ? source.trim() : '') ||
|
||||||
|
(source && typeof source.label === 'string' ? source.label.trim() : '');
|
||||||
|
if (!resolvedName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload =
|
||||||
|
source && typeof source === 'object'
|
||||||
|
? { ...source, name: resolvedName }
|
||||||
|
: { id: null, name: resolvedName };
|
||||||
|
onAdd({ name: resolvedName, option: payload, input: null });
|
||||||
|
},
|
||||||
|
[onAdd],
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedOptions = useMemo(() => normalizeOptions(datalistOptions), [datalistOptions]);
|
||||||
|
const hasEntries = entries && entries.length > 0;
|
||||||
|
const showQuickAdd = Boolean(onAdd);
|
||||||
|
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClass}>
|
||||||
|
{hasEntries
|
||||||
|
? entries.map((entry) => {
|
||||||
|
const key = entry.id ?? entry.name;
|
||||||
|
return (
|
||||||
|
<span key={key} className="correspondent-pill">
|
||||||
|
<span className="correspondent-pill__label">
|
||||||
|
{entry.name}
|
||||||
|
{showCount && entry.count ? ` (${entry.count})` : ''}
|
||||||
|
</span>
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="correspondent-pill__remove"
|
||||||
|
onClick={() => onRemove(entry)}
|
||||||
|
aria-label={`Remove ${entry.name}`}
|
||||||
|
>
|
||||||
|
<IconX className="icon-inline" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: !showQuickAdd && <span className="meta">No correspondents yet.</span>}
|
||||||
|
{showQuickAdd ? (
|
||||||
|
<QuickAddMenu
|
||||||
|
options={normalizedOptions}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
onSelectOption={(original, normalized) => handleSelect(original, normalized)}
|
||||||
|
placeholder={addPlaceholder}
|
||||||
|
createLabel={addButtonLabel}
|
||||||
|
triggerAriaLabel="Add correspondent"
|
||||||
|
triggerTitle={addButtonLabel}
|
||||||
|
triggerClassName="quick-add__chip quick-add__trigger"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="quick-add__chip-label">
|
||||||
|
<PlusIcon className="icon-inline" aria-hidden="true" /> Add correspondent
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentSummarySection = ({
|
||||||
|
document,
|
||||||
|
tagLookupById = new Map(),
|
||||||
|
tagOptions = [],
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
correspondents,
|
||||||
|
correspondentOptions = [],
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued
|
||||||
|
}) => {
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (!document) {
|
||||||
|
return {
|
||||||
|
title: '',
|
||||||
|
originalName: '',
|
||||||
|
sizeLabel: '—',
|
||||||
|
pageCount: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return describeDocumentSummary(document);
|
||||||
|
}, [document]);
|
||||||
|
const issuedDateLabel = useMemo(() => formatDate(document?.issued_at), [document?.issued_at]);
|
||||||
|
|
||||||
|
const editableTitle = Boolean(document && onUpdateTitle);
|
||||||
|
const editableIssued = Boolean(document && onUpdateIssued);
|
||||||
|
|
||||||
|
const resolvedTags = useMemo(() => {
|
||||||
|
if (!Array.isArray(document?.tags)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return document.tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
label: tag.label,
|
||||||
|
color: tag.color ?? tagLookupById.get(tag.id)?.color ?? null,
|
||||||
|
}));
|
||||||
|
}, [document?.tags, tagLookupById]);
|
||||||
|
|
||||||
|
const resolvedCorrespondents = useMemo(() => {
|
||||||
|
if (Array.isArray(correspondents) && correspondents.length) {
|
||||||
|
return correspondents;
|
||||||
|
}
|
||||||
|
return sortCorrespondents(document?.correspondents || []);
|
||||||
|
}, [correspondents, document?.correspondents]);
|
||||||
|
|
||||||
|
const metaRows = useMemo(() => {
|
||||||
|
const rows = [];
|
||||||
|
const currentVersionNumber = document?.current_version?.version_number;
|
||||||
|
if (Number.isFinite(currentVersionNumber)) {
|
||||||
|
rows.push({
|
||||||
|
key: 'current-version',
|
||||||
|
label: 'Current version',
|
||||||
|
value: `#${currentVersionNumber}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.sizeLabel && summary.sizeLabel !== '—') {
|
||||||
|
rows.push({ key: 'size', label: 'Size', value: summary.sizeLabel });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(summary.pageCount)) {
|
||||||
|
rows.push({ key: 'pages', label: 'Pages', value: String(summary.pageCount) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}, [document?.current_version?.version_number, summary]);
|
||||||
|
|
||||||
|
const [titleDraft, setTitleDraft] = useState('');
|
||||||
|
const [titleSaving, setTitleSaving] = useState(false);
|
||||||
|
const [titleError, setTitleError] = useState(null);
|
||||||
|
const [isTitleEditing, setIsTitleEditing] = useState(false);
|
||||||
|
|
||||||
|
const [issuedDraft, setIssuedDraft] = useState('');
|
||||||
|
const [issuedSaving, setIssuedSaving] = useState(false);
|
||||||
|
const [issuedError, setIssuedError] = useState(null);
|
||||||
|
const [isIssuedEditing, setIsIssuedEditing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsTitleEditing(false);
|
||||||
|
setTitleDraft('');
|
||||||
|
setTitleError(null);
|
||||||
|
setTitleSaving(false);
|
||||||
|
|
||||||
|
setIsIssuedEditing(false);
|
||||||
|
setIssuedDraft('');
|
||||||
|
setIssuedError(null);
|
||||||
|
setIssuedSaving(false);
|
||||||
|
}, [document?.id]);
|
||||||
|
|
||||||
|
const startTitleEdit = useCallback(() => {
|
||||||
|
if (!editableTitle || !document) return;
|
||||||
|
setIsTitleEditing(true);
|
||||||
|
setTitleDraft(document.title);
|
||||||
|
setTitleError(null);
|
||||||
|
}, [document, editableTitle]);
|
||||||
|
|
||||||
|
const cancelTitleEdit = useCallback(() => {
|
||||||
|
setIsTitleEditing(false);
|
||||||
|
setTitleDraft('');
|
||||||
|
setTitleError(null);
|
||||||
|
setTitleSaving(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitTitleEdit = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editableTitle || !document) return;
|
||||||
|
const trimmed = titleDraft.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setTitleError('Title cannot be empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTitleSaving(true);
|
||||||
|
try {
|
||||||
|
const ok = await onUpdateTitle(document.id, trimmed);
|
||||||
|
if (ok) {
|
||||||
|
cancelTitleEdit();
|
||||||
|
} else {
|
||||||
|
setTitleError('Failed to update title.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setTitleSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cancelTitleEdit, document, editableTitle, onUpdateTitle, titleDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startIssuedEdit = useCallback(() => {
|
||||||
|
if (!editableIssued || !document) return;
|
||||||
|
setIsIssuedEditing(true);
|
||||||
|
setIssuedDraft(toDateInputValue(document.issued_at));
|
||||||
|
setIssuedError(null);
|
||||||
|
}, [document, editableIssued]);
|
||||||
|
|
||||||
|
const cancelIssuedEdit = useCallback(() => {
|
||||||
|
setIsIssuedEditing(false);
|
||||||
|
setIssuedDraft('');
|
||||||
|
setIssuedError(null);
|
||||||
|
setIssuedSaving(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitIssuedEdit = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editableIssued || !document) return;
|
||||||
|
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
|
||||||
|
setIssuedSaving(true);
|
||||||
|
try {
|
||||||
|
const ok = await onUpdateIssued(document.id, normalizedValue);
|
||||||
|
if (ok) {
|
||||||
|
cancelIssuedEdit();
|
||||||
|
} else {
|
||||||
|
setIssuedError('Failed to update issued date.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIssuedSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cancelIssuedEdit, document, editableIssued, issuedDraft, onUpdateIssued],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="document-summary">
|
||||||
|
<div className="doc-title-row">
|
||||||
|
<div className="doc-title-row__primary">
|
||||||
|
{editableTitle && isTitleEditing ? (
|
||||||
|
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
|
||||||
|
<input
|
||||||
|
value={titleDraft}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTitleDraft(event.target.value);
|
||||||
|
if (titleError) {
|
||||||
|
setTitleError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelTitleEdit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Document title"
|
||||||
|
autoFocus
|
||||||
|
disabled={titleSaving}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={titleSaving}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={cancelTitleEdit}
|
||||||
|
disabled={titleSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="doc-title-row__title">{summary.title}</h3>
|
||||||
|
{editableTitle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={startTitleEdit}
|
||||||
|
aria-label="Edit title"
|
||||||
|
title="Edit title"
|
||||||
|
>
|
||||||
|
<EditIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||||
|
|
||||||
|
<div className="detail-meta">
|
||||||
|
<div className="detail-meta__row">
|
||||||
|
<span className="detail-meta__label">Issued:</span>
|
||||||
|
{editableIssued && isIssuedEditing ? (
|
||||||
|
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={issuedDraft}
|
||||||
|
onChange={(event) => {
|
||||||
|
setIssuedDraft(event.target.value);
|
||||||
|
if (issuedError) {
|
||||||
|
setIssuedError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Issued on"
|
||||||
|
disabled={issuedSaving}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={issuedSaving}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={cancelIssuedEdit}
|
||||||
|
disabled={issuedSaving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
|
||||||
|
{editableIssued ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={startIssuedEdit}
|
||||||
|
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
|
||||||
|
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
|
||||||
|
>
|
||||||
|
<EditIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
|
||||||
|
|
||||||
|
{metaRows.map((row) => (
|
||||||
|
<div key={row.key} className="detail-meta__row">
|
||||||
|
<span className="detail-meta__label">{row.label}:</span>
|
||||||
|
<span className="detail-meta__value">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TagSection
|
||||||
|
tags={resolvedTags}
|
||||||
|
onRemove={
|
||||||
|
onTagRemove
|
||||||
|
? (tag) => onTagRemove(document.id, tag.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onAdd={
|
||||||
|
onTagAdd
|
||||||
|
? ({ value, option }) => onTagAdd(document, value, { option })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
datalistOptions={tagOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CorrespondentSection
|
||||||
|
entries={resolvedCorrespondents}
|
||||||
|
onRemove={
|
||||||
|
onCorrespondentRemove
|
||||||
|
? (entry) =>
|
||||||
|
onCorrespondentRemove({
|
||||||
|
documentId: document.id,
|
||||||
|
correspondentId: entry.id,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onAdd={
|
||||||
|
onCorrespondentAdd
|
||||||
|
? ({ name, option }) =>
|
||||||
|
onCorrespondentAdd({
|
||||||
|
document,
|
||||||
|
name,
|
||||||
|
option,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
showCount
|
||||||
|
datalistOptions={correspondentOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentSummarySection;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
|
|
||||||
|
const DEFAULT_THUMBNAIL_SIZE = 48;
|
||||||
|
|
||||||
|
// Detect when an element becomes visible within a scroll container so we can delay loading.
|
||||||
|
const useLazyVisibility = (rootRef, resetKey) => {
|
||||||
|
const targetRef = useRef(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
const rootNode = rootRef?.current || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = targetRef.current;
|
||||||
|
if (!element) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
|
||||||
|
setIsVisible(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: rootNode,
|
||||||
|
rootMargin: '200px 0px',
|
||||||
|
threshold: 0.01,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isVisible, rootNode, resetKey]);
|
||||||
|
|
||||||
|
return { ref: targetRef, isVisible };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageCount = (doc) =>
|
||||||
|
Number.isFinite(doc?.current_version?.metadata?.page_count)
|
||||||
|
? doc.current_version.metadata.page_count
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const DocumentThumbnailImage = ({
|
||||||
|
document,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
alt = '',
|
||||||
|
maxSize = DEFAULT_THUMBNAIL_SIZE,
|
||||||
|
scrollRootRef = null,
|
||||||
|
}) => {
|
||||||
|
const documentId = document?.id;
|
||||||
|
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
|
||||||
|
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||||
|
|
||||||
|
const thumbnailAsset = useMemo(
|
||||||
|
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||||
|
[document?.current_version],
|
||||||
|
);
|
||||||
|
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
|
||||||
|
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
|
||||||
|
const assetWidth = Number(primaryMetadata?.width);
|
||||||
|
const assetHeight = Number(primaryMetadata?.height);
|
||||||
|
|
||||||
|
const dimensions = useMemo(() => {
|
||||||
|
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
|
||||||
|
return { width: resolvedMaxSize, height: resolvedMaxSize };
|
||||||
|
}
|
||||||
|
const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight);
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(assetWidth * scale)),
|
||||||
|
height: Math.max(1, Math.round(assetHeight * scale)),
|
||||||
|
};
|
||||||
|
}, [assetWidth, assetHeight, resolvedMaxSize]);
|
||||||
|
|
||||||
|
const innerStyle = useMemo(
|
||||||
|
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||||
|
[dimensions.height, dimensions.width],
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
});
|
||||||
|
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
|
||||||
|
|
||||||
|
const pageCount = getPageCount(document);
|
||||||
|
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
|
||||||
|
const innerClasses = ['document-thumbnail-inner'];
|
||||||
|
if (showMultiPageBadge) {
|
||||||
|
innerClasses.push('document-thumbnail-inner--multipage');
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspectRatio = useMemo(() => {
|
||||||
|
if (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) {
|
||||||
|
return assetWidth / assetHeight;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [assetWidth, assetHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = visibilityRef.current;
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (aspectRatio) {
|
||||||
|
node.dataset.thumbnailAspect = String(aspectRatio);
|
||||||
|
} else {
|
||||||
|
delete node.dataset.thumbnailAspect;
|
||||||
|
}
|
||||||
|
}, [aspectRatio, visibilityRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
|
||||||
|
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||||
|
{url ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={alt}
|
||||||
|
className="document-thumbnail"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
onDragStart={(event) => event.preventDefault()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="thumb-placeholder">DOC</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentThumbnailImage;
|
||||||
|
export { DEFAULT_THUMBNAIL_SIZE };
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FolderIcon } from '../ui/icons';
|
||||||
|
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
||||||
|
import CorrespondentLinks from './CorrespondentLinks';
|
||||||
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
import { resolveCorrespondents } from './correspondents';
|
||||||
|
import { writeTagTransferData } from './tagTransfer';
|
||||||
|
|
||||||
|
const DocumentsGrid = ({
|
||||||
|
entries,
|
||||||
|
selectedDocumentIdsSet,
|
||||||
|
selectedFolderIdsSet,
|
||||||
|
draggingDocumentIdsSet,
|
||||||
|
draggedFolderId,
|
||||||
|
onFolderClick,
|
||||||
|
onFolderSelect,
|
||||||
|
onFolderDragOver,
|
||||||
|
onFolderDragLeave,
|
||||||
|
onFolderDrop,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
onDocumentClick,
|
||||||
|
onDocumentOpen,
|
||||||
|
onDocumentDragStart,
|
||||||
|
onDocumentDragEnd,
|
||||||
|
onDocumentTagDragOver,
|
||||||
|
onDocumentTagDragLeave,
|
||||||
|
onDocumentTagDrop,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
gridIconSize,
|
||||||
|
tagLookupById,
|
||||||
|
onTagClick,
|
||||||
|
scrollRef,
|
||||||
|
onCorrespondentClick,
|
||||||
|
activeCorrespondentIdSet,
|
||||||
|
onClearSelection,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className="documents-grid"
|
||||||
|
role="list"
|
||||||
|
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entries.map((entry) => {
|
||||||
|
if (entry.type === 'folder') {
|
||||||
|
const folder = entry.folder;
|
||||||
|
if (!folder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const canDragFolder = folder.id !== 'root';
|
||||||
|
const isDraggingFolder = draggedFolderId === folder.id;
|
||||||
|
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||||
|
const classes = ['document-card', 'folder-card'];
|
||||||
|
if (isDraggingFolder) classes.push('is-dragging');
|
||||||
|
if (isSelectedFolder) classes.push('selected');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.key}
|
||||||
|
className={classes.join(' ')}
|
||||||
|
role="listitem"
|
||||||
|
id={`folder-card-${folder.id}`}
|
||||||
|
draggable={canDragFolder}
|
||||||
|
onClick={(event) => onFolderClick?.(folder, event)}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onFolderSelect?.(folder.id);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
|
||||||
|
onDragLeave={onFolderDragLeave}
|
||||||
|
onDrop={(event) => onFolderDrop?.(event, folder.id)}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (canDragFolder) {
|
||||||
|
onFolderDragStart?.(event, folder.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
if (canDragFolder) {
|
||||||
|
onFolderDragEnd?.(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="folder-card__icon">
|
||||||
|
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
|
||||||
|
</div>
|
||||||
|
<div className="folder-card__meta">
|
||||||
|
<div className="folder-card__name" title={folder.name}>
|
||||||
|
{folder.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = entry.document;
|
||||||
|
if (!doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelected = selectedDocumentIdsSet?.has(doc.id);
|
||||||
|
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
|
||||||
|
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
||||||
|
const visibleTags = tagList.slice(0, 3);
|
||||||
|
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||||
|
const correspondents = resolveCorrespondents(doc);
|
||||||
|
const cardClasses = ['document-card', 'document'];
|
||||||
|
if (isSelected) cardClasses.push('selected');
|
||||||
|
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.key}
|
||||||
|
className={cardClasses.join(' ')}
|
||||||
|
role="listitem"
|
||||||
|
id={`document-card-${doc.id}`}
|
||||||
|
data-doc-id={doc.id}
|
||||||
|
onClick={(event) => onDocumentClick?.(doc, event)}
|
||||||
|
onDoubleClick={() => onDocumentOpen?.(doc.id)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
|
||||||
|
onDragEnd={(event) => onDocumentDragEnd?.(event)}
|
||||||
|
onDragOver={(event) => onDocumentTagDragOver?.(event)}
|
||||||
|
onDragOverCapture={(event) => onDocumentTagDragOver?.(event)}
|
||||||
|
onDragLeave={onDocumentTagDragLeave}
|
||||||
|
onDragLeaveCapture={onDocumentTagDragLeave}
|
||||||
|
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
|
||||||
|
onDropCapture={(event) => onDocumentTagDrop?.(event, doc.id)}
|
||||||
|
>
|
||||||
|
<DocumentThumbnailImage
|
||||||
|
document={doc}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
alt={`Thumbnail for ${doc.title}`}
|
||||||
|
maxSize={gridIconSize}
|
||||||
|
scrollRootRef={scrollRef}
|
||||||
|
/>
|
||||||
|
<div className="document-card__meta">
|
||||||
|
<div className="document-card__title" title={doc.title}>
|
||||||
|
{correspondents.length > 0 ? (
|
||||||
|
<span className="doc-correspondents">
|
||||||
|
<CorrespondentLinks
|
||||||
|
correspondents={correspondents}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="doc-name__primary">{doc.title}</span>
|
||||||
|
</div>
|
||||||
|
{visibleTags.length > 0 && (
|
||||||
|
<div className="document-card__tags">
|
||||||
|
{visibleTags.map((tag) => {
|
||||||
|
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||||
|
const style = getTagColorStyle(colorSource);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="badge tag-chip"
|
||||||
|
style={style || undefined}
|
||||||
|
title={tag.label}
|
||||||
|
role="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onTagClick?.(tag.id);
|
||||||
|
}}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'copyMove';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[documents] Failed to configure drag effect', error);
|
||||||
|
}
|
||||||
|
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onTagClick?.(tag.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{remainingTagCount > 0 && (
|
||||||
|
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DocumentsGrid;
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FolderIcon,
|
||||||
|
EditIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
||||||
|
import CorrespondentLinks from './CorrespondentLinks';
|
||||||
|
import { resolveCorrespondents } from './correspondents';
|
||||||
|
import { writeTagTransferData } from './tagTransfer';
|
||||||
|
|
||||||
|
const DocumentsList = ({
|
||||||
|
entries,
|
||||||
|
focusedRowKey,
|
||||||
|
selectedDocumentIdsSet,
|
||||||
|
selectedFolderIdsSet,
|
||||||
|
draggingDocumentIdsSet,
|
||||||
|
draggedFolderId,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
getDownloadHref,
|
||||||
|
onFolderClick,
|
||||||
|
onFolderSelect,
|
||||||
|
onFolderDragOver,
|
||||||
|
onFolderDragLeave,
|
||||||
|
onFolderDrop,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
onFolderRename,
|
||||||
|
onFolderDelete,
|
||||||
|
onDocumentClick,
|
||||||
|
onDocumentOpen,
|
||||||
|
onDocumentDragStart,
|
||||||
|
onDocumentDragEnd,
|
||||||
|
onDocumentTagDragOver,
|
||||||
|
onDocumentTagDragLeave,
|
||||||
|
onDocumentTagDrop,
|
||||||
|
onDocumentRename,
|
||||||
|
onDocumentDelete,
|
||||||
|
tagLookupById,
|
||||||
|
onTagClick,
|
||||||
|
onCorrespondentClick,
|
||||||
|
activeCorrespondentIdSet,
|
||||||
|
scrollRef,
|
||||||
|
}) => (
|
||||||
|
<table aria-multiselectable="true">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="thumb-column"></th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Issued</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((entry) => {
|
||||||
|
if (entry.type === 'folder') {
|
||||||
|
const folder = entry.folder;
|
||||||
|
if (!folder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const canDragFolder = folder.id !== 'root';
|
||||||
|
const isDraggingFolder = draggedFolderId === folder.id;
|
||||||
|
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||||
|
const rowKey = `folder:${folder.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={entry.key}
|
||||||
|
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${
|
||||||
|
focusedRowKey === rowKey ? ' focused' : ''
|
||||||
|
}${isSelectedFolder ? ' selected' : ''}`}
|
||||||
|
id={`folder-row-${folder.id}`}
|
||||||
|
onClick={(event) => onFolderClick?.(folder, event)}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onFolderSelect?.(folder.id);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
|
||||||
|
onDragLeave={onFolderDragLeave}
|
||||||
|
onDrop={(event) => onFolderDrop?.(event, folder.id)}
|
||||||
|
draggable={canDragFolder}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (canDragFolder) {
|
||||||
|
onFolderDragStart?.(event, folder.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
if (canDragFolder) {
|
||||||
|
onFolderDragEnd?.(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="thumb-cell">
|
||||||
|
<div className="thumb-icon">
|
||||||
|
<FolderIcon className="thumb-icon__image" size={32} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="doc-list__name">
|
||||||
|
<div className="doc-list__name-content">
|
||||||
|
<span>{folder.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td className="actions">
|
||||||
|
<div className="action-buttons">
|
||||||
|
{folder.id !== 'root' && onFolderRename && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
title="Rename"
|
||||||
|
aria-label={`Rename folder ${folder.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const nextName = window.prompt('Rename folder', folder.name);
|
||||||
|
if (!nextName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = nextName.trim();
|
||||||
|
if (!trimmed || trimmed === folder.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFolderRename?.(folder.id, trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger"
|
||||||
|
title="Delete"
|
||||||
|
aria-label={`Delete folder ${folder.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onFolderDelete?.(folder.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = entry.document;
|
||||||
|
if (!doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const isSelected = selectedDocumentIdsSet?.has(doc.id);
|
||||||
|
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
|
||||||
|
const rowClasses = ['document'];
|
||||||
|
if (isSelected) rowClasses.push('selected');
|
||||||
|
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||||
|
const downloadHref = getDownloadHref?.(doc) || null;
|
||||||
|
const correspondents = resolveCorrespondents(doc);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={entry.key}
|
||||||
|
className={rowClasses.join(' ')}
|
||||||
|
id={`document-row-${doc.id}`}
|
||||||
|
data-doc-id={doc.id}
|
||||||
|
onClick={(event) => onDocumentClick?.(doc, event)}
|
||||||
|
onDoubleClick={() => onDocumentOpen?.(doc.id)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
|
||||||
|
onDragEnd={(event) => onDocumentDragEnd?.(event)}
|
||||||
|
onDragOver={onDocumentTagDragOver}
|
||||||
|
onDragLeave={onDocumentTagDragLeave}
|
||||||
|
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
|
||||||
|
>
|
||||||
|
<td className="thumb-cell">
|
||||||
|
<DocumentThumbnailImage
|
||||||
|
document={doc}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
alt={`Thumbnail for ${doc.title}`}
|
||||||
|
scrollRootRef={scrollRef}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="doc-list__name">
|
||||||
|
<div className="doc-name">
|
||||||
|
<div className="doc-list__name-content">
|
||||||
|
<span className="doc-name__title">
|
||||||
|
{correspondents.length > 0 ? (
|
||||||
|
<span className="doc-correspondents">
|
||||||
|
<CorrespondentLinks
|
||||||
|
correspondents={correspondents}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="doc-name__primary">{doc.title}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(doc.tags || []).length > 0 && (
|
||||||
|
<div className="doc-name__tags">
|
||||||
|
{(doc.tags || []).map((tag) => {
|
||||||
|
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
|
||||||
|
const style = getTagColorStyle(colorSource);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="badge tag-chip"
|
||||||
|
style={style || undefined}
|
||||||
|
title={tag.label}
|
||||||
|
role="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onTagClick?.(tag.id);
|
||||||
|
}}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'copyMove';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[documents] Failed to configure drag effect', error);
|
||||||
|
}
|
||||||
|
writeTagTransferData(event.dataTransfer, tag, doc.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onTagClick?.(tag.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{(() => {
|
||||||
|
const issuedAt = doc.issued_at || null;
|
||||||
|
if (!issuedAt) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const timestamp = Date.parse(issuedAt);
|
||||||
|
if (Number.isNaN(timestamp)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
<td className="actions">
|
||||||
|
<div className="action-buttons">
|
||||||
|
{onDocumentRename && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
title="Rename"
|
||||||
|
aria-label={`Rename document ${doc.title}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const nextName = window.prompt('Rename document', doc.title);
|
||||||
|
if (!nextName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = nextName.trim();
|
||||||
|
if (!trimmed || trimmed === doc.title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDocumentRename?.(doc.id, trimmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{downloadHref ? (
|
||||||
|
<a
|
||||||
|
className="icon-button"
|
||||||
|
href={downloadHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Download"
|
||||||
|
aria-label="Download document"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onAuxClick={(event) => event.stopPropagation()}
|
||||||
|
onContextMenu={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="icon-inline" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="meta">No download</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete document"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDocumentDelete?.(doc.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon className="icon-inline" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DocumentsList;
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ViewListIcon,
|
||||||
|
ViewGridIcon,
|
||||||
|
IconFileStack,
|
||||||
|
RefreshIcon,
|
||||||
|
MinusVerticalIcon,
|
||||||
|
InfoIcon,
|
||||||
|
} from '../ui/icons';
|
||||||
|
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||||
|
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
||||||
|
import DetailPanel from '../detail/DetailPanel';
|
||||||
|
import DocumentsGrid from './DocumentsGrid';
|
||||||
|
import DocumentsList from './DocumentsList';
|
||||||
|
import { isTagTransferEvent } from './tagTransfer';
|
||||||
|
|
||||||
|
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||||
|
|
||||||
|
const EntryType = {
|
||||||
|
folder: 'folder',
|
||||||
|
document: 'document',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentsPanel = ({
|
||||||
|
currentFolderName,
|
||||||
|
breadcrumbs,
|
||||||
|
onRefresh,
|
||||||
|
subfolders,
|
||||||
|
documents,
|
||||||
|
searchResults,
|
||||||
|
isFilterActive = false,
|
||||||
|
onFolderSelect,
|
||||||
|
onFolderDrop,
|
||||||
|
onFolderDragOver,
|
||||||
|
onFolderDragLeave,
|
||||||
|
onFolderDragStart,
|
||||||
|
onFolderDragEnd,
|
||||||
|
draggedFolderId,
|
||||||
|
onFolderDelete,
|
||||||
|
onFolderRename,
|
||||||
|
selectedFolderIds = [],
|
||||||
|
onDocumentOpen,
|
||||||
|
selectedDocumentIds = [],
|
||||||
|
focusedRowKey,
|
||||||
|
draggingDocumentIds = [],
|
||||||
|
onDocumentDragStart,
|
||||||
|
onDocumentDragEnd,
|
||||||
|
onDocumentDelete,
|
||||||
|
onDocumentRename,
|
||||||
|
onRowSelection = null,
|
||||||
|
onOpenDetailPanel = null,
|
||||||
|
tagLookupById,
|
||||||
|
activeCorrespondentIds = [],
|
||||||
|
onDocumentListFocus,
|
||||||
|
onDocumentListKeyDown,
|
||||||
|
onFocusedRowChange,
|
||||||
|
ensureAssetUrl = null,
|
||||||
|
getDocumentAsset = () => null,
|
||||||
|
getDownloadHref,
|
||||||
|
onTagClick,
|
||||||
|
onCorrespondentClick,
|
||||||
|
isSearchLoading = false,
|
||||||
|
onDocumentTagDrop,
|
||||||
|
viewMode = 'list',
|
||||||
|
onViewModeChange,
|
||||||
|
onClearSelection,
|
||||||
|
showHeader = true,
|
||||||
|
}) => {
|
||||||
|
const showingSearchResults = searchResults !== null;
|
||||||
|
const rows = showingSearchResults ? searchResults : documents;
|
||||||
|
|
||||||
|
const entries = useMemo(() => {
|
||||||
|
const list = [];
|
||||||
|
if (!showingSearchResults) {
|
||||||
|
subfolders.forEach((folder) => {
|
||||||
|
if (!folder || !folder.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.forEach((doc) => {
|
||||||
|
if (!doc || !doc.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc });
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}, [showingSearchResults, subfolders, rows]);
|
||||||
|
|
||||||
|
const selectedSet = useMemo(
|
||||||
|
() => new Set(selectedDocumentIds),
|
||||||
|
[selectedDocumentIds],
|
||||||
|
);
|
||||||
|
const selectedFolderSet = useMemo(
|
||||||
|
() => new Set(selectedFolderIds || []),
|
||||||
|
[selectedFolderIds],
|
||||||
|
);
|
||||||
|
const draggingSet = useMemo(
|
||||||
|
() => new Set(draggingDocumentIds || []),
|
||||||
|
[draggingDocumentIds],
|
||||||
|
);
|
||||||
|
const activeCorrespondentIdSet = useMemo(
|
||||||
|
() => new Set(activeCorrespondentIds || []),
|
||||||
|
[activeCorrespondentIds],
|
||||||
|
);
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
const suppressDocumentClickRef = useRef(false);
|
||||||
|
const [, forceVisibilityTick] = useState(0);
|
||||||
|
const lastScrollNodeRef = useRef(null);
|
||||||
|
const assignScrollRef = useCallback((node) => {
|
||||||
|
if (lastScrollNodeRef.current === node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastScrollNodeRef.current = node;
|
||||||
|
scrollRef.current = node;
|
||||||
|
if (node) {
|
||||||
|
forceVisibilityTick((value) => value + 1);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const isGridView = viewMode === 'grid';
|
||||||
|
const isDeskView = viewMode === 'desk';
|
||||||
|
const isListView = viewMode === 'list';
|
||||||
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||||
|
const handleSetViewMode = useCallback(
|
||||||
|
(nextMode) => {
|
||||||
|
if (!onViewModeChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onViewModeChange(nextMode);
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onViewModeChange],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [viewMode]);
|
||||||
|
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
|
||||||
|
const ensureFocusedRowVisible = useCallback(() => {
|
||||||
|
if (!focusedRowKey) return;
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
let selector = null;
|
||||||
|
if (focusedRowKey.startsWith('document:')) {
|
||||||
|
selector = `#document-row-${focusedRowKey.slice('document:'.length)}`;
|
||||||
|
} else if (focusedRowKey.startsWith('folder:')) {
|
||||||
|
selector = `#folder-row-${focusedRowKey.slice('folder:'.length)}`;
|
||||||
|
}
|
||||||
|
if (!selector) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = container.querySelector(selector);
|
||||||
|
if (!row || !container.contains(row)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = container.querySelector('thead');
|
||||||
|
const headerHeight = header ? header.getBoundingClientRect().height : 0;
|
||||||
|
const rowTop = row.offsetTop;
|
||||||
|
const rowBottom = rowTop + row.offsetHeight;
|
||||||
|
const visibleTop = container.scrollTop + headerHeight;
|
||||||
|
const visibleBottom = container.scrollTop + container.clientHeight;
|
||||||
|
|
||||||
|
if (rowTop < visibleTop) {
|
||||||
|
container.scrollTop = Math.max(rowTop - headerHeight, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowBottom > visibleBottom) {
|
||||||
|
const nextScrollTop = rowBottom - container.clientHeight;
|
||||||
|
container.scrollTop = Math.max(nextScrollTop, 0);
|
||||||
|
}
|
||||||
|
}, [focusedRowKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureFocusedRowVisible();
|
||||||
|
}, [ensureFocusedRowVisible]);
|
||||||
|
|
||||||
|
const activeDescendantId = useMemo(() => {
|
||||||
|
if (!focusedRowKey) return undefined;
|
||||||
|
if (focusedRowKey.startsWith('document:')) {
|
||||||
|
return `document-row-${focusedRowKey.slice('document:'.length)}`;
|
||||||
|
}
|
||||||
|
if (focusedRowKey.startsWith('folder:')) {
|
||||||
|
return `folder-row-${focusedRowKey.slice('folder:'.length)}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [focusedRowKey]);
|
||||||
|
|
||||||
|
const handleDocumentTagDragOver = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (!isTagDragEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
event.currentTarget.classList.add('tag-drop-target');
|
||||||
|
},
|
||||||
|
[isTagDragEvent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentTagDragLeave = useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (!isTagDragEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.currentTarget.classList.remove('tag-drop-target');
|
||||||
|
},
|
||||||
|
[isTagDragEvent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentTagDrop = useCallback(
|
||||||
|
(event, documentId) => {
|
||||||
|
if (!isTagDragEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.classList.remove('tag-drop-target');
|
||||||
|
const payload =
|
||||||
|
event.dataTransfer.getData('application/x-papercrate-tag') ||
|
||||||
|
event.dataTransfer.getData('text/papercrate-tag');
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload);
|
||||||
|
if (parsed?.id && onDocumentTagDrop) {
|
||||||
|
onDocumentTagDrop(documentId, parsed);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[documents] Failed to parse tag drop payload', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isTagDragEvent, onDocumentTagDrop],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEntryClick = useCallback(
|
||||||
|
(entry, event) => {
|
||||||
|
if (!entry || !entry.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.type === EntryType.document && suppressDocumentClickRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowKey = entry.type === EntryType.document ? `document:${entry.id}` : `folder:${entry.id}`;
|
||||||
|
|
||||||
|
if (rowKey && typeof onRowSelection === 'function') {
|
||||||
|
onRowSelection(rowKey, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === EntryType.document) {
|
||||||
|
const hasModifier = Boolean(
|
||||||
|
event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey),
|
||||||
|
);
|
||||||
|
if (!hasModifier && typeof onOpenDetailPanel === 'function') {
|
||||||
|
onOpenDetailPanel();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === EntryType.folder) {
|
||||||
|
const hasModifier = Boolean(
|
||||||
|
event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey),
|
||||||
|
);
|
||||||
|
const isPrimaryClick = Boolean(event && event.type === 'click' && event.button === 0);
|
||||||
|
if (!hasModifier && isPrimaryClick && typeof onFolderSelect === 'function') {
|
||||||
|
onFolderSelect(entry.id);
|
||||||
|
}
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
onFocusedRowChange?.(rowKey);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onRowSelection, onOpenDetailPanel, onFocusedRowChange, onFolderSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentClick = useCallback(
|
||||||
|
(doc, event) => {
|
||||||
|
if (!doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleEntryClick({ type: EntryType.document, id: doc.id, document: doc }, event);
|
||||||
|
},
|
||||||
|
[handleEntryClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFolderClick = useCallback(
|
||||||
|
(folder, event) => {
|
||||||
|
if (!folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleEntryClick({ type: EntryType.folder, id: folder.id, folder }, event);
|
||||||
|
},
|
||||||
|
[handleEntryClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentDragStartLocal = useCallback(
|
||||||
|
(event, doc) => {
|
||||||
|
suppressDocumentClickRef.current = true;
|
||||||
|
onDocumentDragStart?.(event, doc);
|
||||||
|
},
|
||||||
|
[onDocumentDragStart],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDocumentDragEndLocal = useCallback(
|
||||||
|
(event) => {
|
||||||
|
onDocumentDragEnd?.(event);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
suppressDocumentClickRef.current = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onDocumentDragEnd],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasDocumentEntries = useMemo(
|
||||||
|
() => entries.some((entry) => entry.type === EntryType.document),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
const showTableRows = entries.length > 0;
|
||||||
|
const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0;
|
||||||
|
const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading;
|
||||||
|
const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading;
|
||||||
|
const showSearchHint = showingSearchResults && rows.length > 0;
|
||||||
|
const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]);
|
||||||
|
const trailEntries = useMemo(() => {
|
||||||
|
if (!breadcrumbEntries.length) {
|
||||||
|
return [{ id: 'current-folder', label: currentFolderName }];
|
||||||
|
}
|
||||||
|
const lastIndex = breadcrumbEntries.length - 1;
|
||||||
|
return breadcrumbEntries.map((crumb, index) => ({
|
||||||
|
id: crumb.id ?? index,
|
||||||
|
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
|
||||||
|
onClick: index < lastIndex && onFolderSelect
|
||||||
|
? () => onFolderSelect(crumb.id)
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}, [breadcrumbEntries, currentFolderName, onFolderSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`documents-panel documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||||
|
>
|
||||||
|
{showHeader ? (
|
||||||
|
<div className="panel-section__header">
|
||||||
|
<div className="panel-section__titles">
|
||||||
|
<h2 className="documents-panel__title">
|
||||||
|
<BreadcrumbTrail
|
||||||
|
entries={trailEntries}
|
||||||
|
className="documents-panel__breadcrumbs"
|
||||||
|
separator="/"
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
{showingSearchResults && (
|
||||||
|
<div className="panel-section__subtitle">Search results</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="header-actions">
|
||||||
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
||||||
|
onClick={() => handleSetViewMode('list')}
|
||||||
|
aria-pressed={isListView}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||||
|
onClick={() => handleSetViewMode('grid')}
|
||||||
|
aria-pressed={isGridView}
|
||||||
|
title="Icons view"
|
||||||
|
>
|
||||||
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
||||||
|
onClick={() => handleSetViewMode('desk')}
|
||||||
|
aria-pressed={isDeskView}
|
||||||
|
title="Desk view"
|
||||||
|
>
|
||||||
|
<IconFileStack className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button className="secondary" onClick={onRefresh}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showDefaultEmptyState ? (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div className="empty-state">
|
||||||
|
Drop files anywhere or onto a folder to upload documents.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : showGridSearchEmptyState ? (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div className="empty-state empty-state--global">
|
||||||
|
No documents match the current filters.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : showListSearchEmptyState ? (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div className="empty-state">No documents match the current filters.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel-section__body">
|
||||||
|
<div
|
||||||
|
ref={assignScrollRef}
|
||||||
|
className="documents-scroll"
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={(event) => {
|
||||||
|
if (event.target === scrollRef.current) {
|
||||||
|
onDocumentListFocus?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.target !== scrollRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onDocumentListKeyDown) {
|
||||||
|
onDocumentListKeyDown(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||||
|
>
|
||||||
|
{isGridView ? (
|
||||||
|
<DocumentsGrid
|
||||||
|
entries={entries}
|
||||||
|
selectedDocumentIdsSet={selectedSet}
|
||||||
|
selectedFolderIdsSet={selectedFolderSet}
|
||||||
|
draggingDocumentIdsSet={draggingSet}
|
||||||
|
draggedFolderId={draggedFolderId}
|
||||||
|
onFolderClick={handleFolderClick}
|
||||||
|
onFolderSelect={onFolderSelect}
|
||||||
|
onFolderDragOver={onFolderDragOver}
|
||||||
|
onFolderDragLeave={onFolderDragLeave}
|
||||||
|
onFolderDrop={onFolderDrop}
|
||||||
|
onFolderDragStart={onFolderDragStart}
|
||||||
|
onFolderDragEnd={onFolderDragEnd}
|
||||||
|
onDocumentClick={handleDocumentClick}
|
||||||
|
onDocumentOpen={onDocumentOpen}
|
||||||
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
||||||
|
onDocumentDragEnd={handleDocumentDragEndLocal}
|
||||||
|
onDocumentTagDragOver={handleDocumentTagDragOver}
|
||||||
|
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
||||||
|
onDocumentTagDrop={handleDocumentTagDrop}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
gridIconSize={gridIconSize}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
/>
|
||||||
|
) : !showTableRows ? null : (
|
||||||
|
<DocumentsList
|
||||||
|
entries={entries}
|
||||||
|
focusedRowKey={focusedRowKey}
|
||||||
|
selectedDocumentIdsSet={selectedSet}
|
||||||
|
selectedFolderIdsSet={selectedFolderSet}
|
||||||
|
draggingDocumentIdsSet={draggingSet}
|
||||||
|
draggedFolderId={draggedFolderId}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
getDownloadHref={getDownloadHref}
|
||||||
|
onFolderClick={handleFolderClick}
|
||||||
|
onFolderSelect={onFolderSelect}
|
||||||
|
onFolderDragOver={onFolderDragOver}
|
||||||
|
onFolderDragLeave={onFolderDragLeave}
|
||||||
|
onFolderDrop={onFolderDrop}
|
||||||
|
onFolderDragStart={onFolderDragStart}
|
||||||
|
onFolderDragEnd={onFolderDragEnd}
|
||||||
|
onFolderRename={onFolderRename}
|
||||||
|
onFolderDelete={onFolderDelete}
|
||||||
|
onDocumentClick={handleDocumentClick}
|
||||||
|
onDocumentOpen={onDocumentOpen}
|
||||||
|
onDocumentDragStart={handleDocumentDragStartLocal}
|
||||||
|
onDocumentDragEnd={handleDocumentDragEndLocal}
|
||||||
|
onDocumentTagDragOver={handleDocumentTagDragOver}
|
||||||
|
onDocumentTagDragLeave={handleDocumentTagDragLeave}
|
||||||
|
onDocumentTagDrop={handleDocumentTagDrop}
|
||||||
|
onDocumentRename={onDocumentRename}
|
||||||
|
onDocumentDelete={onDocumentDelete}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
onTagClick={onTagClick}
|
||||||
|
onCorrespondentClick={onCorrespondentClick}
|
||||||
|
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showSearchHint && (
|
||||||
|
<div className="search-hint">
|
||||||
|
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentsPanel;
|
||||||
|
|
||||||
|
export const createDocumentsTableHeaderActions = ({
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRefresh,
|
||||||
|
onShowDeskHelp = null,
|
||||||
|
}) => {
|
||||||
|
const isListView = viewMode === 'list';
|
||||||
|
const isGridView = viewMode === 'grid';
|
||||||
|
const isDeskView = viewMode === 'desk';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isListView ? ' active' : ''}`}
|
||||||
|
onClick={() => onViewModeChange?.('list')}
|
||||||
|
aria-pressed={isListView}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<ViewListIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isGridView ? ' active' : ''}`}
|
||||||
|
onClick={() => onViewModeChange?.('grid')}
|
||||||
|
aria-pressed={isGridView}
|
||||||
|
title="Icons view"
|
||||||
|
>
|
||||||
|
<ViewGridIcon className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isDeskView ? ' active' : ''}`}
|
||||||
|
onClick={() => onViewModeChange?.('desk')}
|
||||||
|
aria-pressed={isDeskView}
|
||||||
|
title="Desk view"
|
||||||
|
>
|
||||||
|
<IconFileStack className="view-toggle__icon" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
aria-label="Refresh"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
</button>
|
||||||
|
{isDeskView && typeof onShowDeskHelp === 'function' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onShowDeskHelp}
|
||||||
|
aria-label="Show desk view tips"
|
||||||
|
title="Show desk view tips"
|
||||||
|
>
|
||||||
|
<InfoIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDocumentsSurface = ({
|
||||||
|
tableProps,
|
||||||
|
parentBreadcrumb,
|
||||||
|
onNavigateParent,
|
||||||
|
renderSidebarToggle,
|
||||||
|
detailProps,
|
||||||
|
detailOpen = false,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
currentFolderName,
|
||||||
|
breadcrumbs,
|
||||||
|
searchResults,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRefresh,
|
||||||
|
} = tableProps;
|
||||||
|
|
||||||
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||||
|
const subtitle = Array.isArray(searchResults)
|
||||||
|
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const actions = createDocumentsTableHeaderActions({
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
|
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||||
|
|
||||||
|
const surfaceConfig = createWorkspaceSurfaceConfig({
|
||||||
|
key: 'documents',
|
||||||
|
variant: 'documents',
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
sidebarToggle,
|
||||||
|
parentBreadcrumb,
|
||||||
|
onNavigateParent,
|
||||||
|
actions,
|
||||||
|
breadcrumbs,
|
||||||
|
content: <DocumentsPanel {...tableProps} showHeader={false} />,
|
||||||
|
detail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return surfaceConfig;
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
export const resolveCorrespondents = (doc) => {
|
||||||
|
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
doc.correspondents.forEach((entry = {}, index) => {
|
||||||
|
const { id, name } = entry;
|
||||||
|
if (typeof name !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id && seen.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
seen.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id,
|
||||||
|
name: trimmedName,
|
||||||
|
key: id ?? `${trimmedName}-${index}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default resolveCorrespondents;
|
||||||
@@ -29,14 +29,14 @@ const sanitizeTags = (tags) => {
|
|||||||
if (!Array.isArray(tags)) {
|
if (!Array.isArray(tags)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return tags.filter((tag) => tag && (tag.label || tag.id));
|
return tags.filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeCorrespondents = (entries) => {
|
const sanitizeCorrespondents = (entries) => {
|
||||||
if (!Array.isArray(entries)) {
|
if (!Array.isArray(entries)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return entries.filter((entry) => entry && (entry.name || entry.id));
|
return entries.filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const describeDocumentSummary = (document, options = {}) => {
|
export const describeDocumentSummary = (document, options = {}) => {
|
||||||
@@ -47,7 +47,7 @@ export const describeDocumentSummary = (document, options = {}) => {
|
|||||||
mimeTypeLabel: '—',
|
mimeTypeLabel: '—',
|
||||||
sizeLabel: '—',
|
sizeLabel: '—',
|
||||||
createdAtLabel: '—',
|
createdAtLabel: '—',
|
||||||
issuedAtLabel: '—',
|
issuedLabel: '—',
|
||||||
updatedAtLabel: '—',
|
updatedAtLabel: '—',
|
||||||
pageCount: null,
|
pageCount: null,
|
||||||
pageCountLabel: '—',
|
pageCountLabel: '—',
|
||||||
@@ -64,7 +64,6 @@ export const describeDocumentSummary = (document, options = {}) => {
|
|||||||
formatDateTime = defaultFormatDateTime,
|
formatDateTime = defaultFormatDateTime,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const title = document.title;
|
|
||||||
const originalName = document.original_name;
|
const originalName = document.original_name;
|
||||||
const mimeTypeLabel = document.content_type || 'Unknown';
|
const mimeTypeLabel = document.content_type || 'Unknown';
|
||||||
|
|
||||||
@@ -76,10 +75,10 @@ export const describeDocumentSummary = (document, options = {}) => {
|
|||||||
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
|
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
|
||||||
|
|
||||||
const createdAtLabel = formatDateTime(document.created_at);
|
const createdAtLabel = formatDateTime(document.created_at);
|
||||||
const issuedAtLabel = formatDateTime(document.issued_at);
|
const issuedLabel = formatDateTime(document.issued_at);
|
||||||
const updatedAtLabel = formatDateTime(document.updated_at);
|
const updatedAtLabel = formatDateTime(document.updated_at);
|
||||||
|
|
||||||
const folderLabel = document.folder_path || document.folder_name || null;
|
const folderLabel = document.folder_path;
|
||||||
|
|
||||||
const tags = sanitizeTags(document.tags);
|
const tags = sanitizeTags(document.tags);
|
||||||
const correspondents = sanitizeCorrespondents(document.correspondents);
|
const correspondents = sanitizeCorrespondents(document.correspondents);
|
||||||
@@ -96,21 +95,21 @@ export const describeDocumentSummary = (document, options = {}) => {
|
|||||||
{ key: 'created', label: 'Created', value: createdAtLabel },
|
{ key: 'created', label: 'Created', value: createdAtLabel },
|
||||||
{ key: 'size', label: 'Size', value: sizeLabel },
|
{ key: 'size', label: 'Size', value: sizeLabel },
|
||||||
{ key: 'type', label: 'Type', value: mimeTypeLabel },
|
{ key: 'type', label: 'Type', value: mimeTypeLabel },
|
||||||
{ key: 'issued', label: 'Issued', value: issuedAtLabel },
|
{ key: 'issued', label: 'Issued', value: issuedLabel },
|
||||||
{ key: 'pages', label: 'Pages', value: pageCountLabel },
|
{ key: 'pages', label: 'Pages', value: pageCountLabel },
|
||||||
{ key: 'updated', label: 'Updated', value: updatedAtLabel },
|
{ key: 'updated', label: 'Updated', value: updatedAtLabel },
|
||||||
{ key: 'folder', label: 'Folder', value: folderLabel || '—' },
|
{ key: 'folder', label: 'Folder', value: folderLabel },
|
||||||
{ key: 'tags', label: 'Tags', value: tagsSummary },
|
{ key: 'tags', label: 'Tags', value: tagsSummary },
|
||||||
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary },
|
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary },
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title: document.title,
|
||||||
originalName,
|
originalName,
|
||||||
mimeTypeLabel,
|
mimeTypeLabel,
|
||||||
sizeLabel,
|
sizeLabel,
|
||||||
createdAtLabel,
|
createdAtLabel,
|
||||||
issuedAtLabel,
|
issuedLabel,
|
||||||
updatedAtLabel,
|
updatedAtLabel,
|
||||||
pageCount,
|
pageCount,
|
||||||
pageCountLabel,
|
pageCountLabel,
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ArrowUpIcon } from '../ui/icons';
|
|
||||||
|
|
||||||
export const createWorkspaceSurfaceConfig = ({
|
export const createWorkspaceSurfaceConfig = ({
|
||||||
title,
|
title,
|
||||||
subtitle = null,
|
subtitle = null,
|
||||||
sidebarToggle = null,
|
sidebarToggle = null,
|
||||||
parentBreadcrumb = null,
|
|
||||||
onNavigateParent = null,
|
|
||||||
actions = null,
|
actions = null,
|
||||||
breadcrumbs = null,
|
breadcrumbs = null,
|
||||||
content = null,
|
content = null,
|
||||||
@@ -14,25 +11,10 @@ export const createWorkspaceSurfaceConfig = ({
|
|||||||
variant = 'documents',
|
variant = 'documents',
|
||||||
key = 'documents',
|
key = 'documents',
|
||||||
}) => {
|
}) => {
|
||||||
const showParent = parentBreadcrumb && typeof onNavigateParent === 'function';
|
const leading = sidebarToggle
|
||||||
|
|
||||||
const parentControl = showParent ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={onNavigateParent}
|
|
||||||
aria-label="Go to parent folder"
|
|
||||||
title="Go to parent folder"
|
|
||||||
>
|
|
||||||
<ArrowUpIcon />
|
|
||||||
</button>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const leading = sidebarToggle || parentControl
|
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
{sidebarToggle}
|
{sidebarToggle}
|
||||||
{parentControl}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { DownloadIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
||||||
|
import DocumentSummarySection, {
|
||||||
|
buildCorrespondentOptions,
|
||||||
|
sortCorrespondents,
|
||||||
|
} from '../documents/DocumentSummarySection';
|
||||||
|
import { createDocumentActionState } from '../documents/documentActions';
|
||||||
|
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentViewerPanel = ({
|
||||||
|
document,
|
||||||
|
documentId,
|
||||||
|
previewEntry,
|
||||||
|
tagLookupById,
|
||||||
|
tagOptions,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
correspondents,
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued,
|
||||||
|
hasOcr = false,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
}) => {
|
||||||
|
const sortedCorrespondents = useMemo(
|
||||||
|
() => sortCorrespondents(document?.correspondents || []),
|
||||||
|
[document],
|
||||||
|
);
|
||||||
|
|
||||||
|
const correspondentOptions = useMemo(
|
||||||
|
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
|
||||||
|
[correspondents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadataItems = useMemo(() => {
|
||||||
|
if (!document) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ label: 'Created at', value: formatDateTime(document.created_at) },
|
||||||
|
{ label: 'Updated at', value: formatDateTime(document.updated_at) },
|
||||||
|
{
|
||||||
|
label: 'Filename',
|
||||||
|
value: document.filename,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Original filename',
|
||||||
|
value: document.original_name || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'SHA-256 checksum',
|
||||||
|
value: document.current_version?.checksum || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Content type',
|
||||||
|
value: document.content_type || '—',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [document]);
|
||||||
|
|
||||||
|
const previewContent = useMemo(() => {
|
||||||
|
if (!document || !previewEntry?.url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedContentType = (previewEntry.contentType
|
||||||
|
|| document.content_type
|
||||||
|
|| '')
|
||||||
|
.toLowerCase();
|
||||||
|
const isImage = normalizedContentType.startsWith('image/');
|
||||||
|
const isPdf = normalizedContentType === 'application/pdf'
|
||||||
|
|| normalizedContentType === 'application/x-pdf';
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={previewEntry.url}
|
||||||
|
alt={`Preview of ${document.title}`}
|
||||||
|
className="document-viewer__object document-viewer__object--image"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPdf) {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={previewEntry.url}
|
||||||
|
title={`Preview of ${document.title}`}
|
||||||
|
className="document-viewer__object"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayContentType = document.content_type || previewEntry.contentType || 'this file type';
|
||||||
|
const displayFilename = previewEntry.filename
|
||||||
|
|| document.filename
|
||||||
|
|| document.original_name
|
||||||
|
|| 'download';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="document-viewer__unsupported">
|
||||||
|
<div className="document-viewer__unsupported-message">
|
||||||
|
Preview is not available for {displayContentType} files.
|
||||||
|
</div>
|
||||||
|
<div className="document-viewer__unsupported-filename">{displayFilename}</div>
|
||||||
|
<a
|
||||||
|
className="button-link document-viewer__unsupported-download"
|
||||||
|
href={previewEntry.url}
|
||||||
|
download={displayFilename}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
<span>Download</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [previewEntry, document]);
|
||||||
|
|
||||||
|
const metadataPayload = useMemo(() => {
|
||||||
|
if (!document || !document.metadata || Object.keys(document.metadata).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return document.metadata;
|
||||||
|
}, [document]);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState('details');
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTab('details');
|
||||||
|
}, [document?.id, hasOcr, metadataPayload]);
|
||||||
|
|
||||||
|
const [ocrContent, setOcrContent] = useState(null);
|
||||||
|
const [ocrLoading, setOcrLoading] = useState(false);
|
||||||
|
const [ocrError, setOcrError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!document || !hasOcr || typeof getDocumentAsset !== 'function') {
|
||||||
|
setOcrContent(null);
|
||||||
|
setOcrLoading(false);
|
||||||
|
setOcrError(null);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateUrl = () =>
|
||||||
|
resolveDocumentAssetUrl(document, 'ocr-text', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const asset = getDocumentAsset(document, 'ocr-text');
|
||||||
|
|
||||||
|
const ensureAndUpdate = async () => {
|
||||||
|
setOcrLoading(true);
|
||||||
|
setOcrError(null);
|
||||||
|
|
||||||
|
let url = updateUrl();
|
||||||
|
if (!url && document.id && asset?.id && typeof ensureAssetUrl === 'function') {
|
||||||
|
try {
|
||||||
|
await ensureAssetUrl(document.id, asset, { start: 1, limit: 1 });
|
||||||
|
url = updateUrl();
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setOcrError('Unable to load OCR content.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let textContent = null;
|
||||||
|
if (!cancelled && url) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'omit',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unexpected status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
textContent = await response.text();
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('[OCR] Failed to fetch text', error);
|
||||||
|
setOcrError('Unable to load OCR content.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setOcrContent(textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
if (!textContent) {
|
||||||
|
setOcrContent(null);
|
||||||
|
}
|
||||||
|
setOcrLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureAndUpdate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [document, hasOcr, ensureAssetUrl, getDocumentAsset]);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
return (
|
||||||
|
<section className="document-viewer document-viewer--loading">
|
||||||
|
<div className="document-viewer__details">
|
||||||
|
<div className="document-viewer__message">
|
||||||
|
Loading document{documentId ? ` ${documentId}` : ''}…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="document-viewer__viewport">
|
||||||
|
<div className="document-viewer__message">Preparing preview…</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="document-viewer">
|
||||||
|
<div className="document-viewer__details">
|
||||||
|
<DocumentSummarySection
|
||||||
|
document={document}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
tagOptions={tagOptions}
|
||||||
|
onTagAdd={onTagAdd}
|
||||||
|
onTagRemove={onTagRemove}
|
||||||
|
correspondents={sortedCorrespondents}
|
||||||
|
correspondentOptions={correspondentOptions}
|
||||||
|
onCorrespondentAdd={onCorrespondentAdd}
|
||||||
|
onCorrespondentRemove={onCorrespondentRemove}
|
||||||
|
onUpdateTitle={onUpdateTitle}
|
||||||
|
onUpdateIssued={onUpdateIssued}
|
||||||
|
/>
|
||||||
|
<div className="document-viewer__tabs-wrapper">
|
||||||
|
<div className="document-viewer__tabs" role="tablist" aria-label="Document details">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'details'}
|
||||||
|
className={`document-viewer__tab${activeTab === 'details' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('details')}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
{hasOcr ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'content'}
|
||||||
|
className={`document-viewer__tab${activeTab === 'content' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('content')}
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{metadataPayload ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'metadata'}
|
||||||
|
className={`document-viewer__tab${activeTab === 'metadata' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('metadata')}
|
||||||
|
>
|
||||||
|
Metadata
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="document-viewer__tabpanes">
|
||||||
|
{activeTab === 'details' ? (
|
||||||
|
<div role="tabpanel" className="document-viewer__tabpanel">
|
||||||
|
<section className="document-viewer__section">
|
||||||
|
<dl className="document-viewer__section-list">
|
||||||
|
{metadataItems.map(({ label, value }) => (
|
||||||
|
<div className="document-viewer__section-item" key={label}>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{value || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeTab === 'content' && hasOcr ? (
|
||||||
|
<div role="tabpanel" className="document-viewer__tabpanel">
|
||||||
|
{ocrLoading ? (
|
||||||
|
<div className="document-viewer__message">Loading OCR content…</div>
|
||||||
|
) : ocrError ? (
|
||||||
|
<div className="document-viewer__message document-viewer__message--error">
|
||||||
|
{ocrError}
|
||||||
|
</div>
|
||||||
|
) : ocrContent ? (
|
||||||
|
<pre className="document-viewer__object document-viewer__object--ocr-text">
|
||||||
|
{ocrContent}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="document-viewer__message">No OCR content available.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeTab === 'metadata' && metadataPayload ? (
|
||||||
|
<div role="tabpanel" className="document-viewer__tabpanel">
|
||||||
|
<section className="document-viewer__section document-viewer__section--metadata-json">
|
||||||
|
<pre className="document-viewer__metadata-json">
|
||||||
|
{JSON.stringify(metadataPayload, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="document-viewer__viewport">
|
||||||
|
{!previewEntry?.url ? (
|
||||||
|
<div className="document-viewer__message">Loading preview…</div>
|
||||||
|
) : (
|
||||||
|
previewContent
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentViewerPanel;
|
||||||
|
|
||||||
|
export const createDocumentViewerHeaderActions = ({
|
||||||
|
document,
|
||||||
|
actionState,
|
||||||
|
onRegenerate,
|
||||||
|
}) => {
|
||||||
|
if (!document || !actionState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { downloadHref } = actionState;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{downloadHref ? (
|
||||||
|
<a
|
||||||
|
className="icon-button"
|
||||||
|
href={downloadHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Download document"
|
||||||
|
title="Download document"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => onRegenerate(document.id)}
|
||||||
|
aria-label="Re-run analysis"
|
||||||
|
title="Re-run analysis"
|
||||||
|
>
|
||||||
|
<AnalyzeIcon />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDocumentViewerSurface = ({
|
||||||
|
documentId,
|
||||||
|
document,
|
||||||
|
previewEntry,
|
||||||
|
ensureAssetUrl,
|
||||||
|
ensurePreviewData,
|
||||||
|
getDocumentAsset,
|
||||||
|
resolveApiPath,
|
||||||
|
notifyApiError,
|
||||||
|
onRegenerate,
|
||||||
|
onClose,
|
||||||
|
renderSidebarToggle,
|
||||||
|
tagLookupById,
|
||||||
|
tagOptions,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
correspondents,
|
||||||
|
onCorrespondentAdd,
|
||||||
|
onCorrespondentRemove,
|
||||||
|
onUpdateTitle,
|
||||||
|
onUpdateIssued,
|
||||||
|
resolveFolderPath,
|
||||||
|
}) => {
|
||||||
|
if (!documentId && !document) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = document?.title || 'Document preview';
|
||||||
|
const closeButton = onClose
|
||||||
|
? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() => onClose?.()}
|
||||||
|
aria-label="Close preview"
|
||||||
|
title="Close preview"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||||
|
const leading = closeButton || sidebarToggle
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
{sidebarToggle}
|
||||||
|
{closeButton}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
let breadcrumbs = null;
|
||||||
|
if (document && typeof resolveFolderPath === 'function') {
|
||||||
|
const folderSegments = resolveFolderPath(document.folder_id);
|
||||||
|
const normalizedSegments = Array.isArray(folderSegments)
|
||||||
|
? folderSegments
|
||||||
|
.filter((segment) => segment && segment.id && segment.name)
|
||||||
|
.map((segment) => ({ id: segment.id, name: segment.name }))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
breadcrumbs = [
|
||||||
|
...normalizedSegments,
|
||||||
|
{ id: document.id, name: document.title },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionState = document
|
||||||
|
? createDocumentActionState({
|
||||||
|
document,
|
||||||
|
resolveApiPath,
|
||||||
|
ensurePreviewData,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
notifyApiError,
|
||||||
|
ocrErrorMessage: 'Unable to open OCR text.',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const header = {
|
||||||
|
title,
|
||||||
|
subtitle: null,
|
||||||
|
leading,
|
||||||
|
actions: createDocumentViewerHeaderActions({
|
||||||
|
document,
|
||||||
|
actionState,
|
||||||
|
onRegenerate,
|
||||||
|
}),
|
||||||
|
breadcrumbs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: 'preview',
|
||||||
|
variant: 'preview',
|
||||||
|
header,
|
||||||
|
content: (
|
||||||
|
<DocumentViewerPanel
|
||||||
|
document={document}
|
||||||
|
documentId={documentId}
|
||||||
|
previewEntry={previewEntry}
|
||||||
|
tagLookupById={tagLookupById}
|
||||||
|
tagOptions={tagOptions}
|
||||||
|
onTagAdd={onTagAdd}
|
||||||
|
onTagRemove={onTagRemove}
|
||||||
|
correspondents={correspondents}
|
||||||
|
onCorrespondentAdd={onCorrespondentAdd}
|
||||||
|
onCorrespondentRemove={onCorrespondentRemove}
|
||||||
|
onUpdateTitle={onUpdateTitle}
|
||||||
|
onUpdateIssued={onUpdateIssued}
|
||||||
|
hasOcr={Boolean(actionState?.hasOcr)}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
supportsDetail: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
|
|
||||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
|
||||||
import { createDocumentActionState } from '../documents/documentActions';
|
|
||||||
|
|
||||||
const PreviewWorkspace = ({
|
|
||||||
document,
|
|
||||||
previewEntry,
|
|
||||||
}) => {
|
|
||||||
if (!document) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = document.title;
|
|
||||||
const summary = describeDocumentSummary(document);
|
|
||||||
const correspondents = Array.isArray(document.correspondents)
|
|
||||||
? document.correspondents.map((entry) => entry?.name).filter(Boolean).join(', ')
|
|
||||||
: '';
|
|
||||||
const tags = Array.isArray(document.tags)
|
|
||||||
? document.tags.map((tag) => tag?.label).filter(Boolean).join(', ')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const formatDateTime = (value) => {
|
|
||||||
if (!value) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const detailItems = [
|
|
||||||
{ label: 'Title', value: summary.title || '—' },
|
|
||||||
{ label: 'Archive Reference', value: document.archive_serial || '—' },
|
|
||||||
{ label: 'Issued On', value: formatDateTime(document.issued_at) },
|
|
||||||
{ label: 'Correspondent', value: correspondents || '—' },
|
|
||||||
{
|
|
||||||
label: 'Filename',
|
|
||||||
value: document.archive_path || document.filename || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Original Filename',
|
|
||||||
value: document.original_name || '—',
|
|
||||||
},
|
|
||||||
{ label: 'Tags', value: tags || '—' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const metadataItems = [
|
|
||||||
{ label: 'Modified At', value: formatDateTime(document.updated_at) },
|
|
||||||
{ label: 'Created At', value: formatDateTime(document.created_at) },
|
|
||||||
{
|
|
||||||
label: 'Media Filename',
|
|
||||||
value: document.current_version?.filename || document.archive_path || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'SHA-256 Checksum',
|
|
||||||
value: document.current_version?.checksum || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Original File Size',
|
|
||||||
value: summary.sizeLabel,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Original MIME Type',
|
|
||||||
value: document.content_type || '—',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const metadata =
|
|
||||||
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="preview-workspace">
|
|
||||||
<div className="preview-workspace__details">
|
|
||||||
<section className="preview-section">
|
|
||||||
<h3 className="preview-section__title">Details</h3>
|
|
||||||
<dl className="preview-section__list">
|
|
||||||
{detailItems.map(({ label, value }) => (
|
|
||||||
<div className="preview-section__item" key={label}>
|
|
||||||
<dt>{label}</dt>
|
|
||||||
<dd>{value || '—'}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
<section className="preview-section">
|
|
||||||
<h3 className="preview-section__title">Content</h3>
|
|
||||||
<p className="preview-section__placeholder">
|
|
||||||
Full OCR text will appear here in a future update. Use the toolbar button to open the OCR view for now.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
<section className="preview-section">
|
|
||||||
<h3 className="preview-section__title">Metadata</h3>
|
|
||||||
<dl className="preview-section__list">
|
|
||||||
{metadataItems.map(({ label, value }) => (
|
|
||||||
<div className="preview-section__item" key={label}>
|
|
||||||
<dt>{label}</dt>
|
|
||||||
<dd>{value || '—'}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
{metadata ? (
|
|
||||||
<details className="preview-section__payload">
|
|
||||||
<summary>Show metadata payload</summary>
|
|
||||||
<pre>{JSON.stringify(metadata, null, 2)}</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
<section className="preview-section">
|
|
||||||
<h3 className="preview-section__title">Notes</h3>
|
|
||||||
<p className="preview-section__placeholder">Custom notes will be editable here once the feature lands.</p>
|
|
||||||
</section>
|
|
||||||
<section className="preview-section">
|
|
||||||
<h3 className="preview-section__title">History</h3>
|
|
||||||
<p className="preview-section__placeholder">Change history will be displayed here in an upcoming release.</p>
|
|
||||||
</section>
|
|
||||||
<section className="preview-section">
|
|
||||||
<h3 className="preview-section__title">Permissions</h3>
|
|
||||||
<p className="preview-section__placeholder">Access control management is planned and will surface here.</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<div className="preview-workspace__viewer">
|
|
||||||
{!previewEntry?.url ? (
|
|
||||||
<div className="preview-workspace__message">Loading preview…</div>
|
|
||||||
) : (
|
|
||||||
<iframe
|
|
||||||
src={previewEntry.url}
|
|
||||||
title={`Preview of ${title}`}
|
|
||||||
className="preview-workspace__object"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PreviewWorkspace;
|
|
||||||
|
|
||||||
export const createPreviewWorkspaceHeaderActions = ({
|
|
||||||
document,
|
|
||||||
ensureAssetUrl,
|
|
||||||
ensurePreviewData,
|
|
||||||
getDocumentAsset,
|
|
||||||
resolveApiPath,
|
|
||||||
notifyApiError,
|
|
||||||
onRegenerate,
|
|
||||||
}) => {
|
|
||||||
if (!document) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { downloadHref, hasOcr, openOcr } = createDocumentActionState({
|
|
||||||
document,
|
|
||||||
resolveApiPath,
|
|
||||||
ensurePreviewData,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
notifyApiError,
|
|
||||||
ocrErrorMessage: 'Unable to open OCR text.',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{downloadHref ? (
|
|
||||||
<a
|
|
||||||
className="icon-button"
|
|
||||||
href={downloadHref}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Download document"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{hasOcr ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={() => {
|
|
||||||
openOcr().catch(() => {});
|
|
||||||
}}
|
|
||||||
aria-label="View OCR text"
|
|
||||||
title="View OCR text"
|
|
||||||
>
|
|
||||||
<TextScanIcon />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={() => onRegenerate(document.id)}
|
|
||||||
aria-label="Re-run analysis"
|
|
||||||
title="Re-run analysis"
|
|
||||||
>
|
|
||||||
<AnalyzeIcon />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createPreviewSurface = ({
|
|
||||||
document,
|
|
||||||
previewEntry,
|
|
||||||
ensureAssetUrl,
|
|
||||||
ensurePreviewData,
|
|
||||||
getDocumentAsset,
|
|
||||||
resolveApiPath,
|
|
||||||
notifyApiError,
|
|
||||||
onRegenerate,
|
|
||||||
onClose,
|
|
||||||
renderSidebarToggle,
|
|
||||||
}) => {
|
|
||||||
if (!document) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = document.title;
|
|
||||||
const closeButton = onClose
|
|
||||||
? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="icon-button"
|
|
||||||
onClick={() => onClose?.()}
|
|
||||||
aria-label="Close preview"
|
|
||||||
title="Close preview"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
|
||||||
const leading = closeButton || sidebarToggle
|
|
||||||
? (
|
|
||||||
<>
|
|
||||||
{sidebarToggle}
|
|
||||||
{closeButton}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const header = {
|
|
||||||
title,
|
|
||||||
subtitle: null,
|
|
||||||
leading,
|
|
||||||
actions: createPreviewWorkspaceHeaderActions({
|
|
||||||
document,
|
|
||||||
ensureAssetUrl,
|
|
||||||
ensurePreviewData,
|
|
||||||
getDocumentAsset,
|
|
||||||
resolveApiPath,
|
|
||||||
notifyApiError,
|
|
||||||
onRegenerate,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: 'preview',
|
|
||||||
variant: 'preview',
|
|
||||||
header,
|
|
||||||
content: <PreviewWorkspace document={document} previewEntry={previewEntry} />,
|
|
||||||
supportsDetail: false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -44,7 +44,7 @@ const DocumentViewerRoute = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) {
|
if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) {
|
||||||
return <div className="preview-workspace__message">Loading preview…</div>;
|
return <div className="document-viewer__message">Loading preview…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Navigate to="/documents" replace />;
|
return <Navigate to="/documents" replace />;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const SECTIONS = [
|
|||||||
label: 'Passkeys',
|
label: 'Passkeys',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'webdav',
|
id: 'apiTokens',
|
||||||
label: 'WebDAV',
|
label: 'API tokens',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -20,10 +20,12 @@ const SettingsModal = ({
|
|||||||
creating = false,
|
creating = false,
|
||||||
deletingId = null,
|
deletingId = null,
|
||||||
regeneratingId = null,
|
regeneratingId = null,
|
||||||
|
updatingId = null,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onCreate,
|
onCreate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
|
onUpdateCapabilities,
|
||||||
createdToken = null,
|
createdToken = null,
|
||||||
onDismissCreatedToken,
|
onDismissCreatedToken,
|
||||||
passkeys = [],
|
passkeys = [],
|
||||||
@@ -39,6 +41,7 @@ const SettingsModal = ({
|
|||||||
const [activeSection, setActiveSection] = useState(defaultSection);
|
const [activeSection, setActiveSection] = useState(defaultSection);
|
||||||
const [newTokenLabel, setNewTokenLabel] = useState('');
|
const [newTokenLabel, setNewTokenLabel] = useState('');
|
||||||
const [newTokenExpires, setNewTokenExpires] = useState('');
|
const [newTokenExpires, setNewTokenExpires] = useState('');
|
||||||
|
const [newTokenCapabilities, setNewTokenCapabilities] = useState([]);
|
||||||
const [formError, setFormError] = useState(null);
|
const [formError, setFormError] = useState(null);
|
||||||
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@ const SettingsModal = ({
|
|||||||
setActiveSection(defaultSection);
|
setActiveSection(defaultSection);
|
||||||
setNewTokenLabel('');
|
setNewTokenLabel('');
|
||||||
setNewTokenExpires('');
|
setNewTokenExpires('');
|
||||||
|
setNewTokenCapabilities([]);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setNewPasskeyNickname('');
|
setNewPasskeyNickname('');
|
||||||
}
|
}
|
||||||
@@ -116,6 +120,57 @@ const SettingsModal = ({
|
|||||||
[onRevokePasskey],
|
[onRevokePasskey],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const capabilityOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'webdav', label: 'WebDAV access' },
|
||||||
|
{ value: 'api', label: 'REST API access' },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNewCapabilityChange = useCallback((capability, enabled) => {
|
||||||
|
setFormError(null);
|
||||||
|
setNewTokenCapabilities((previous) => {
|
||||||
|
if (enabled) {
|
||||||
|
if (previous.includes(capability)) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
return [...previous, capability];
|
||||||
|
}
|
||||||
|
return previous.filter((value) => value !== capability);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleTokenCapability = useCallback(
|
||||||
|
async (token, capability, enabled) => {
|
||||||
|
if (!token?.id || !onUpdateCapabilities) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = Array.isArray(token.capabilities) ? [...token.capabilities] : [];
|
||||||
|
let next;
|
||||||
|
if (enabled) {
|
||||||
|
if (existing.includes(capability)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next = [...existing, capability];
|
||||||
|
} else {
|
||||||
|
next = existing.filter((value) => value !== capability);
|
||||||
|
if (next.length === 0) {
|
||||||
|
setFormError('Tokens must have at least one capability.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormError(null);
|
||||||
|
const result = await onUpdateCapabilities(token.id, next);
|
||||||
|
if (result === false) {
|
||||||
|
setFormError('Failed to update token capabilities.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onUpdateCapabilities],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateToken = useCallback(
|
const handleCreateToken = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -135,18 +190,25 @@ const SettingsModal = ({
|
|||||||
normalizedExpires = parsed.toISOString();
|
normalizedExpires = parsed.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!newTokenCapabilities.length) {
|
||||||
|
setFormError('Select at least one capability.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await onCreate?.({
|
const result = await onCreate?.({
|
||||||
label: normalizedLabel,
|
label: normalizedLabel,
|
||||||
expires_at: normalizedExpires,
|
expires_at: normalizedExpires,
|
||||||
|
capabilities: newTokenCapabilities,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result !== false) {
|
if (result !== false) {
|
||||||
setNewTokenLabel('');
|
setNewTokenLabel('');
|
||||||
setNewTokenExpires('');
|
setNewTokenExpires('');
|
||||||
|
setNewTokenCapabilities([]);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[newTokenExpires, newTokenLabel, onCreate],
|
[newTokenExpires, newTokenLabel, newTokenCapabilities, onCreate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRegenerateToken = useCallback(
|
const handleRegenerateToken = useCallback(
|
||||||
@@ -159,7 +221,7 @@ const SettingsModal = ({
|
|||||||
[onRegenerate],
|
[onRegenerate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderWebdavSection = useMemo(() => {
|
const renderApiTokensSection = useMemo(() => {
|
||||||
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -175,6 +237,11 @@ const SettingsModal = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
API tokens can grant access to the REST API, WebDAV, or both. Select at least one
|
||||||
|
capability for each token. You can adjust capabilities for existing tokens at any time.
|
||||||
|
</p>
|
||||||
|
|
||||||
{createdToken ? (
|
{createdToken ? (
|
||||||
<div className="settings-notice">
|
<div className="settings-notice">
|
||||||
<p>
|
<p>
|
||||||
@@ -194,24 +261,44 @@ const SettingsModal = ({
|
|||||||
|
|
||||||
<form className="settings-form" onSubmit={handleCreateToken}>
|
<form className="settings-form" onSubmit={handleCreateToken}>
|
||||||
<div className="settings-form__field">
|
<div className="settings-form__field">
|
||||||
<label htmlFor="webdav-token-label">Label</label>
|
<label htmlFor="api-token-label">Label</label>
|
||||||
<input
|
<input
|
||||||
id="webdav-token-label"
|
id="api-token-label"
|
||||||
type="text"
|
type="text"
|
||||||
value={newTokenLabel}
|
value={newTokenLabel}
|
||||||
onChange={(event) => setNewTokenLabel(event.target.value)}
|
onChange={(event) => setNewTokenLabel(event.target.value)}
|
||||||
placeholder="Personal WebDAV token"
|
placeholder="Personal API token"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-form__field">
|
<div className="settings-form__field">
|
||||||
<label htmlFor="webdav-token-expires">Expires at</label>
|
<label htmlFor="api-token-expires">Expires at</label>
|
||||||
<input
|
<input
|
||||||
id="webdav-token-expires"
|
id="api-token-expires"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={newTokenExpires}
|
value={newTokenExpires}
|
||||||
onChange={(event) => setNewTokenExpires(event.target.value)}
|
onChange={(event) => setNewTokenExpires(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<fieldset className="settings-form__field">
|
||||||
|
<legend>Capabilities</legend>
|
||||||
|
<div className="settings-form__choices">
|
||||||
|
{capabilityOptions.map((option) => {
|
||||||
|
const checked = newTokenCapabilities.includes(option.value);
|
||||||
|
return (
|
||||||
|
<label key={option.value} className="settings-choice">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleNewCapabilityChange(option.value, event.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
<div className="settings-form__actions">
|
<div className="settings-form__actions">
|
||||||
<button type="submit" disabled={creating}>
|
<button type="submit" disabled={creating}>
|
||||||
{creating ? 'Creating…' : 'Create token'}
|
{creating ? 'Creating…' : 'Create token'}
|
||||||
@@ -225,7 +312,7 @@ const SettingsModal = ({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!loading && !hasTokens ? (
|
{!loading && !hasTokens ? (
|
||||||
<p className="settings-empty">No WebDAV tokens yet.</p>
|
<p className="settings-empty">No API tokens yet.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasTokens ? (
|
{hasTokens ? (
|
||||||
@@ -236,18 +323,54 @@ const SettingsModal = ({
|
|||||||
<th scope="col">Created</th>
|
<th scope="col">Created</th>
|
||||||
<th scope="col">Last used</th>
|
<th scope="col">Last used</th>
|
||||||
<th scope="col">Expires</th>
|
<th scope="col">Expires</th>
|
||||||
|
<th scope="col">Capabilities</th>
|
||||||
<th scope="col">Actions</th>
|
<th scope="col">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tokens.map((token) => {
|
{tokens.map((token) => {
|
||||||
const isRevoked = Boolean(token?.revoked_at);
|
const isRevoked = Boolean(token?.revoked_at);
|
||||||
|
const capabilitySet = Array.isArray(token?.capabilities)
|
||||||
|
? token.capabilities
|
||||||
|
: [];
|
||||||
return (
|
return (
|
||||||
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
|
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
|
||||||
<td>{token.label || '—'}</td>
|
<td>{token.label || '—'}</td>
|
||||||
<td>{formatDateTime(token.created_at)}</td>
|
<td>{formatDateTime(token.created_at)}</td>
|
||||||
<td>{formatDateTime(token.last_used_at)}</td>
|
<td>{formatDateTime(token.last_used_at)}</td>
|
||||||
<td>{formatDateTime(token.expires_at)}</td>
|
<td>{formatDateTime(token.expires_at)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="settings-form__choices">
|
||||||
|
{capabilityOptions.map((option) => {
|
||||||
|
const checked = capabilitySet.includes(option.value);
|
||||||
|
return (
|
||||||
|
<label key={option.value} className="settings-choice">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={
|
||||||
|
isRevoked
|
||||||
|
|| deletingId === token.id
|
||||||
|
|| regeneratingId === token.id
|
||||||
|
|| updatingId === token.id
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleToggleTokenCapability(
|
||||||
|
token,
|
||||||
|
option.value,
|
||||||
|
event.target.checked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{updatingId === token.id ? (
|
||||||
|
<span className="settings-status">Saving…</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="settings-table__actions">
|
<td className="settings-table__actions">
|
||||||
{isRevoked ? (
|
{isRevoked ? (
|
||||||
<span className="settings-status">Revoked</span>
|
<span className="settings-status">Revoked</span>
|
||||||
@@ -291,9 +414,12 @@ const SettingsModal = ({
|
|||||||
creating,
|
creating,
|
||||||
deletingId,
|
deletingId,
|
||||||
regeneratingId,
|
regeneratingId,
|
||||||
|
updatingId,
|
||||||
newTokenLabel,
|
newTokenLabel,
|
||||||
newTokenExpires,
|
newTokenExpires,
|
||||||
|
newTokenCapabilities,
|
||||||
formError,
|
formError,
|
||||||
|
capabilityOptions,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
handleCopyToken,
|
handleCopyToken,
|
||||||
handleCreateToken,
|
handleCreateToken,
|
||||||
@@ -301,6 +427,8 @@ const SettingsModal = ({
|
|||||||
handleRefresh,
|
handleRefresh,
|
||||||
onDelete,
|
onDelete,
|
||||||
handleDismissSecret,
|
handleDismissSecret,
|
||||||
|
handleNewCapabilityChange,
|
||||||
|
handleToggleTokenCapability,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const renderPasskeysSection = useMemo(() => {
|
const renderPasskeysSection = useMemo(() => {
|
||||||
@@ -465,8 +593,8 @@ const SettingsModal = ({
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="settings-modal__content">
|
<div className="settings-modal__content">
|
||||||
{activeSection === 'passkeys' ? renderPasskeysSection : null}
|
{activeSection === 'passkeys' ? renderPasskeysSection : null}
|
||||||
{activeSection === 'webdav' ? renderWebdavSection : null}
|
{activeSection === 'apiTokens' ? renderApiTokensSection : null}
|
||||||
{activeSection !== 'passkeys' && activeSection !== 'webdav' ? (
|
{activeSection !== 'passkeys' && activeSection !== 'apiTokens' ? (
|
||||||
<p>Select a settings section.</p>
|
<p>Select a settings section.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
const useApiTokens = ({ api, notifyApiError, setStatusMessage, token }) => {
|
||||||
|
const [tokens, setTokens] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState(null);
|
||||||
|
const [regeneratingId, setRegeneratingId] = useState(null);
|
||||||
|
const [updatingId, setUpdatingId] = useState(null);
|
||||||
|
const [createdSecret, setCreatedSecret] = useState(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/profile/api-tokens');
|
||||||
|
setTokens(Array.isArray(data) ? data : []);
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError?.(error, 'Failed to load API tokens.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [api, notifyApiError, token]);
|
||||||
|
|
||||||
|
const create = useCallback(
|
||||||
|
async ({ label, expires_at, capabilities } = {}) => {
|
||||||
|
if (creating) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const payload = {};
|
||||||
|
if (label) {
|
||||||
|
payload.label = label;
|
||||||
|
}
|
||||||
|
if (expires_at) {
|
||||||
|
payload.expires_at = expires_at;
|
||||||
|
}
|
||||||
|
if (Array.isArray(capabilities) && capabilities.length > 0) {
|
||||||
|
payload.capabilities = capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.post('/profile/api-tokens', payload);
|
||||||
|
if (data?.token_info) {
|
||||||
|
setTokens((previous) => {
|
||||||
|
const filtered = previous.filter((entry) => entry.id !== data.token_info.id);
|
||||||
|
return [data.token_info, ...filtered];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.token) {
|
||||||
|
setCreatedSecret(data.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage?.('API token created.', 'success');
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError?.(error, 'Failed to create API token.');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, creating, notifyApiError, refresh, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const revoke = useCallback(
|
||||||
|
async (tokenId) => {
|
||||||
|
if (!tokenId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setDeletingId(tokenId);
|
||||||
|
try {
|
||||||
|
await api.delete(`/profile/api-tokens/${tokenId}`);
|
||||||
|
await refresh();
|
||||||
|
setStatusMessage?.('API token revoked.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError?.(error, 'Failed to revoke API token.');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, notifyApiError, refresh, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const regenerate = useCallback(
|
||||||
|
async (tokenId) => {
|
||||||
|
if (!tokenId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setRegeneratingId(tokenId);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(`/profile/api-tokens/${tokenId}/regenerate`);
|
||||||
|
if (data?.token_info) {
|
||||||
|
setTokens((previous) => {
|
||||||
|
let found = false;
|
||||||
|
const next = previous.map((entry) => {
|
||||||
|
if (entry.id === data.token_info.id) {
|
||||||
|
found = true;
|
||||||
|
return data.token_info;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
return [data.token_info, ...previous];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.token) {
|
||||||
|
setCreatedSecret(data.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage?.('API token regenerated.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError?.(error, 'Failed to regenerate API token.');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setRegeneratingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, notifyApiError, refresh, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCapabilities = useCallback(
|
||||||
|
async (tokenId, capabilities) => {
|
||||||
|
if (!tokenId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setUpdatingId(tokenId);
|
||||||
|
try {
|
||||||
|
const { data } = await api.patch(`/profile/api-tokens/${tokenId}`, {
|
||||||
|
capabilities,
|
||||||
|
});
|
||||||
|
if (data) {
|
||||||
|
setTokens((previous) => {
|
||||||
|
let found = false;
|
||||||
|
const next = previous.map((entry) => {
|
||||||
|
if (entry.id === data.id) {
|
||||||
|
found = true;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
return [data, ...previous];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
setStatusMessage?.('API token updated.', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError?.(error, 'Failed to update API token.');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setUpdatingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, notifyApiError, refresh, setStatusMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismissSecret = useCallback(() => {
|
||||||
|
setCreatedSecret(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokens,
|
||||||
|
loading,
|
||||||
|
creating,
|
||||||
|
deletingId,
|
||||||
|
regeneratingId,
|
||||||
|
updatingId,
|
||||||
|
createdSecret,
|
||||||
|
refresh,
|
||||||
|
create,
|
||||||
|
revoke,
|
||||||
|
regenerate,
|
||||||
|
updateCapabilities,
|
||||||
|
dismissSecret,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useApiTokens;
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
ChevronsLeftIcon,
|
SidebarCollapseIcon,
|
||||||
LogoutIcon,
|
LogoutIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
@@ -21,6 +21,7 @@ import PanelHeader from '../ui/PanelHeader';
|
|||||||
import useFloatingMenu from '../ui/useFloatingMenu';
|
import useFloatingMenu from '../ui/useFloatingMenu';
|
||||||
|
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
import { useSidebarContext } from './SidebarContext';
|
||||||
|
|
||||||
const FolderNode = ({
|
const FolderNode = ({
|
||||||
node,
|
node,
|
||||||
@@ -64,6 +65,11 @@ const FolderNode = ({
|
|||||||
className={rowClasses.join(' ')}
|
className={rowClasses.join(' ')}
|
||||||
draggable={canDrag}
|
draggable={canDrag}
|
||||||
onClick={() => onSelect(node.id)}
|
onClick={() => onSelect(node.id)}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
if (canToggle) {
|
||||||
|
onToggle(node.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDragOver={(event) => onDragOver(event, node.id)}
|
onDragOver={(event) => onDragOver(event, node.id)}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDrop={(event) => onDrop(event, node.id)}
|
onDrop={(event) => onDrop(event, node.id)}
|
||||||
@@ -83,9 +89,11 @@ const FolderNode = ({
|
|||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="name">
|
<span className="name-wrap">
|
||||||
<FolderIcon className="folder-icon-image" size={16} />
|
<FolderIcon className="folder-icon-image" size={16} />
|
||||||
{node.name}
|
<span className="name__label" title={node.name}>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{node.id !== 'root' && (
|
{node.id !== 'root' && (
|
||||||
<div className="folder-row__actions">
|
<div className="folder-row__actions">
|
||||||
@@ -156,10 +164,6 @@ const Sidebar = ({
|
|||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
onCreateFolder,
|
onCreateFolder,
|
||||||
creatingFolder = false,
|
creatingFolder = false,
|
||||||
onNeutralHueChange,
|
|
||||||
neutralHue,
|
|
||||||
themeMode = 'system',
|
|
||||||
onThemeModeChange,
|
|
||||||
tags = [],
|
tags = [],
|
||||||
activeTagIds = [],
|
activeTagIds = [],
|
||||||
onToggleTagFilter,
|
onToggleTagFilter,
|
||||||
@@ -177,13 +181,24 @@ const Sidebar = ({
|
|||||||
isFilterActive,
|
isFilterActive,
|
||||||
onLogout,
|
onLogout,
|
||||||
status,
|
status,
|
||||||
onCollapse,
|
|
||||||
tenantName,
|
tenantName,
|
||||||
tenants = [],
|
tenants = [],
|
||||||
activeTenantId = null,
|
activeTenantId = null,
|
||||||
onSelectTenant,
|
onSelectTenant,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}) => {
|
}) => {
|
||||||
|
const {
|
||||||
|
setCollapsed,
|
||||||
|
neutralHue,
|
||||||
|
setNeutralHue,
|
||||||
|
resetNeutralHue,
|
||||||
|
themeMode,
|
||||||
|
cycleThemeMode,
|
||||||
|
themeModes,
|
||||||
|
} = useSidebarContext();
|
||||||
|
const handleCollapse = useCallback(() => {
|
||||||
|
setCollapsed(true);
|
||||||
|
}, [setCollapsed]);
|
||||||
const neutralHueInputId = useId();
|
const neutralHueInputId = useId();
|
||||||
const sortedCorrespondents = useMemo(() => {
|
const sortedCorrespondents = useMemo(() => {
|
||||||
if (!Array.isArray(correspondents)) {
|
if (!Array.isArray(correspondents)) {
|
||||||
@@ -242,15 +257,31 @@ const Sidebar = ({
|
|||||||
}, [creatingFolder, onCreateFolder]);
|
}, [creatingFolder, onCreateFolder]);
|
||||||
|
|
||||||
const handleNeutralHueReset = useCallback(() => {
|
const handleNeutralHueReset = useCallback(() => {
|
||||||
onNeutralHueChange?.('');
|
resetNeutralHue();
|
||||||
}, [onNeutralHueChange]);
|
}, [resetNeutralHue]);
|
||||||
|
|
||||||
const themeModeIndex = THEME_MODES.indexOf(themeMode);
|
const handleNeutralHueChange = useCallback(
|
||||||
|
(value) => {
|
||||||
|
if (value === '') {
|
||||||
|
resetNeutralHue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNeutralHue(parsed);
|
||||||
|
},
|
||||||
|
[resetNeutralHue, setNeutralHue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const themeModeList = themeModes && themeModes.length ? themeModes : THEME_MODES;
|
||||||
|
const themeModeIndex = themeModeList.indexOf(themeMode);
|
||||||
const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex;
|
const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex;
|
||||||
const resolvedThemeMode = THEME_MODES[safeThemeModeIndex];
|
const resolvedThemeMode = themeModeList[safeThemeModeIndex] || THEME_MODES[0];
|
||||||
const nextThemeMode = THEME_MODES[(safeThemeModeIndex + 1) % THEME_MODES.length];
|
const nextThemeMode = themeModeList[(safeThemeModeIndex + 1) % themeModeList.length];
|
||||||
const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode];
|
const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode] || THEME_MODE_LABELS.system;
|
||||||
const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode];
|
const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode] || THEME_MODE_LABELS.system;
|
||||||
const themeModeIcon = resolvedThemeMode === 'dark'
|
const themeModeIcon = resolvedThemeMode === 'dark'
|
||||||
? <MoonIcon size={16} />
|
? <MoonIcon size={16} />
|
||||||
: resolvedThemeMode === 'light'
|
: resolvedThemeMode === 'light'
|
||||||
@@ -258,14 +289,8 @@ const Sidebar = ({
|
|||||||
: <DesktopIcon size={16} />;
|
: <DesktopIcon size={16} />;
|
||||||
|
|
||||||
const handleThemeModeToggle = useCallback(() => {
|
const handleThemeModeToggle = useCallback(() => {
|
||||||
if (!onThemeModeChange) {
|
cycleThemeMode();
|
||||||
return;
|
}, [cycleThemeMode]);
|
||||||
}
|
|
||||||
const currentIndex = THEME_MODES.indexOf(themeMode);
|
|
||||||
const safeIndex = currentIndex === -1 ? 0 : currentIndex;
|
|
||||||
const nextMode = THEME_MODES[(safeIndex + 1) % THEME_MODES.length];
|
|
||||||
onThemeModeChange(nextMode);
|
|
||||||
}, [onThemeModeChange, themeMode]);
|
|
||||||
|
|
||||||
const handleSearchInputChange = useCallback(
|
const handleSearchInputChange = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
@@ -411,7 +436,7 @@ const Sidebar = ({
|
|||||||
max="360"
|
max="360"
|
||||||
step="1"
|
step="1"
|
||||||
value={neutralHue}
|
value={neutralHue}
|
||||||
onChange={(event) => onNeutralHueChange?.(event.target.value)}
|
onChange={(event) => handleNeutralHueChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="sidebar-slider__value">{neutralHue}°</span>
|
<span className="sidebar-slider__value">{neutralHue}°</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -504,18 +529,18 @@ const Sidebar = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
actions={
|
actions={
|
||||||
onCollapse
|
handleCollapse
|
||||||
? [
|
? [
|
||||||
(
|
(
|
||||||
<button
|
<button
|
||||||
key="collapse"
|
key="collapse"
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={onCollapse}
|
onClick={handleCollapse}
|
||||||
aria-label="Collapse sidebar"
|
aria-label="Collapse sidebar"
|
||||||
title="Collapse sidebar"
|
title="Collapse sidebar"
|
||||||
>
|
>
|
||||||
<ChevronsLeftIcon />
|
<SidebarCollapseIcon />
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -656,7 +681,6 @@ const Sidebar = ({
|
|||||||
{sortedCorrespondents.map((correspondent) => {
|
{sortedCorrespondents.map((correspondent) => {
|
||||||
const isActive = activeCorrespondentSet.has(correspondent.id);
|
const isActive = activeCorrespondentSet.has(correspondent.id);
|
||||||
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
||||||
const label = correspondent.name || 'Unnamed';
|
|
||||||
const handleSelect = () => {
|
const handleSelect = () => {
|
||||||
const nextId = isActive ? null : correspondent.id;
|
const nextId = isActive ? null : correspondent.id;
|
||||||
onToggleCorrespondentFilter?.(nextId);
|
onToggleCorrespondentFilter?.(nextId);
|
||||||
@@ -674,7 +698,7 @@ const Sidebar = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{correspondent.name}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed';
|
||||||
|
|
||||||
|
const SidebarContext = createContext(null);
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = 'papercrate_theme_settings';
|
||||||
|
const DEFAULT_NEUTRAL_HUE = 180;
|
||||||
|
const DEFAULT_THEME_MODE = 'system';
|
||||||
|
const THEME_MODES = ['system', 'light', 'dark'];
|
||||||
|
|
||||||
|
const loadInitialThemeSettings = () => {
|
||||||
|
const defaults = { neutralHue: DEFAULT_NEUTRAL_HUE, mode: DEFAULT_THEME_MODE };
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const current = document.documentElement.style.getPropertyValue('--neutral-hue');
|
||||||
|
const parsed = Number.parseInt(current, 10);
|
||||||
|
return {
|
||||||
|
neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed,
|
||||||
|
mode: defaults.mode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const loadFromRoot = () => {
|
||||||
|
const current = root.style.getPropertyValue('--neutral-hue');
|
||||||
|
const parsed = Number.parseInt(current, 10);
|
||||||
|
return Number.isNaN(parsed) ? defaults.neutralHue : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
let neutralHueValue = loadFromRoot();
|
||||||
|
let modeValue = defaults.mode;
|
||||||
|
|
||||||
|
const composite = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (composite) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(composite);
|
||||||
|
const storedHue = Number.parseInt(parsed?.neutralHue, 10);
|
||||||
|
if (!Number.isNaN(storedHue)) {
|
||||||
|
neutralHueValue = storedHue;
|
||||||
|
}
|
||||||
|
const storedMode = parsed?.mode;
|
||||||
|
if (THEME_MODES.includes(storedMode)) {
|
||||||
|
modeValue = storedMode;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[theme] failed to parse stored theme settings', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const legacyHue = window.localStorage.getItem('papercrate_neutral_hue');
|
||||||
|
if (legacyHue) {
|
||||||
|
const parsedLegacyHue = Number.parseInt(legacyHue, 10);
|
||||||
|
if (!Number.isNaN(parsedLegacyHue)) {
|
||||||
|
neutralHueValue = parsedLegacyHue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const legacyMode = window.localStorage.getItem('papercrate_theme_mode');
|
||||||
|
if (THEME_MODES.includes(legacyMode)) {
|
||||||
|
modeValue = legacyMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { neutralHue: neutralHueValue, mode: modeValue };
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadInitialCollapsedState = (defaultValue) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return Boolean(defaultValue);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stored = window.sessionStorage.getItem(SIDEBAR_COLLAPSE_STORAGE_KEY);
|
||||||
|
if (stored === '1' || stored === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (stored === '0' || stored === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[sidebar] failed to read collapse state', error);
|
||||||
|
}
|
||||||
|
return Boolean(defaultValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SidebarProvider = ({ initialCollapsed = false, children }) => {
|
||||||
|
const [collapsed, setCollapsedState] = useState(() => loadInitialCollapsedState(initialCollapsed));
|
||||||
|
const initialTheme = useMemo(() => loadInitialThemeSettings(), []);
|
||||||
|
const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue);
|
||||||
|
const [themeMode, setThemeModeState] = useState(initialTheme.mode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`);
|
||||||
|
}, [neutralHue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (themeMode === 'system') {
|
||||||
|
root.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
root.setAttribute('data-theme', themeMode);
|
||||||
|
}
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({ neutralHue, mode: themeMode });
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, payload);
|
||||||
|
window.localStorage.removeItem('papercrate_neutral_hue');
|
||||||
|
window.localStorage.removeItem('papercrate_theme_mode');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[theme] failed to persist theme settings', error);
|
||||||
|
}
|
||||||
|
}, [neutralHue, themeMode]);
|
||||||
|
|
||||||
|
const setNeutralHue = useCallback((value) => {
|
||||||
|
setNeutralHueState((prev) => {
|
||||||
|
if (value === '' || value === null || typeof value === 'undefined') {
|
||||||
|
return DEFAULT_NEUTRAL_HUE;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const clamped = Math.min(Math.max(Math.round(parsed), 0), 360);
|
||||||
|
return clamped;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetNeutralHue = useCallback(() => {
|
||||||
|
setNeutralHueState(DEFAULT_NEUTRAL_HUE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setThemeMode = useCallback((mode) => {
|
||||||
|
if (!THEME_MODES.includes(mode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setThemeModeState(mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cycleThemeMode = useCallback(() => {
|
||||||
|
const index = THEME_MODES.indexOf(themeMode);
|
||||||
|
const nextIndex = index === -1 ? 0 : (index + 1) % THEME_MODES.length;
|
||||||
|
setThemeModeState(THEME_MODES[nextIndex]);
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(SIDEBAR_COLLAPSE_STORAGE_KEY, collapsed ? '1' : '0');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[sidebar] failed to persist collapse state', error);
|
||||||
|
}
|
||||||
|
}, [collapsed]);
|
||||||
|
|
||||||
|
const setCollapsed = useCallback((value) => {
|
||||||
|
if (typeof value === 'function') {
|
||||||
|
setCollapsedState((prev) => Boolean(value(prev)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCollapsedState(Boolean(value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
collapsed,
|
||||||
|
setCollapsed,
|
||||||
|
neutralHue,
|
||||||
|
setNeutralHue,
|
||||||
|
resetNeutralHue,
|
||||||
|
themeMode,
|
||||||
|
setThemeMode,
|
||||||
|
cycleThemeMode,
|
||||||
|
themeModes: THEME_MODES,
|
||||||
|
defaultNeutralHue: DEFAULT_NEUTRAL_HUE,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
collapsed,
|
||||||
|
setCollapsed,
|
||||||
|
neutralHue,
|
||||||
|
setNeutralHue,
|
||||||
|
resetNeutralHue,
|
||||||
|
themeMode,
|
||||||
|
setThemeMode,
|
||||||
|
cycleThemeMode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SidebarContext.Provider value={contextValue}>{children}</SidebarContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSidebarContext = () => {
|
||||||
|
const context = useContext(SidebarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSidebarContext must be used within a SidebarProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSidebarControls = () => {
|
||||||
|
const { setCollapsed } = useSidebarContext();
|
||||||
|
const openSidebar = useCallback(() => setCollapsed(false), [setCollapsed]);
|
||||||
|
const closeSidebar = useCallback(() => setCollapsed(true), [setCollapsed]);
|
||||||
|
return { openSidebar, closeSidebar };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SidebarContext;
|
||||||
+495
-65
@@ -555,6 +555,10 @@ button.danger:hover:not([disabled]) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon--flip-y {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -671,6 +675,10 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-content__body--workspace.main-content__body--has-detail > .desk-shell {
|
||||||
|
margin-right: calc(-1 * var(--detail-panel-width));
|
||||||
|
}
|
||||||
|
|
||||||
.main-content--has-detail {
|
.main-content--has-detail {
|
||||||
padding-right: var(--detail-panel-width);
|
padding-right: var(--detail-panel-width);
|
||||||
}
|
}
|
||||||
@@ -752,13 +760,13 @@ button.danger:hover:not([disabled]) {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace {
|
.document-viewer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-drag-preview {
|
.document-drag-preview {
|
||||||
@@ -859,31 +867,38 @@ button.danger:hover:not([disabled]) {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace__details {
|
.document-viewer__details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 0.5rem;
|
||||||
overflow-y: auto;
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.document-viewer__tabs-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section {
|
.document-viewer__section {
|
||||||
background: var(--surface-subtle);
|
|
||||||
box-shadow: inset 0 0 0 1px var(--outline-subtle);
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section__title {
|
.document-viewer__section-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section__list {
|
.document-viewer__section-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -892,66 +907,182 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.75rem 1.25rem;
|
gap: 0.75rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section__item {
|
.document-viewer__section-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section__item dt {
|
.document-viewer__section-item dt {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section__item dd {
|
.document-viewer__section-item dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section__placeholder {
|
.document-viewer__section-placeholder {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section__payload {
|
.document-viewer__section-payload {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section__payload summary {
|
.document-viewer__section-payload summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent-strong, var(--accent));
|
color: var(--accent-strong, var(--accent));
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-section__payload pre {
|
.document-viewer__section-payload pre {
|
||||||
margin: 0.75rem 0 0;
|
margin: 0.75rem 0 0;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: inset 0 0 0 1px var(--outline-subtle);
|
|
||||||
border-radius: 6px;
|
|
||||||
max-height: 280px;
|
max-height: 280px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace__viewer {
|
.document-viewer__section--metadata-json {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__metadata-json {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--outline-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__tab {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition:
|
||||||
|
color 120ms ease,
|
||||||
|
border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__tab:hover,
|
||||||
|
.document-viewer__tab:focus-visible {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__tab.is-active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__tabpanes {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__tabpanel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__object--ocr {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__object--ocr-text {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__message--error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__viewport {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--surface-subtle);
|
|
||||||
box-shadow: inset 0 0 0 1px var(--outline-subtle);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace__object {
|
.document-viewer__object {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
background: #fff;
|
}
|
||||||
|
|
||||||
|
.document-viewer__object--image {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__unsupported {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__unsupported-filename {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__unsupported-message {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__unsupported-download {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-viewer__unsupported-download svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags-panel__body {
|
.tags-panel__body {
|
||||||
@@ -1079,12 +1210,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
min-width: 14rem;
|
min-width: 14rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.correspondent-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correspondent-pill {
|
.correspondent-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1094,11 +1220,10 @@ button.danger:hover:not([disabled]) {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.correspondent-pill__label {
|
.correspondent-pill__label {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1112,8 +1237,9 @@ button.danger:hover:not([disabled]) {
|
|||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
display: inline-flex;
|
||||||
line-height: 1;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -1133,7 +1259,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-workspace__message {
|
.document-viewer__message {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -1287,16 +1413,29 @@ button.danger:hover:not([disabled]) {
|
|||||||
transition: background 0.12s ease, color 0.12s ease;
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-row span.name {
|
.folder-row .name-wrap {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-row .name-wrap .name__label {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-row .folder-icon-image {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.folder-row:hover {
|
.folder-row:hover {
|
||||||
background: var(--sidebar-hover-bg);
|
background: var(--sidebar-hover-bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
@@ -1888,6 +2027,12 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-panel .documents-scroll:focus-visible {
|
||||||
|
outline: 2px solid var(--selection-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.documents-panel table {
|
.documents-panel table {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -1931,6 +2076,16 @@ button.danger:hover:not([disabled]) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-panel--view-grid .folder-card__icon {
|
||||||
|
width: var(--documents-grid-icon-size);
|
||||||
|
height: var(--documents-grid-icon-size);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.document-thumbnail-inner {
|
.document-thumbnail-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -2077,7 +2232,8 @@ button.danger:hover:not([disabled]) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.documents-panel tbody tr.document.selected {
|
.documents-panel tbody tr.document.selected,
|
||||||
|
.documents-panel tbody tr.folder.selected {
|
||||||
background: var(--selection-soft);
|
background: var(--selection-soft);
|
||||||
box-shadow: inset 2px 0 0 var(--accent-outline-strong);
|
box-shadow: inset 2px 0 0 var(--accent-outline-strong);
|
||||||
}
|
}
|
||||||
@@ -2116,7 +2272,8 @@ button.danger:hover:not([disabled]) {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-card.selected .document-thumbnail-wrapper {
|
.document-card.selected .document-thumbnail-wrapper,
|
||||||
|
.folder-card.selected .folder-card__icon {
|
||||||
background-color: var(--selection-soft);
|
background-color: var(--selection-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2145,7 +2302,8 @@ button.danger:hover:not([disabled]) {
|
|||||||
font-size: var(--documents-grid-title-size);
|
font-size: var(--documents-grid-title-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-card.selected .document-card__title {
|
.document-card.selected .document-card__title,
|
||||||
|
.folder-card.selected .folder-card__name {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: var(--on-accent);
|
color: var(--on-accent);
|
||||||
}
|
}
|
||||||
@@ -2194,6 +2352,8 @@ button.danger:hover:not([disabled]) {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: break-word;
|
white-space: break-word;
|
||||||
font-size: var(--documents-grid-title-size);
|
font-size: var(--documents-grid-title-size);
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
border-radius: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2333,10 +2493,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-chip__label {
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-chip__remove {
|
.tag-chip__remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -2345,7 +2501,6 @@ button.danger:hover:not([disabled]) {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.9em;
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
@@ -2377,10 +2532,10 @@ button.danger:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel.detail-panel {
|
.panel.detail-panel {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: var(--detail-panel-width);
|
width: min(100vw, var(--detail-panel-width));
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -2389,7 +2544,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
z-index: 20;
|
z-index: 10000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -2438,12 +2593,105 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-section__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section__title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__trigger {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.18rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__chip:hover,
|
||||||
|
.quick-add__chip:focus-visible {
|
||||||
|
border-style: solid;
|
||||||
|
background: var(--selection-soft);
|
||||||
|
color: var(--fg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__chip .icon-inline {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__chip-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__menu {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__form input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__list {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add__swatch {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.preview-pane {
|
.preview-pane {
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
@@ -2470,54 +2718,92 @@ button.danger:hover:not([disabled]) {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .meta {
|
.detail-panel .meta,
|
||||||
|
.document-summary .meta {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .detail-folder-path {
|
.detail-panel .detail-folder-path,
|
||||||
|
.document-summary .detail-folder-path {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .detail-folder-path__link {
|
.detail-folder-path--block {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .detail-folder-path__link,
|
||||||
|
.document-summary .detail-folder-path__link {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .detail-folder-path__link:hover,
|
.detail-panel .detail-folder-path__link:hover,
|
||||||
.detail-panel .detail-folder-path__link:focus-visible {
|
.detail-panel .detail-folder-path__link:focus-visible,
|
||||||
|
.document-summary .detail-folder-path__link:hover,
|
||||||
|
.document-summary .detail-folder-path__link:focus-visible {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .detail-folder-path__separator {
|
.detail-panel .detail-folder-path__separator,
|
||||||
|
.document-summary .detail-folder-path__separator {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .detail-folder-path__segment {
|
.detail-panel .detail-folder-path__segment,
|
||||||
|
.document-summary .detail-folder-path__segment {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .doc-title-row {
|
.detail-panel .doc-title-row,
|
||||||
|
.document-summary .doc-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.35rem;
|
||||||
margin: 0.25rem 0 0.5rem;
|
margin: 0.25rem 0 1.25rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .doc-title-edit {
|
.doc-title-row__primary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .doc-title-edit input {
|
.doc-title-row__title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title-row__path {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .doc-title-edit,
|
||||||
|
.document-summary .doc-title-edit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .doc-title-edit input,
|
||||||
|
.document-summary .doc-title-edit input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -2527,6 +2813,63 @@ button.danger:hover:not([disabled]) {
|
|||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta__row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta__label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta__value {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-meta__row > .icon-button {
|
||||||
|
margin: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-issued-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-issued-row__label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-issued-row__value {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-issued-edit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-issued-edit input[type='date'] {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.status-inline.error {
|
.status-inline.error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
@@ -2567,6 +2910,12 @@ button.danger:hover:not([disabled]) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-stack--empty {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 420px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-stack__item {
|
.preview-stack__item {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -2620,6 +2969,40 @@ button.danger:hover:not([disabled]) {
|
|||||||
0 6px 18px var(--accent-elevated-strong);
|
0 6px 18px var(--accent-elevated-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-pane__unsupported {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__unsupported-message {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__unsupported-filename {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__unsupported-download {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__unsupported-download svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-pane--stack {
|
.preview-pane--stack {
|
||||||
min-height: 460px;
|
min-height: 460px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -2729,11 +3112,18 @@ button.danger:hover:not([disabled]) {
|
|||||||
margin: 0.2rem 0 0;
|
margin: 0.2rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .tag-list {
|
.detail-panel .tag-list,
|
||||||
|
.document-summary .tag-list,
|
||||||
|
.correspondent-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.5rem;
|
margin: 1rem 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list__empty {
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-metadata__block {
|
.detail-metadata__block {
|
||||||
@@ -2944,6 +3334,19 @@ form.inline {
|
|||||||
min-width: 14rem;
|
min-width: 14rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fieldset.settings-form__field {
|
||||||
|
border: 1px solid var(--border-muted, var(--border));
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 0.9rem 0.85rem;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset.settings-form__field legend {
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted-strong, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-form__field input[type='text'],
|
.settings-form__field input[type='text'],
|
||||||
.settings-form__field input[type='datetime-local'] {
|
.settings-form__field input[type='datetime-local'] {
|
||||||
padding: 0.45rem 0.6rem;
|
padding: 0.45rem 0.6rem;
|
||||||
@@ -2953,6 +3356,33 @@ form.inline {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-form__choices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice input[type='checkbox'] {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice input[type='checkbox']:disabled + span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-form__actions {
|
.settings-form__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -26,8 +26,18 @@ const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null,
|
|||||||
const WIDTH_TOLERANCE = 1;
|
const WIDTH_TOLERANCE = 1;
|
||||||
const WIDTH_BUFFER_RATIO = 0.99;
|
const WIDTH_BUFFER_RATIO = 0.99;
|
||||||
|
|
||||||
const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
const BreadcrumbTrail = ({
|
||||||
|
entries = [],
|
||||||
|
className = '',
|
||||||
|
separator = '/',
|
||||||
|
truncateFromStart = true,
|
||||||
|
}) => {
|
||||||
const normalized = useMemo(() => normalizeEntries(entries), [entries]);
|
const normalized = useMemo(() => normalizeEntries(entries), [entries]);
|
||||||
|
const shouldTruncateFromStart = truncateFromStart !== false;
|
||||||
|
const measurementEntries = useMemo(
|
||||||
|
() => (shouldTruncateFromStart ? normalized : normalized.slice().reverse()),
|
||||||
|
[normalized, shouldTruncateFromStart],
|
||||||
|
);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const measurementRef = useRef(null);
|
const measurementRef = useRef(null);
|
||||||
const ellipsisButtonRef = useRef(null);
|
const ellipsisButtonRef = useRef(null);
|
||||||
@@ -50,7 +60,7 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStartIndex(0);
|
setStartIndex(0);
|
||||||
closeEllipsisMenu();
|
closeEllipsisMenu();
|
||||||
}, [normalized, closeEllipsisMenu]);
|
}, [normalized, shouldTruncateFromStart, closeEllipsisMenu]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -58,10 +68,12 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = container.parentElement || container;
|
|
||||||
|
|
||||||
const updateWidth = () => {
|
const updateWidth = () => {
|
||||||
const nextWidth = target.getBoundingClientRect().width;
|
const host = containerRef.current;
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextWidth = host.getBoundingClientRect().width;
|
||||||
if (!nextWidth) {
|
if (!nextWidth) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,12 +83,12 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
|||||||
updateWidth();
|
updateWidth();
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateWidth);
|
const observer = new ResizeObserver(updateWidth);
|
||||||
observer.observe(target);
|
observer.observe(container.parentElement || container);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!normalized.length) {
|
if (!measurementEntries.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +148,7 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
|||||||
ellipsisNode.style.display = originalEllipsisDisplay ?? 'none';
|
ellipsisNode.style.display = originalEllipsisDisplay ?? 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = availableWidth ?? container.clientWidth;
|
const available = availableWidth ?? container.getBoundingClientRect().width;
|
||||||
if (!available || !widths.length) {
|
if (!available || !widths.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -156,13 +168,24 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
|||||||
if (nextStart !== startIndex) {
|
if (nextStart !== startIndex) {
|
||||||
setStartIndex(nextStart);
|
setStartIndex(nextStart);
|
||||||
}
|
}
|
||||||
}, [normalized, separator, availableWidth, startIndex]);
|
}, [measurementEntries, separator, availableWidth, startIndex]);
|
||||||
|
|
||||||
const sliceStart = Math.min(startIndex, Math.max(0, normalized.length - 1));
|
const trimmedCount = Math.min(startIndex, Math.max(0, normalized.length - 1));
|
||||||
const slice = normalized.slice(sliceStart);
|
const visibleEntries = shouldTruncateFromStart
|
||||||
const visibleEntries = sliceStart > 0 ? [ELLIPSIS, ...slice] : slice;
|
? normalized.slice(trimmedCount)
|
||||||
const hiddenEntries = sliceStart > 0 ? normalized.slice(0, sliceStart) : [];
|
: normalized.slice(0, Math.max(normalized.length - trimmedCount, 1));
|
||||||
|
const hiddenEntries = trimmedCount === 0
|
||||||
|
? []
|
||||||
|
: shouldTruncateFromStart
|
||||||
|
? normalized.slice(0, trimmedCount)
|
||||||
|
: normalized.slice(-trimmedCount);
|
||||||
const hasHiddenEntries = hiddenEntries.length > 0;
|
const hasHiddenEntries = hiddenEntries.length > 0;
|
||||||
|
const ellipsisPlacement = shouldTruncateFromStart ? 'start' : 'end';
|
||||||
|
const displayEntries = hasHiddenEntries
|
||||||
|
? ellipsisPlacement === 'start'
|
||||||
|
? [ELLIPSIS, ...visibleEntries]
|
||||||
|
: [...visibleEntries, ELLIPSIS]
|
||||||
|
: visibleEntries;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasHiddenEntries) {
|
if (!hasHiddenEntries) {
|
||||||
@@ -191,9 +214,9 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span ref={containerRef} className={wrapperClassName}>
|
<span ref={containerRef} className={wrapperClassName}>
|
||||||
{visibleEntries.map((entry, index) => {
|
{displayEntries.map((entry, index) => {
|
||||||
const isEllipsis = entry.id === ELLIPSIS.id;
|
const isEllipsis = entry.id === ELLIPSIS.id;
|
||||||
const isLast = index === visibleEntries.length - 1;
|
const isLast = index === displayEntries.length - 1;
|
||||||
|
|
||||||
if (isEllipsis) {
|
if (isEllipsis) {
|
||||||
return (
|
return (
|
||||||
@@ -273,7 +296,7 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
|||||||
>
|
>
|
||||||
{ELLIPSIS.label}
|
{ELLIPSIS.label}
|
||||||
</span>
|
</span>
|
||||||
{normalized.map((entry, index) => (
|
{measurementEntries.map((entry, index) => (
|
||||||
<React.Fragment key={`measure-${entry.id || index}`}>
|
<React.Fragment key={`measure-${entry.id || index}`}>
|
||||||
{index > 0 ? (
|
{index > 0 ? (
|
||||||
<span
|
<span
|
||||||
@@ -342,6 +365,7 @@ BreadcrumbTrail.propTypes = {
|
|||||||
entries: PropTypes.arrayOf(breadcrumbEntryShape),
|
entries: PropTypes.arrayOf(breadcrumbEntryShape),
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
separator: PropTypes.string,
|
separator: PropTypes.string,
|
||||||
|
truncateFromStart: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BreadcrumbTrail;
|
export default BreadcrumbTrail;
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { PlusIcon } from './icons';
|
||||||
|
import useFloatingMenu from './useFloatingMenu';
|
||||||
|
|
||||||
|
const normalizeOption = (option, index) => {
|
||||||
|
if (option == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof option === 'string') {
|
||||||
|
return {
|
||||||
|
id: option,
|
||||||
|
label: option,
|
||||||
|
original: option,
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const label = option.label ?? option.name;
|
||||||
|
if (!label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: option.id ?? label,
|
||||||
|
label,
|
||||||
|
original: option,
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const QuickAddMenu = ({
|
||||||
|
onSelectOption,
|
||||||
|
onCreate,
|
||||||
|
options = [],
|
||||||
|
placeholder = 'Search or create…',
|
||||||
|
createLabel = 'Add',
|
||||||
|
emptyMessage = 'No matches',
|
||||||
|
className,
|
||||||
|
triggerAriaLabel = 'Add item',
|
||||||
|
triggerTitle = 'Add',
|
||||||
|
renderOption,
|
||||||
|
menuMinWidth = 220,
|
||||||
|
triggerClassName = 'icon-button quick-add__trigger',
|
||||||
|
triggerContent = null,
|
||||||
|
}) => {
|
||||||
|
const anchorRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
toggle,
|
||||||
|
close,
|
||||||
|
menuRef,
|
||||||
|
menuStyle,
|
||||||
|
updatePosition,
|
||||||
|
} = useFloatingMenu({
|
||||||
|
anchorRef,
|
||||||
|
minWidth: menuMinWidth,
|
||||||
|
matchAnchorWidth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
setQuery('');
|
||||||
|
setSubmitting(false);
|
||||||
|
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select?.();
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
|
const normalizedOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
options
|
||||||
|
.map((option, index) => normalizeOption(option, index))
|
||||||
|
.filter((option) => option && typeof option.label === 'string'),
|
||||||
|
[options],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredOptions = useMemo(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return normalizedOptions;
|
||||||
|
}
|
||||||
|
const search = query.trim().toLowerCase();
|
||||||
|
return normalizedOptions.filter((option) => option.label.toLowerCase().includes(search));
|
||||||
|
}, [normalizedOptions, query]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
async (option) => {
|
||||||
|
if (!option || !onSelectOption) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSelectOption(option.original ?? option.label, option);
|
||||||
|
setSubmitting(false);
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitting(false);
|
||||||
|
console.error('[quick-add] option selection failed', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[close, onSelectOption],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(
|
||||||
|
async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!onCreate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = query.trim();
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onCreate(value);
|
||||||
|
setSubmitting(false);
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitting(false);
|
||||||
|
console.error('[quick-add] creation failed', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[close, onCreate, query],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canCreate = Boolean(onCreate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className ? `quick-add ${className}` : 'quick-add'}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={anchorRef}
|
||||||
|
className={triggerClassName}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={triggerAriaLabel}
|
||||||
|
title={triggerTitle}
|
||||||
|
>
|
||||||
|
{triggerContent ?? <PlusIcon />}
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<div
|
||||||
|
className="menu menu--floating quick-add__menu"
|
||||||
|
ref={menuRef}
|
||||||
|
style={menuStyle || undefined}
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
{canCreate ? (
|
||||||
|
<form className="quick-add__form" onSubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={submitting}
|
||||||
|
aria-label={placeholder}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={submitting || !query.trim()}>
|
||||||
|
{createLabel}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
<div className="menu__list quick-add__list" role="presentation">
|
||||||
|
{filteredOptions.length ? (
|
||||||
|
filteredOptions.map((option) => {
|
||||||
|
const key = option.id ?? option.index;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className="menu__item"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{renderOption ? (
|
||||||
|
renderOption(option.original ?? option.label, option)
|
||||||
|
) : (
|
||||||
|
<span className="menu__label">{option.label}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="menu__empty">{emptyMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuickAddMenu;
|
||||||
+40
-11
@@ -1,4 +1,3 @@
|
|||||||
import { useId } from 'react';
|
|
||||||
import {
|
import {
|
||||||
IconChevronRight as TablerChevronRight,
|
IconChevronRight as TablerChevronRight,
|
||||||
IconDownload as TablerDownload,
|
IconDownload as TablerDownload,
|
||||||
@@ -11,8 +10,6 @@ import {
|
|||||||
IconArrowLeft,
|
IconArrowLeft,
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconArrowUp,
|
IconArrowUp,
|
||||||
IconChevronsRight,
|
|
||||||
IconChevronsLeft,
|
|
||||||
IconAnalyze,
|
IconAnalyze,
|
||||||
IconWindowMaximize,
|
IconWindowMaximize,
|
||||||
IconTextScan2,
|
IconTextScan2,
|
||||||
@@ -22,13 +19,17 @@ import {
|
|||||||
IconMinusVertical,
|
IconMinusVertical,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconX,
|
IconX as TablerIconX,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconSun,
|
IconSun,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconDeviceLaptop,
|
IconDeviceLaptop,
|
||||||
|
IconLayoutSidebarLeftCollapse,
|
||||||
|
IconLayoutSidebarLeftExpand,
|
||||||
|
IconLayoutSidebarRightCollapse,
|
||||||
|
IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FolderSvg from '../assets/folder.svg';
|
import FolderSvg from '../assets/folder.svg';
|
||||||
|
|
||||||
@@ -140,8 +141,8 @@ export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ChevronsLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const SidebarCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconChevronsLeft
|
<IconLayoutSidebarLeftCollapse
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
size={size}
|
size={size}
|
||||||
stroke={stroke}
|
stroke={stroke}
|
||||||
@@ -149,8 +150,26 @@ export const ChevronsLeftIcon = ({ className, size = '1em', stroke = 1.6, ...res
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ChevronsRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const SidebarExpandIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconChevronsRight
|
<IconLayoutSidebarLeftExpand
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InfoIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconInfoCircle
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DetailPanelCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconLayoutSidebarRightCollapse
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
size={size}
|
size={size}
|
||||||
stroke={stroke}
|
stroke={stroke}
|
||||||
@@ -239,9 +258,18 @@ export const IconFileStack = ({ className, size = 24, stroke = 160, ...rest }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const IconX = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<TablerIconX
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconX
|
<IconX
|
||||||
className={composeClassName('icon', className)}
|
className={className}
|
||||||
size={size}
|
size={size}
|
||||||
stroke={stroke}
|
stroke={stroke}
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -348,8 +376,9 @@ export default {
|
|||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
ViewListIcon,
|
ViewListIcon,
|
||||||
ViewGridIcon,
|
ViewGridIcon,
|
||||||
ChevronsLeftIcon,
|
SidebarCollapseIcon,
|
||||||
ChevronsRightIcon,
|
SidebarExpandIcon,
|
||||||
|
DetailPanelCollapseIcon,
|
||||||
AnalyzeIcon,
|
AnalyzeIcon,
|
||||||
WindowMaximizeIcon,
|
WindowMaximizeIcon,
|
||||||
FolderPlusIcon,
|
FolderPlusIcon,
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ const useFloatingMenu = ({
|
|||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
||||||
const viewportWidth = resolveViewportWidth();
|
const viewportWidth = resolveViewportWidth();
|
||||||
|
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||||
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
||||||
|
const menu = menuRef.current;
|
||||||
|
const menuHeight = menu?.offsetHeight ?? 0;
|
||||||
|
|
||||||
let left;
|
let left;
|
||||||
if (align === 'end') {
|
if (align === 'end') {
|
||||||
@@ -75,14 +78,17 @@ const useFloatingMenu = ({
|
|||||||
left = rect.left;
|
left = rect.left;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxLeft = viewportWidth > 0
|
const maxLeft = viewportWidth > 0 ? viewportWidth - desiredWidth - safeMargin : left;
|
||||||
? viewportWidth - desiredWidth - safeMargin
|
const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left;
|
||||||
: left;
|
|
||||||
const clampedLeft = viewportWidth > 0
|
|
||||||
? clamp(left, safeMargin, Math.max(maxLeft, safeMargin))
|
|
||||||
: left;
|
|
||||||
|
|
||||||
const top = rect.bottom + offset;
|
let top = rect.bottom + offset;
|
||||||
|
if (viewportHeight > 0 && menuHeight > 0) {
|
||||||
|
const projectedBottom = top + menuHeight + safeMargin;
|
||||||
|
if (projectedBottom > viewportHeight) {
|
||||||
|
const upwardTop = rect.top - offset - menuHeight;
|
||||||
|
top = Math.max(upwardTop, safeMargin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setMenuMetrics({
|
setMenuMetrics({
|
||||||
top,
|
top,
|
||||||
|
|||||||
Reference in New Issue
Block a user