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:
|
||||
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
|
||||
id: compute_tag
|
||||
run: |
|
||||
@@ -80,7 +89,7 @@ jobs:
|
||||
push: true
|
||||
provenance: false
|
||||
tags: |
|
||||
${{ vars.REGISTRY_URL }}/${{ gitea.repository }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
|
||||
${{ vars.REGISTRY_URL }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.sha }}
|
||||
ghcr.io/${{ vars.GHCR_USERNAME }}/${{ gitea.repository }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
|
||||
ghcr.io/${{ vars.GHCR_USERNAME }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.sha }}
|
||||
${{ vars.REGISTRY_URL }}/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
|
||||
${{ vars.REGISTRY_URL }}/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ gitea.sha }}
|
||||
ghcr.io/paperless-dms/${{ steps.repo_meta.outputs.repo_name }}-${{ matrix.service }}:${{ steps.compute_tag.outputs.base_tag }}
|
||||
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
|
||||
as simple as `git pull` followed by `docker compose up -d`.
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
For development workflows (local stack, integration tests, migrations, and
|
||||
|
||||
Generated
+17
@@ -1959,6 +1959,7 @@ checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
@@ -1966,6 +1967,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "indexmap"
|
||||
version = "2.11.4"
|
||||
@@ -2695,6 +2706,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.32.0"
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
bytes = "1.5"
|
||||
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"
|
||||
mime_guess = "2.0"
|
||||
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 passkeys;
|
||||
pub mod password;
|
||||
pub mod webdav_tokens;
|
||||
|
||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||
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,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::fmt::Display;
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
@@ -13,6 +14,7 @@ pub struct AppError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
code: Option<String>,
|
||||
details: Option<Value>,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
@@ -21,6 +23,7 @@ impl AppError {
|
||||
status,
|
||||
message: message.into(),
|
||||
code: None,
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +51,11 @@ impl AppError {
|
||||
self.code = Some(code.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_details(mut self, details: Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
@@ -56,6 +64,7 @@ impl IntoResponse for AppError {
|
||||
let body = Json(ErrorResponse {
|
||||
error: self.message,
|
||||
code: self.code,
|
||||
details: self.details,
|
||||
});
|
||||
(status, body).into_response()
|
||||
}
|
||||
@@ -66,6 +75,8 @@ struct ErrorResponse {
|
||||
error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
details: Option<Value>,
|
||||
}
|
||||
|
||||
impl From<diesel::result::Error> for AppError {
|
||||
|
||||
+76
-9
@@ -4,14 +4,18 @@ use diesel::pg::{Pg, PgValue};
|
||||
use diesel::prelude::*;
|
||||
use diesel::serialize::{IsNull, Output, ToSql};
|
||||
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::str;
|
||||
use uuid::Uuid;
|
||||
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::schema::sql_types::{
|
||||
MagicTokenKind as MagicTokenKindSql, TenantStatus as TenantStatusSql,
|
||||
ApiTokenCapability as ApiTokenCapabilitySql, MagicTokenKind as MagicTokenKindSql,
|
||||
TenantStatus as TenantStatusSql,
|
||||
};
|
||||
use crate::schema::*;
|
||||
|
||||
@@ -52,6 +56,16 @@ pub enum MagicTokenKind {
|
||||
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 {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
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 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
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 {
|
||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
||||
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 {
|
||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||
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 {
|
||||
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 {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -243,10 +308,10 @@ pub struct NewWebauthnChallenge {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = webdav_tokens)]
|
||||
#[diesel(table_name = api_tokens)]
|
||||
#[diesel(belongs_to(User))]
|
||||
#[diesel(belongs_to(Tenant))]
|
||||
pub struct WebdavToken {
|
||||
pub struct ApiToken {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
@@ -257,11 +322,12 @@ pub struct WebdavToken {
|
||||
pub last_used_at: Option<NaiveDateTime>,
|
||||
pub expires_at: Option<NaiveDateTime>,
|
||||
pub revoked_at: Option<NaiveDateTime>,
|
||||
pub capabilities: Vec<ApiTokenCapability>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = webdav_tokens)]
|
||||
pub struct NewWebdavToken {
|
||||
#[diesel(table_name = api_tokens)]
|
||||
pub struct NewApiToken {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
@@ -269,6 +335,7 @@ pub struct NewWebdavToken {
|
||||
pub token_hash: String,
|
||||
pub label: Option<String>,
|
||||
pub expires_at: Option<NaiveDateTime>,
|
||||
pub capabilities: Vec<ApiTokenCapability>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||
@@ -529,9 +596,9 @@ pub struct NewDocumentCorrespondent {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||
#[diesel(table_name = refresh_tokens)]
|
||||
#[diesel(table_name = user_sessions)]
|
||||
#[diesel(belongs_to(User))]
|
||||
pub struct RefreshToken {
|
||||
pub struct UserSession {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub token_hash: String,
|
||||
@@ -544,8 +611,8 @@ pub struct RefreshToken {
|
||||
}
|
||||
|
||||
#[derive(Debug, Insertable)]
|
||||
#[diesel(table_name = refresh_tokens)]
|
||||
pub struct NewRefreshToken {
|
||||
#[diesel(table_name = user_sessions)]
|
||||
pub struct NewUserSession {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub token_hash: String,
|
||||
|
||||
@@ -68,10 +68,11 @@ pub mod schemas {
|
||||
DocumentVersionDetailResponse, DocumentVersionResponse,
|
||||
};
|
||||
pub use crate::documents::correspondents::DocumentCorrespondentResponse;
|
||||
pub use crate::models::ApiTokenCapability;
|
||||
pub use crate::routes::auth::{
|
||||
LoginRequest, LoginResponse, LoginResponseVariants, SignupFinishRequest,
|
||||
SignupStartRequest, SignupStartResponse, TenantListResponse, TenantSelectionRequest,
|
||||
TenantSelectionResponse, TenantSnippet,
|
||||
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
|
||||
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
||||
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
|
||||
};
|
||||
pub use crate::routes::correspondents::{
|
||||
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
|
||||
@@ -91,8 +92,8 @@ pub mod schemas {
|
||||
FolderInfo, FolderResponse, UpdateFolderRequest,
|
||||
};
|
||||
pub use crate::routes::profile::{
|
||||
CreateWebdavTokenRequest, RevokePasskeyQuery, WebdavTokenCreatedResponse,
|
||||
WebdavTokenResponse,
|
||||
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery,
|
||||
UpdateApiTokenCapabilitiesRequest,
|
||||
};
|
||||
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
|
||||
}
|
||||
|
||||
+124
-50
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
api_tokens::{find_active_token_by_secret, touch_api_token},
|
||||
passkeys::{
|
||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||
@@ -26,23 +27,25 @@ use crate::{
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, TenantStatus, User,
|
||||
ApiTokenCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus,
|
||||
User, UserSession,
|
||||
},
|
||||
schema::{
|
||||
magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl,
|
||||
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl,
|
||||
magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl,
|
||||
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, user_sessions,
|
||||
users::dsl,
|
||||
},
|
||||
state::AppState,
|
||||
tenants::{
|
||||
apply_refresh_token_hash, apply_tenant_guc, apply_user_guc, clear_refresh_token_hash,
|
||||
clear_user_guc,
|
||||
apply_tenant_guc, apply_user_guc, apply_user_session_hash, 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;
|
||||
|
||||
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||
const SESSION_COOKIE_NAME: &str = "refresh_token";
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
@@ -59,6 +62,11 @@ pub struct LoginRequest {
|
||||
pub preferred_tenant_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ApiTokenExchangeRequest {
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
@@ -120,6 +128,7 @@ pub enum LoginResponseVariants {
|
||||
#[openapi(
|
||||
paths(
|
||||
login,
|
||||
api_token_exchange,
|
||||
signup_start,
|
||||
signup_finish,
|
||||
refresh,
|
||||
@@ -134,6 +143,7 @@ pub enum LoginResponseVariants {
|
||||
),
|
||||
components(schemas(
|
||||
LoginRequest,
|
||||
ApiTokenExchangeRequest,
|
||||
SignupStartRequest,
|
||||
SignupStartResponse,
|
||||
SignupFinishRequest,
|
||||
@@ -150,6 +160,7 @@ pub enum LoginResponseVariants {
|
||||
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
||||
crate::auth::passkeys::PasskeyLoginStartPayload,
|
||||
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
||||
crate::models::ApiTokenCapability,
|
||||
))
|
||||
)]
|
||||
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(
|
||||
post,
|
||||
path = "/api/auth/signup/start",
|
||||
@@ -332,34 +406,34 @@ pub async fn refresh(
|
||||
) -> AppResult<Response> {
|
||||
let cookies = jar.ok_or_else(AppError::unauthorized)?;
|
||||
let refresh_value = cookies
|
||||
.get(REFRESH_COOKIE_NAME)
|
||||
.get(SESSION_COOKIE_NAME)
|
||||
.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 now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
apply_refresh_token_hash(&mut conn, &hashed)?;
|
||||
let token = match refresh_dsl::refresh_tokens
|
||||
.filter(refresh_dsl::token_hash.eq(&hashed))
|
||||
.filter(refresh_dsl::revoked_at.is_null())
|
||||
.filter(refresh_dsl::expires_at.gt(now_naive))
|
||||
.first::<RefreshToken>(&mut conn)
|
||||
apply_user_session_hash(&mut conn, &hashed)?;
|
||||
let token = match session_dsl::user_sessions
|
||||
.filter(session_dsl::token_hash.eq(&hashed))
|
||||
.filter(session_dsl::revoked_at.is_null())
|
||||
.filter(session_dsl::expires_at.gt(now_naive))
|
||||
.first::<UserSession>(&mut conn)
|
||||
{
|
||||
Ok(token) => token,
|
||||
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
|
||||
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)?;
|
||||
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((
|
||||
refresh_dsl::revoked_at.eq(now_naive),
|
||||
refresh_dsl::updated_at.eq(now_naive),
|
||||
session_dsl::revoked_at.eq(now_naive),
|
||||
session_dsl::updated_at.eq(now_naive),
|
||||
))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
@@ -445,17 +519,17 @@ pub async fn logout(
|
||||
let mut rows_affected = 0;
|
||||
|
||||
if let Some(cookies) = jar {
|
||||
if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) {
|
||||
let hashed = hash_refresh_token(value);
|
||||
if let Some(value) = cookies.get(SESSION_COOKIE_NAME) {
|
||||
let hashed = hash_session_token(value);
|
||||
rows_affected = diesel::update(
|
||||
refresh_dsl::refresh_tokens
|
||||
.filter(refresh_dsl::token_hash.eq(hashed))
|
||||
.filter(refresh_dsl::user_id.eq(user.user_id))
|
||||
.filter(refresh_dsl::revoked_at.is_null()),
|
||||
session_dsl::user_sessions
|
||||
.filter(session_dsl::token_hash.eq(hashed))
|
||||
.filter(session_dsl::user_id.eq(user.user_id))
|
||||
.filter(session_dsl::revoked_at.is_null()),
|
||||
)
|
||||
.set((
|
||||
refresh_dsl::revoked_at.eq(now),
|
||||
refresh_dsl::updated_at.eq(now),
|
||||
session_dsl::revoked_at.eq(now),
|
||||
session_dsl::updated_at.eq(now),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.unwrap_or(0);
|
||||
@@ -464,19 +538,19 @@ pub async fn logout(
|
||||
|
||||
if rows_affected == 0 {
|
||||
let _ = diesel::update(
|
||||
refresh_dsl::refresh_tokens
|
||||
.filter(refresh_dsl::user_id.eq(user.user_id))
|
||||
.filter(refresh_dsl::revoked_at.is_null()),
|
||||
session_dsl::user_sessions
|
||||
.filter(session_dsl::user_id.eq(user.user_id))
|
||||
.filter(session_dsl::revoked_at.is_null()),
|
||||
)
|
||||
.set((
|
||||
refresh_dsl::revoked_at.eq(now),
|
||||
refresh_dsl::updated_at.eq(now),
|
||||
session_dsl::revoked_at.eq(now),
|
||||
session_dsl::updated_at.eq(now),
|
||||
))
|
||||
.execute(&mut conn);
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -780,7 +854,7 @@ fn issue_session(
|
||||
) -> AppResult<Response> {
|
||||
apply_tenant_guc(conn, tenant_id)?;
|
||||
clear_user_guc(conn)?;
|
||||
clear_refresh_token_hash(conn)?;
|
||||
clear_user_session_hash(conn)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let access_token = state
|
||||
@@ -794,21 +868,21 @@ fn issue_session(
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let refresh_value = generate_refresh_token();
|
||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||
let session_value = generate_session_token();
|
||||
let session_hash = hash_session_token(&session_value);
|
||||
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(),
|
||||
user_id: user.id,
|
||||
token_hash: refresh_hash,
|
||||
token_hash: session_hash,
|
||||
issued_at: now.naive_utc(),
|
||||
expires_at: refresh_expires_at.naive_utc(),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(refresh_tokens::table)
|
||||
.values(&new_refresh)
|
||||
diesel::insert_into(user_sessions::table)
|
||||
.values(&new_session)
|
||||
.execute(conn)?;
|
||||
|
||||
let mut response = Json(LoginResponse {
|
||||
@@ -824,36 +898,36 @@ fn issue_session(
|
||||
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
build_refresh_cookie(state, &refresh_value, refresh_expires_at),
|
||||
build_session_cookie(state, &session_value, refresh_expires_at),
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn hash_refresh_token(token: &str) -> String {
|
||||
fn hash_session_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
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];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn build_refresh_cookie(
|
||||
fn build_session_cookie(
|
||||
state: &AppState,
|
||||
token: &str,
|
||||
expires_at: chrono::DateTime<Utc>,
|
||||
) -> HeaderValue {
|
||||
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("HttpOnly".into());
|
||||
parts.push("SameSite=Strict".into());
|
||||
@@ -866,11 +940,11 @@ fn build_refresh_cookie(
|
||||
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 {
|
||||
let mut parts = vec![format!("{}=", REFRESH_COOKIE_NAME)];
|
||||
fn build_clear_session_cookie(state: &AppState) -> HeaderValue {
|
||||
let mut parts = vec![format!("{}=", SESSION_COOKIE_NAME)];
|
||||
parts.push("Path=/".into());
|
||||
parts.push("HttpOnly".into());
|
||||
parts.push("SameSite=Strict".into());
|
||||
@@ -883,5 +957,5 @@ fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue {
|
||||
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::{
|
||||
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::utils::{
|
||||
@@ -303,7 +303,6 @@ struct UploadRequest {
|
||||
enum UploadOutcome {
|
||||
Created(DocumentDetailResponse),
|
||||
Reused(DocumentDetailResponse),
|
||||
Skipped { document_id: Uuid },
|
||||
}
|
||||
|
||||
#[derive(ToSchema)]
|
||||
@@ -322,7 +321,7 @@ pub struct UploadDocumentForm {
|
||||
pub correspondents: Option<Vec<CorrespondentAssignmentInput>>,
|
||||
#[schema(nullable, example = "2024-01-01T00:00:00Z")]
|
||||
pub issued_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
#[schema(nullable, default = true)]
|
||||
pub skip_existing: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -713,7 +712,7 @@ pub async fn get_document(
|
||||
responses(
|
||||
(status = 201, description = "Document created", 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"
|
||||
)]
|
||||
@@ -732,7 +731,7 @@ pub async fn upload_document(
|
||||
let mut tag_ids: Vec<Uuid> = Vec::new();
|
||||
let mut correspondents: Vec<CorrespondentAssignmentInput> = Vec::new();
|
||||
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;
|
||||
|
||||
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()
|
||||
}
|
||||
UploadOutcome::Skipped { document_id } => {
|
||||
info!(document_id = %document_id, "document upload skipped by client request");
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(response)
|
||||
@@ -1113,10 +1108,13 @@ pub async fn get_document_asset(
|
||||
|
||||
let mut object_responses = Vec::with_capacity(objects.len());
|
||||
for object in objects {
|
||||
let response_disposition = presign_disposition_for_asset(&asset, &object);
|
||||
|
||||
let url = storage
|
||||
.presign_get_object(
|
||||
&object.s3_key,
|
||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||
response_disposition.as_deref(),
|
||||
)
|
||||
.await
|
||||
.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)))
|
||||
}
|
||||
|
||||
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(
|
||||
get,
|
||||
path = "/api/documents/{id}/versions",
|
||||
@@ -1251,11 +1257,11 @@ pub async fn download_with_token(
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let has_active_refresh: bool = select(exists(
|
||||
refresh_dsl::refresh_tokens
|
||||
.filter(refresh_dsl::user_id.eq(claims.user_id))
|
||||
.filter(refresh_dsl::tenant_id.eq(claims.tenant_id))
|
||||
.filter(refresh_dsl::revoked_at.is_null())
|
||||
.filter(refresh_dsl::expires_at.gt(now)),
|
||||
session_dsl::user_sessions
|
||||
.filter(session_dsl::user_id.eq(claims.user_id))
|
||||
.filter(session_dsl::tenant_id.eq(claims.tenant_id))
|
||||
.filter(session_dsl::revoked_at.is_null())
|
||||
.filter(session_dsl::expires_at.gt(now)),
|
||||
))
|
||||
.get_result(&mut conn)?;
|
||||
|
||||
@@ -1267,10 +1273,13 @@ pub async fn download_with_token(
|
||||
|
||||
let storage = state.storage_for_tenant(claims.tenant_id)?;
|
||||
|
||||
let disposition = inline_content_disposition(&doc.filename);
|
||||
|
||||
let presigned_url = storage
|
||||
.presign_get_object(
|
||||
&version.s3_key,
|
||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||
disposition.as_deref(),
|
||||
)
|
||||
.await
|
||||
.storage_context("failed to generate download URL")?;
|
||||
@@ -2126,11 +2135,15 @@ async fn process_upload(
|
||||
info!(
|
||||
document_id = %document.id,
|
||||
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 {
|
||||
|
||||
@@ -57,6 +57,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.route("/signup/start", post(auth::signup_start))
|
||||
.route("/signup/finish", post(auth::signup_finish))
|
||||
.route("/login", post(auth::login))
|
||||
.route("/exchange-api-token", post(auth::api_token_exchange))
|
||||
.route("/refresh", post(auth::refresh))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/select-tenant", post(auth::select_tenant))
|
||||
@@ -145,14 +146,17 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
|
||||
let profile_routes = Router::new()
|
||||
.route(
|
||||
"/webdav-tokens",
|
||||
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
||||
"/api-tokens",
|
||||
get(profile::list_api_tokens).post(profile::create_api_token),
|
||||
)
|
||||
.route(
|
||||
"/webdav-tokens/:id/regenerate",
|
||||
post(profile::regenerate_webdav_token),
|
||||
"/api-tokens/:id/regenerate",
|
||||
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/:id", delete(profile::delete_passkey));
|
||||
|
||||
|
||||
+110
-50
@@ -9,24 +9,26 @@ use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{
|
||||
passkeys::PasskeySummary,
|
||||
webdav_tokens::{
|
||||
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
||||
regenerate_webdav_token as rotate_token, revoke_webdav_token as revoke_token,
|
||||
api_tokens::{
|
||||
create_api_token as issue_token, list_api_tokens as load_tokens,
|
||||
regenerate_api_token as rotate_token, revoke_api_token as revoke_token,
|
||||
update_api_token_capabilities as update_capabilities,
|
||||
},
|
||||
passkeys::PasskeySummary,
|
||||
TenantScopedConn,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::WebdavToken;
|
||||
use crate::models::{ApiToken, ApiTokenCapability};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{db::no_content, time::to_iso};
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct WebdavTokenResponse {
|
||||
pub struct ApiTokenResponse {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
pub capabilities: Vec<ApiTokenCapability>,
|
||||
pub created_at: String,
|
||||
#[schema(nullable)]
|
||||
pub last_used_at: Option<String>,
|
||||
@@ -37,17 +39,25 @@ pub struct WebdavTokenResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct WebdavTokenCreatedResponse {
|
||||
pub struct ApiTokenCreatedResponse {
|
||||
pub token: String,
|
||||
pub token_info: WebdavTokenResponse,
|
||||
pub token_info: ApiTokenResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateWebdavTokenRequest {
|
||||
pub struct CreateApiTokenRequest {
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
#[schema(nullable)]
|
||||
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)]
|
||||
@@ -80,55 +90,60 @@ pub async fn list_passkeys(
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/profile/webdav-tokens",
|
||||
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
|
||||
path = "/api/profile/api-tokens",
|
||||
responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn list_webdav_tokens(
|
||||
pub async fn list_api_tokens(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<Vec<WebdavTokenResponse>>> {
|
||||
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
|
||||
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))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/profile/webdav-tokens",
|
||||
request_body = CreateWebdavTokenRequest,
|
||||
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
|
||||
path = "/api/profile/api-tokens",
|
||||
request_body = CreateApiTokenRequest,
|
||||
responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn create_webdav_token(
|
||||
pub async fn create_api_token(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateWebdavTokenRequest>,
|
||||
) -> AppResult<(StatusCode, Json<WebdavTokenCreatedResponse>)> {
|
||||
Json(payload): Json<CreateApiTokenRequest>,
|
||||
) -> AppResult<(StatusCode, Json<ApiTokenCreatedResponse>)> {
|
||||
let expires_at = match payload.expires_at {
|
||||
Some(ref value) => Some(parse_timestamp(value)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let capabilities = payload
|
||||
.capabilities
|
||||
.unwrap_or_else(|| vec![ApiTokenCapability::Webdav]);
|
||||
|
||||
let issued = issue_token(
|
||||
&mut conn,
|
||||
user_id,
|
||||
tenant_id,
|
||||
payload.label.clone(),
|
||||
expires_at,
|
||||
capabilities,
|
||||
)?;
|
||||
|
||||
let response = WebdavTokenCreatedResponse {
|
||||
let response = ApiTokenCreatedResponse {
|
||||
token: issued.token,
|
||||
token_info: webdav_token_to_response(issued.record),
|
||||
token_info: api_token_to_response(issued.record),
|
||||
};
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
@@ -136,12 +151,12 @@ pub async fn create_webdav_token(
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/profile/webdav-tokens/{id}/regenerate",
|
||||
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
||||
responses((status = 200, description = "WebDAV token regenerated", body = WebdavTokenCreatedResponse)),
|
||||
path = "/api/profile/api-tokens/{id}/regenerate",
|
||||
params(("id" = Uuid, Path, description = "API token ID")),
|
||||
responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn regenerate_webdav_token(
|
||||
pub async fn regenerate_api_token(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
@@ -149,24 +164,53 @@ pub async fn regenerate_webdav_token(
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
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 response = WebdavTokenCreatedResponse {
|
||||
let response = ApiTokenCreatedResponse {
|
||||
token: issued.token,
|
||||
token_info: webdav_token_to_response(issued.record),
|
||||
token_info: api_token_to_response(issued.record),
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/profile/webdav-tokens/{id}",
|
||||
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
||||
responses((status = 204, description = "WebDAV token revoked")),
|
||||
patch,
|
||||
path = "/api/profile/api-tokens/{id}",
|
||||
params(("id" = Uuid, Path, description = "API token ID")),
|
||||
request_body = UpdateApiTokenCapabilitiesRequest,
|
||||
responses((status = 200, description = "API token updated", body = ApiTokenResponse)),
|
||||
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 {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
@@ -210,15 +254,28 @@ pub async fn delete_passkey(
|
||||
no_content()
|
||||
}
|
||||
|
||||
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
|
||||
WebdavTokenResponse {
|
||||
id: token.id,
|
||||
tenant_id: token.tenant_id,
|
||||
label: token.label,
|
||||
created_at: to_iso(token.created_at),
|
||||
last_used_at: token.last_used_at.map(to_iso),
|
||||
expires_at: token.expires_at.map(to_iso),
|
||||
revoked_at: token.revoked_at.map(to_iso),
|
||||
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
|
||||
let ApiToken {
|
||||
id,
|
||||
tenant_id,
|
||||
label,
|
||||
created_at,
|
||||
last_used_at,
|
||||
expires_at,
|
||||
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)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::profile::list_webdav_tokens,
|
||||
crate::routes::profile::create_webdav_token,
|
||||
crate::routes::profile::regenerate_webdav_token,
|
||||
crate::routes::profile::delete_webdav_token,
|
||||
crate::routes::profile::list_api_tokens,
|
||||
crate::routes::profile::create_api_token,
|
||||
crate::routes::profile::regenerate_api_token,
|
||||
crate::routes::profile::update_api_token,
|
||||
crate::routes::profile::delete_api_token,
|
||||
crate::routes::profile::list_passkeys,
|
||||
crate::routes::profile::delete_passkey
|
||||
),
|
||||
components(schemas(
|
||||
crate::routes::profile::WebdavTokenResponse,
|
||||
crate::routes::profile::WebdavTokenCreatedResponse,
|
||||
crate::routes::profile::CreateWebdavTokenRequest,
|
||||
crate::models::ApiTokenCapability,
|
||||
crate::routes::profile::ApiTokenResponse,
|
||||
crate::routes::profile::ApiTokenCreatedResponse,
|
||||
crate::routes::profile::CreateApiTokenRequest,
|
||||
crate::routes::profile::UpdateApiTokenCapabilitiesRequest,
|
||||
crate::routes::profile::RevokePasskeyQuery,
|
||||
crate::auth::passkeys::PasskeySummary
|
||||
))
|
||||
|
||||
@@ -16,9 +16,9 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
||||
use quick_xml::Writer;
|
||||
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::models::{Document, DocumentVersion, Folder, User};
|
||||
use crate::models::{ApiTokenCapability, Document, DocumentVersion, Folder, User};
|
||||
use crate::schema::{
|
||||
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,
|
||||
@@ -320,6 +320,7 @@ async fn stream_document(
|
||||
.presign_get_object(
|
||||
&version.s3_key,
|
||||
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.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),
|
||||
_ => 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 user: User = match users_dsl::users
|
||||
.filter(users_dsl::username.eq(username))
|
||||
.first(&mut conn)
|
||||
{
|
||||
let token = match find_active_token_by_secret(
|
||||
&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,
|
||||
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);
|
||||
}
|
||||
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)?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
@@ -468,7 +475,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%username,
|
||||
presented_username = %presented_username,
|
||||
username = %user.username,
|
||||
tenant_id = %token.tenant_id,
|
||||
"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)?;
|
||||
touch_webdav_token(&mut conn, token.id)?;
|
||||
touch_api_token(&mut conn, token.id)?;
|
||||
|
||||
tracing::debug!(
|
||||
%username,
|
||||
presented_username = %presented_username,
|
||||
username = %user.username,
|
||||
tenant_id = %tenant_id,
|
||||
token_id = %token.id,
|
||||
"webdav token login success"
|
||||
|
||||
+16
-8
@@ -8,6 +8,10 @@ pub mod sql_types {
|
||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||
#[diesel(postgres_type(name = "tenant_status"))]
|
||||
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! {
|
||||
@@ -151,7 +155,7 @@ diesel::table! {
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
refresh_tokens (id) {
|
||||
user_sessions (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
token_hash -> Text,
|
||||
@@ -246,7 +250,10 @@ diesel::table! {
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
webdav_tokens (id) {
|
||||
use diesel::sql_types::*;
|
||||
use super::sql_types::ApiTokenCapability;
|
||||
|
||||
api_tokens (id) {
|
||||
id -> Uuid,
|
||||
user_id -> Uuid,
|
||||
tenant_id -> Uuid,
|
||||
@@ -257,6 +264,7 @@ diesel::table! {
|
||||
last_used_at -> Nullable<Timestamptz>,
|
||||
expires_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!(folders -> tenants (tenant_id));
|
||||
diesel::joinable!(jobs -> tenants (tenant_id));
|
||||
diesel::joinable!(refresh_tokens -> tenants (tenant_id));
|
||||
diesel::joinable!(refresh_tokens -> users (user_id));
|
||||
diesel::joinable!(user_sessions -> tenants (tenant_id));
|
||||
diesel::joinable!(user_sessions -> users (user_id));
|
||||
diesel::joinable!(tags -> tenants (tenant_id));
|
||||
diesel::joinable!(user_memberships -> tenants (tenant_id));
|
||||
diesel::joinable!(user_memberships -> users (user_id));
|
||||
diesel::joinable!(user_passkeys -> users (user_id));
|
||||
diesel::joinable!(webauthn_challenges -> users (user_id));
|
||||
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
|
||||
diesel::joinable!(webdav_tokens -> users (user_id));
|
||||
diesel::joinable!(api_tokens -> tenants (tenant_id));
|
||||
diesel::joinable!(api_tokens -> users (user_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
correspondents,
|
||||
@@ -299,12 +307,12 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
folders,
|
||||
jobs,
|
||||
magic_tokens,
|
||||
refresh_tokens,
|
||||
user_sessions,
|
||||
tags,
|
||||
tenants,
|
||||
user_memberships,
|
||||
user_passkeys,
|
||||
users,
|
||||
webauthn_challenges,
|
||||
webdav_tokens,
|
||||
api_tokens,
|
||||
);
|
||||
|
||||
+27
-9
@@ -20,7 +20,12 @@ pub trait ObjectStorage: Send + Sync + 'static {
|
||||
content_disposition: Option<String>,
|
||||
) -> 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>>;
|
||||
|
||||
@@ -73,17 +78,23 @@ impl ObjectStorage for S3Storage {
|
||||
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()
|
||||
.expires_in(expires_in)
|
||||
.build()
|
||||
.context("failed to build S3 presigning config")?;
|
||||
|
||||
let presigned = self
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
let mut request = self.client.get_object().bucket(&self.bucket).key(key);
|
||||
if let Some(value) = response_content_disposition {
|
||||
request = request.response_content_disposition(value);
|
||||
}
|
||||
|
||||
let presigned = request
|
||||
.presigned(presign_config)
|
||||
.await
|
||||
.context("failed to generate presigned download URL")?;
|
||||
@@ -157,9 +168,16 @@ impl TenantStorage {
|
||||
.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);
|
||||
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>> {
|
||||
|
||||
+10
-10
@@ -148,8 +148,8 @@ pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> {
|
||||
"SELECT \
|
||||
set_config('papercrate.tenant_id', '', false), \
|
||||
set_config('papercrate.user_id', '', false), \
|
||||
set_config('papercrate.refresh_token_hash', '', false), \
|
||||
set_config('papercrate.webdav_token_prefix', '', false)",
|
||||
set_config('papercrate.user_session_hash', '', false), \
|
||||
set_config('papercrate.api_token_prefix', '', false)",
|
||||
)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
@@ -163,31 +163,31 @@ pub fn clear_user_guc(conn: &mut PgConnection) -> AppResult<()> {
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub fn apply_refresh_token_hash(conn: &mut PgConnection, hash: &str) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.refresh_token_hash', $1, false)")
|
||||
pub fn apply_user_session_hash(conn: &mut PgConnection, hash: &str) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.user_session_hash', $1, false)")
|
||||
.bind::<Text, _>(hash)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub fn clear_refresh_token_hash(conn: &mut PgConnection) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.refresh_token_hash', '', false)")
|
||||
pub fn clear_user_session_hash(conn: &mut PgConnection) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.user_session_hash', '', false)")
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub fn apply_webdav_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', $1, false)")
|
||||
pub fn apply_api_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', $1, false)")
|
||||
.bind::<Text, _>(prefix)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
pub fn clear_webdav_token_prefix(conn: &mut PgConnection) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', '', false)")
|
||||
pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)")
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
|
||||
@@ -274,7 +274,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
.put_object(
|
||||
&s3_key,
|
||||
image.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
Some("image/webp".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -315,7 +315,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
.put_object(
|
||||
&s3_key,
|
||||
image.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
Some("image/webp".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -609,7 +609,7 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, St
|
||||
let (width, height) = image.dimensions();
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.write_to(&mut cursor, ImageFormat::WebP)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(GeneratedImage {
|
||||
image_bytes: cursor.into_inner(),
|
||||
@@ -660,7 +660,7 @@ fn persist_assets_metadata(
|
||||
id: asset.asset_id,
|
||||
document_version_id: context.version.id,
|
||||
asset_type: asset.asset_type.to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
mime_type: "image/webp".to_string(),
|
||||
metadata: json!({
|
||||
"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,
|
||||
RegistrationChallengeResponse,
|
||||
};
|
||||
use papercrate::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
|
||||
use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey};
|
||||
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::RngCore;
|
||||
use serde::Deserialize;
|
||||
@@ -598,21 +598,21 @@ async fn login_with_session(
|
||||
.generate_token(user.id, tenant.id, &user.username)
|
||||
.map_err(|err| anyhow!(err))?;
|
||||
|
||||
let refresh_value = generate_refresh_token();
|
||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||
let session_value = generate_session_token();
|
||||
let session_hash = hash_session_token(&session_value);
|
||||
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(),
|
||||
user_id: user.id,
|
||||
token_hash: refresh_hash,
|
||||
token_hash: session_hash,
|
||||
issued_at: now.naive_utc(),
|
||||
expires_at: refresh_expires_at.naive_utc(),
|
||||
tenant_id: tenant.id,
|
||||
};
|
||||
|
||||
diesel::insert_into(refresh_tokens::table)
|
||||
.values(&new_refresh)
|
||||
diesel::insert_into(user_sessions::table)
|
||||
.values(&new_session)
|
||||
.execute(conn)?;
|
||||
|
||||
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))
|
||||
})
|
||||
.await
|
||||
@@ -643,13 +643,13 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
|
||||
Ok(cookie)
|
||||
}
|
||||
|
||||
fn generate_refresh_token() -> String {
|
||||
fn generate_session_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn hash_refresh_token(value: &str) -> String {
|
||||
fn hash_session_token(value: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
|
||||
+13
-13
@@ -20,11 +20,11 @@ use papercrate::auth::jwt::JwtService;
|
||||
use papercrate::config::AppConfig;
|
||||
use papercrate::db::{self, PgPool};
|
||||
use papercrate::models::{
|
||||
Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User,
|
||||
Job, NewUser, NewUserMembership, NewUserPasskey, NewUserSession, Tenant, TenantStatus, User,
|
||||
UserMembership,
|
||||
};
|
||||
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::storage::ObjectStorage;
|
||||
use rand::rngs::OsRng;
|
||||
@@ -359,25 +359,25 @@ impl TestApp {
|
||||
.generate_token(user.id, tenant.id, &user.username)
|
||||
.map_err(|err| anyhow!(err))?;
|
||||
|
||||
let refresh_value = generate_refresh_token();
|
||||
let refresh_hash = hash_refresh_token(&refresh_value);
|
||||
let session_value = generate_session_token();
|
||||
let session_hash = hash_session_token(&session_value);
|
||||
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(),
|
||||
user_id: user.id,
|
||||
token_hash: refresh_hash,
|
||||
token_hash: session_hash,
|
||||
issued_at: now.naive_utc(),
|
||||
expires_at: refresh_expires_at.naive_utc(),
|
||||
tenant_id: tenant.id,
|
||||
};
|
||||
|
||||
diesel::insert_into(refresh_dsl::refresh_tokens)
|
||||
.values(&new_refresh)
|
||||
diesel::insert_into(session_dsl::user_sessions)
|
||||
.values(&new_session)
|
||||
.execute(conn)?;
|
||||
|
||||
let cookie = format!("refresh_token={refresh_value}");
|
||||
let cookie = format!("refresh_token={session_value}");
|
||||
Ok((access_token, cookie, tenant.id))
|
||||
})
|
||||
.await
|
||||
@@ -787,9 +787,9 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
||||
tenant.documents, \
|
||||
tenant.folders, \
|
||||
shared.jobs, \
|
||||
tenant.refresh_tokens, \
|
||||
tenant.user_sessions, \
|
||||
tenant.tags, \
|
||||
tenant.webdav_tokens, \
|
||||
tenant.api_tokens, \
|
||||
shared.webauthn_challenges, \
|
||||
shared.user_passkeys, \
|
||||
tenant.user_memberships, \
|
||||
@@ -802,13 +802,13 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_refresh_token() -> String {
|
||||
fn generate_session_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn hash_refresh_token(value: &str) -> String {
|
||||
fn hash_session_token(value: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
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
|
||||
relationships for categorisation.
|
||||
- `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.
|
||||
|
||||
## 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 {
|
||||
opacity: 0.6;
|
||||
outline: 0.25rem solid var(--accent-outline);
|
||||
outline-offset: 0.25rem;
|
||||
}
|
||||
|
||||
.desk-item.is-filtered-out {
|
||||
@@ -80,6 +81,22 @@
|
||||
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 {
|
||||
--tag-scale: 1;
|
||||
position: absolute;
|
||||
@@ -103,6 +120,100 @@
|
||||
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 {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -201,6 +312,9 @@ body.desk-cursor-remove * {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.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 Sidebar from '../sidebar/Sidebar';
|
||||
import { useSidebarContext } from '../sidebar/SidebarContext';
|
||||
|
||||
const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => (
|
||||
<main className={`documents-main${sidebarCollapsed ? ' documents-main--sidebar-collapsed' : ''}`}>
|
||||
{!sidebarCollapsed ? <Sidebar {...sidebarProps} /> : null}
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
const DocumentsLayout = ({ sidebarProps, children }) => {
|
||||
const { collapsed } = useSidebarContext();
|
||||
return (
|
||||
<main className={`documents-main${collapsed ? ' documents-main--sidebar-collapsed' : ''}`}>
|
||||
{!collapsed ? <Sidebar {...sidebarProps} /> : null}
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
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 { useAppShell } from '../appShellContext';
|
||||
import DocumentsLayout from './DocumentsLayout';
|
||||
import { useWorkspaceSurface } from './useWorkspaceSurface';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
|
||||
|
||||
const DocumentsRoute = () => {
|
||||
const DocumentsRouteContent = () => {
|
||||
const {
|
||||
sidebarProps,
|
||||
documentsTableProps,
|
||||
@@ -18,6 +19,7 @@ const DocumentsRoute = () => {
|
||||
openCorrespondentsModal,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
previewDocumentId,
|
||||
closeDocumentPreview,
|
||||
handleThumbnailRegeneration,
|
||||
ensurePreviewData,
|
||||
@@ -27,19 +29,17 @@ const DocumentsRoute = () => {
|
||||
notifyApiError,
|
||||
} = useAppShell();
|
||||
const navigate = useNavigate();
|
||||
const { collapsed: sidebarCollapsed, setCollapsed } = useSidebarContext();
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []);
|
||||
const expandSidebar = useCallback(() => setSidebarCollapsed(false), []);
|
||||
const expandSidebar = useCallback(() => setCollapsed(false), [setCollapsed]);
|
||||
|
||||
const sidebarPropsWithActions = useMemo(
|
||||
() => ({
|
||||
...sidebarProps,
|
||||
onManageTags: openTagsModal,
|
||||
onManageCorrespondents: openCorrespondentsModal,
|
||||
onCollapse: collapseSidebar,
|
||||
}),
|
||||
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
|
||||
[sidebarProps, openTagsModal, openCorrespondentsModal],
|
||||
);
|
||||
|
||||
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
|
||||
@@ -78,6 +78,7 @@ const DocumentsRoute = () => {
|
||||
deskWorkspaceProps,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
previewDocumentId,
|
||||
ensureAssetUrl,
|
||||
ensurePreviewData,
|
||||
getDocumentAsset,
|
||||
@@ -91,10 +92,7 @@ const DocumentsRoute = () => {
|
||||
|
||||
if (!surface) {
|
||||
return (
|
||||
<DocumentsLayout
|
||||
sidebarProps={sidebarPropsWithActions}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
>
|
||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||
<div className="main-content main-content--documents">
|
||||
<div className="main-content__body main-content__body--documents" />
|
||||
</div>
|
||||
@@ -140,10 +138,7 @@ const DocumentsRoute = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentsLayout
|
||||
sidebarProps={sidebarPropsWithActions}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
>
|
||||
<DocumentsLayout sidebarProps={sidebarPropsWithActions}>
|
||||
<div className={mainContentClass}>
|
||||
{header ? (
|
||||
<PanelHeader
|
||||
@@ -161,4 +156,10 @@ const DocumentsRoute = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentsRoute = () => (
|
||||
<SidebarProvider>
|
||||
<DocumentsRouteContent />
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
export default DocumentsRoute;
|
||||
|
||||
@@ -2,21 +2,15 @@ import React, { useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SettingsModal from '../settings/SettingsModal';
|
||||
import { useAppShell } from '../appShellContext';
|
||||
import useApiTokens from '../settings/useApiTokens';
|
||||
import { api } from './appState';
|
||||
|
||||
const SettingsRoute = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
webdavTokens,
|
||||
webdavTokensLoading,
|
||||
creatingWebdavToken,
|
||||
deletingWebdavTokenId,
|
||||
regeneratingWebdavTokenId,
|
||||
refreshWebdavTokens,
|
||||
createWebdavToken,
|
||||
deleteWebdavToken,
|
||||
regenerateWebdavToken,
|
||||
webdavTokenSecret,
|
||||
dismissCreatedWebdavToken,
|
||||
token,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
@@ -27,15 +21,31 @@ const SettingsRoute = () => {
|
||||
revokePasskey,
|
||||
} = useAppShell();
|
||||
|
||||
const {
|
||||
tokens,
|
||||
loading,
|
||||
creating,
|
||||
deletingId,
|
||||
regeneratingId,
|
||||
updatingId,
|
||||
createdSecret,
|
||||
refresh,
|
||||
create,
|
||||
revoke,
|
||||
regenerate,
|
||||
updateCapabilities,
|
||||
dismissSecret,
|
||||
} = useApiTokens({ api, token, notifyApiError, setStatusMessage });
|
||||
|
||||
useEffect(() => {
|
||||
refreshWebdavTokens();
|
||||
refresh();
|
||||
refreshPasskeys();
|
||||
}, [refreshWebdavTokens, refreshPasskeys]);
|
||||
}, [refresh, refreshPasskeys]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dismissCreatedWebdavToken();
|
||||
dismissSecret();
|
||||
navigate(-1);
|
||||
}, [dismissCreatedWebdavToken, navigate]);
|
||||
}, [dismissSecret, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
@@ -49,24 +59,26 @@ const SettingsRoute = () => {
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => () => {
|
||||
dismissCreatedWebdavToken();
|
||||
}, [dismissCreatedWebdavToken]);
|
||||
dismissSecret();
|
||||
}, [dismissSecret]);
|
||||
|
||||
return (
|
||||
<SettingsModal
|
||||
open
|
||||
onClose={handleClose}
|
||||
tokens={webdavTokens}
|
||||
loading={webdavTokensLoading}
|
||||
creating={creatingWebdavToken}
|
||||
deletingId={deletingWebdavTokenId}
|
||||
regeneratingId={regeneratingWebdavTokenId}
|
||||
onRefresh={refreshWebdavTokens}
|
||||
onCreate={createWebdavToken}
|
||||
onDelete={deleteWebdavToken}
|
||||
onRegenerate={regenerateWebdavToken}
|
||||
createdToken={webdavTokenSecret}
|
||||
onDismissCreatedToken={dismissCreatedWebdavToken}
|
||||
tokens={tokens}
|
||||
loading={loading}
|
||||
creating={creating}
|
||||
deletingId={deletingId}
|
||||
regeneratingId={regeneratingId}
|
||||
updatingId={updatingId}
|
||||
onRefresh={refresh}
|
||||
onCreate={create}
|
||||
onDelete={revoke}
|
||||
onRegenerate={regenerate}
|
||||
onUpdateCapabilities={updateCapabilities}
|
||||
createdToken={createdSecret}
|
||||
onDismissCreatedToken={dismissSecret}
|
||||
passkeys={passkeys}
|
||||
passkeysSupported={passkeysSupported}
|
||||
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 { ChevronsRightIcon } from '../ui/icons';
|
||||
import { createDocumentsSurface } from '../documents/DocumentsTable';
|
||||
import { createPreviewSurface } from '../preview/PreviewWorkspace';
|
||||
import { SidebarExpandIcon } from '../ui/icons';
|
||||
import { createDocumentsSurface } from '../documents/DocumentsPanel';
|
||||
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
|
||||
import { createDesktopSurface } from '../DesktopWorkspace';
|
||||
|
||||
export const useWorkspaceSurface = ({
|
||||
@@ -14,6 +14,7 @@ export const useWorkspaceSurface = ({
|
||||
deskWorkspaceProps,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
previewDocumentId,
|
||||
ensureAssetUrl,
|
||||
ensurePreviewData,
|
||||
getDocumentAsset,
|
||||
@@ -36,7 +37,7 @@ export const useWorkspaceSurface = ({
|
||||
aria-label="Expand sidebar"
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<ChevronsRightIcon />
|
||||
<SidebarExpandIcon />
|
||||
</button>
|
||||
);
|
||||
}, [sidebarCollapsed, onExpandSidebar]);
|
||||
@@ -62,13 +63,28 @@ export const useWorkspaceSurface = ({
|
||||
detailPanelOpen,
|
||||
]);
|
||||
|
||||
const showPreviewWorkspace = viewMode !== 'desk' && Boolean(previewWorkspaceDocument);
|
||||
const showPreviewWorkspace = Boolean(previewDocumentId);
|
||||
|
||||
const previewSurface = useMemo(() => {
|
||||
if (!showPreviewWorkspace || !previewWorkspaceDocument) {
|
||||
if (!showPreviewWorkspace) {
|
||||
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,
|
||||
previewEntry: previewWorkspaceEntry,
|
||||
ensureAssetUrl,
|
||||
@@ -79,10 +95,22 @@ export const useWorkspaceSurface = ({
|
||||
onRegenerate: handleThumbnailRegeneration,
|
||||
onClose: closeDocumentPreview,
|
||||
renderSidebarToggle,
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
resolveFolderPath,
|
||||
onFolderNavigate,
|
||||
});
|
||||
}, [
|
||||
showPreviewWorkspace,
|
||||
previewWorkspaceDocument,
|
||||
previewDocumentId,
|
||||
previewWorkspaceEntry,
|
||||
ensureAssetUrl,
|
||||
ensurePreviewData,
|
||||
@@ -92,6 +120,7 @@ export const useWorkspaceSurface = ({
|
||||
handleThumbnailRegeneration,
|
||||
closeDocumentPreview,
|
||||
renderSidebarToggle,
|
||||
detailPanelProps,
|
||||
]);
|
||||
|
||||
const workspaceSurface = useMemo(() => {
|
||||
@@ -103,8 +132,18 @@ export const useWorkspaceSurface = ({
|
||||
renderSidebarToggle,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent: parentBreadcrumb ? onNavigateParent : null,
|
||||
detailProps: detailPanelProps,
|
||||
detailOpen: detailPanelOpen,
|
||||
});
|
||||
}, [viewMode, deskWorkspaceProps, renderSidebarToggle, parentBreadcrumb, onNavigateParent]);
|
||||
}, [
|
||||
viewMode,
|
||||
deskWorkspaceProps,
|
||||
renderSidebarToggle,
|
||||
parentBreadcrumb,
|
||||
onNavigateParent,
|
||||
detailPanelProps,
|
||||
detailPanelOpen,
|
||||
]);
|
||||
|
||||
const surface = useMemo(() => {
|
||||
if (showPreviewWorkspace) {
|
||||
|
||||
@@ -2,9 +2,18 @@ import { useCallback, useRef } from 'react';
|
||||
import { useDesktopContext } from './context';
|
||||
import { preventAll } from './events';
|
||||
import { clamp, formatTransform } from './math';
|
||||
import usePointerTap from '../ui/usePointerTap';
|
||||
|
||||
const DRAG_HYSTERESIS_PX = 4;
|
||||
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 {
|
||||
@@ -20,11 +29,207 @@ const useDocumentDrag = () => {
|
||||
openOverlayForDoc,
|
||||
recalcVisibleDocIds,
|
||||
settings,
|
||||
containerRef,
|
||||
onDocumentOpen,
|
||||
onInspectDocument,
|
||||
onDocumentStackSelect,
|
||||
selectedDocumentIds,
|
||||
markLayoutDirty,
|
||||
} = 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 inertiaAnimationsRef = useRef(new Map());
|
||||
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(
|
||||
(pointerId) => {
|
||||
const state = dragStateRef.current;
|
||||
@@ -49,11 +254,11 @@ const useDocumentDrag = () => {
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event, docId) => {
|
||||
(event, docIdInput, options = {}) => {
|
||||
if (debugDrag) {
|
||||
console.log(
|
||||
'[desk] handlePointerDown fired for doc',
|
||||
docId,
|
||||
docIdInput,
|
||||
'button',
|
||||
event.button,
|
||||
'pointerType',
|
||||
@@ -63,21 +268,91 @@ const useDocumentDrag = () => {
|
||||
);
|
||||
}
|
||||
preventAll(event);
|
||||
|
||||
const docId = docIdInput != null ? docIdInput : null;
|
||||
const docKey = docId != null ? String(docId) : null;
|
||||
const doc = docKey ? documentLookup.get(docKey) : null;
|
||||
const { width: docWidth, height: docHeight } = ensureDocumentSize(doc);
|
||||
if (!docKey) {
|
||||
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 normalizedBaseScale =
|
||||
Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1;
|
||||
|
||||
const entry = layoutRef.current.get(docKey) || null;
|
||||
const defaultCenterX = canvasPadding + docWidth / 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 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)) {
|
||||
layoutRef.current.set(docId, { ...entry, centerX, centerY });
|
||||
layoutRef.current.set(docKey, { ...entry, centerX, centerY });
|
||||
}
|
||||
|
||||
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 = {
|
||||
docId,
|
||||
docKey,
|
||||
pointerId: event.pointerId,
|
||||
originCenterX: centerX,
|
||||
originCenterY: centerY,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
rotation: entry?.rotation ?? 0,
|
||||
restRotation: entry?.rotation ?? 0,
|
||||
dynamicRotation: 0,
|
||||
angularVelocity: 0,
|
||||
moved: false,
|
||||
locked: false,
|
||||
width: docWidth,
|
||||
@@ -105,42 +439,225 @@ const useDocumentDrag = () => {
|
||||
dragScale: 1,
|
||||
baseScale: normalizedBaseScale,
|
||||
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);
|
||||
},
|
||||
[
|
||||
bringToFront,
|
||||
|
||||
setDraggingId(docId);
|
||||
|
||||
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,
|
||||
cancelInertiaAnimation,
|
||||
containerRef,
|
||||
documentLookup,
|
||||
ensureDocumentSize,
|
||||
layoutRef,
|
||||
resolveBaseMetrics,
|
||||
setDraggingId,
|
||||
debugDrag,
|
||||
],
|
||||
);
|
||||
ensureDocumentSize,
|
||||
layoutRef,
|
||||
resolveBaseMetrics,
|
||||
selectedDocumentIds,
|
||||
setDraggingId,
|
||||
debugDrag,
|
||||
itemRefs,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) {
|
||||
if (debugDrag) {
|
||||
console.log('[desk] handlePointerMove: no drag state for pointer', event.pointerId);
|
||||
}
|
||||
return;
|
||||
if (debugDrag) {
|
||||
console.log('[desk] handlePointerMove: no drag state for pointer', event.pointerId);
|
||||
}
|
||||
if (state.pointerId !== event.pointerId) {
|
||||
if (debugDrag) {
|
||||
console.log(
|
||||
'[desk] handlePointerMove: pointer mismatch expected',
|
||||
state.pointerId,
|
||||
'got',
|
||||
event.pointerId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
return;
|
||||
}
|
||||
if (state.pointerId !== event.pointerId) {
|
||||
if (debugDrag) {
|
||||
console.log(
|
||||
'[desk] handlePointerMove: pointer mismatch expected',
|
||||
state.pointerId,
|
||||
'got',
|
||||
event.pointerId,
|
||||
);
|
||||
}
|
||||
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 (debugDrag) {
|
||||
console.log('[desk] handlePointerMove: locked drag for doc', state.docId);
|
||||
@@ -155,21 +672,59 @@ const useDocumentDrag = () => {
|
||||
|
||||
const deltaX = event.clientX - state.startX;
|
||||
const deltaY = event.clientY - state.startY;
|
||||
const nextCenterX = state.originCenterX + deltaX;
|
||||
const nextCenterY = state.originCenterY + deltaY;
|
||||
|
||||
const docWidth = state.width;
|
||||
const docHeight = state.height;
|
||||
const halfWidth = docWidth / 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 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 clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX);
|
||||
const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY);
|
||||
const minCenterX = canvasPadding + rotatedHalfWidth;
|
||||
const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - rotatedHalfWidth);
|
||||
const minCenterY = canvasPadding + rotatedHalfHeight;
|
||||
const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - rotatedHalfHeight);
|
||||
|
||||
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) {
|
||||
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||
@@ -180,20 +735,83 @@ const useDocumentDrag = () => {
|
||||
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);
|
||||
|
||||
const node = itemRefs.current.get(state.docId);
|
||||
if (node) {
|
||||
node.style.transform = formatTransform(
|
||||
clampedCenterX - state.width / 2,
|
||||
clampedCenterY - state.height / 2,
|
||||
state.rotation,
|
||||
applyTransform(
|
||||
state.docId,
|
||||
currentCenterX,
|
||||
currentCenterY,
|
||||
state.width,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY);
|
||||
console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY);
|
||||
}
|
||||
recalcVisibleDocIds();
|
||||
},
|
||||
@@ -204,44 +822,106 @@ const useDocumentDrag = () => {
|
||||
canvasSize.width,
|
||||
defaultCanvasHeight,
|
||||
defaultCanvasWidth,
|
||||
itemRefs,
|
||||
containerRef,
|
||||
layoutRef,
|
||||
itemRefs,
|
||||
applyTransform,
|
||||
recalcVisibleDocIds,
|
||||
debugDrag,
|
||||
onDocumentStackSelect,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(event) => {
|
||||
const state = dragStateRef.current;
|
||||
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,
|
||||
};
|
||||
if (!state || state.pointerId !== event.pointerId) {
|
||||
finishDrag(event.pointerId);
|
||||
openOverlayForDoc(docId, originInfo);
|
||||
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);
|
||||
},
|
||||
[bringToFront, finishDrag, openOverlayForDoc],
|
||||
[
|
||||
bringToFront,
|
||||
documentLookup,
|
||||
finishDrag,
|
||||
finalizeGroupDrag,
|
||||
startInertiaAnimation,
|
||||
recalcVisibleDocIds,
|
||||
tapHandler,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerCancel = useCallback(
|
||||
(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],
|
||||
[finalizeGroupDrag, finishDrag, recalcVisibleDocIds, startInertiaAnimation],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
+146
-514
@@ -1,22 +1,26 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronsRightIcon,
|
||||
DetailPanelCollapseIcon,
|
||||
AnalyzeIcon,
|
||||
WindowMaximizeIcon,
|
||||
TextScanIcon,
|
||||
} from '../ui/icons';
|
||||
import PanelHeader from '../ui/PanelHeader';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||
import { createDocumentActionState } from '../documents/documentActions';
|
||||
import PreviewZoomOverlay from './PreviewZoomOverlay';
|
||||
import DocumentSummarySection, {
|
||||
TagSection,
|
||||
CorrespondentSection,
|
||||
sortCorrespondents,
|
||||
buildCorrespondentOptions,
|
||||
} from '../documents/DocumentSummarySection';
|
||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||
|
||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||
|
||||
@@ -29,156 +33,6 @@ const derivePreviewOrientation = (metadata) => {
|
||||
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) => {
|
||||
if (index === 0) return 0;
|
||||
let hash = 0;
|
||||
@@ -195,6 +49,7 @@ const PreviewStack = ({
|
||||
items = [],
|
||||
maxItems = MAX_PREVIEW_STACK_ITEMS,
|
||||
emptyMessage = 'Preview unavailable',
|
||||
emptyContent = null,
|
||||
onItemActivate,
|
||||
onOpenPreview,
|
||||
onZoomPreview,
|
||||
@@ -211,7 +66,11 @@ const PreviewStack = ({
|
||||
);
|
||||
|
||||
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 (
|
||||
@@ -294,6 +153,7 @@ const DetailPanel = ({
|
||||
onBulkCorrespondentRemove,
|
||||
onPromoteSelection,
|
||||
onUpdateTitle = async () => false,
|
||||
onUpdateIssued = async () => false,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
ensurePreviewData = () => Promise.resolve(),
|
||||
@@ -313,7 +173,7 @@ const DetailPanel = ({
|
||||
[selectedDocuments],
|
||||
);
|
||||
|
||||
const { downloadHref: singleDownloadHref, hasOcr: singleHasOcr, openOcr } = useMemo(
|
||||
const { downloadHref: singleDownloadHref } = useMemo(
|
||||
() =>
|
||||
createDocumentActionState({
|
||||
document: singleDoc,
|
||||
@@ -337,10 +197,38 @@ const DetailPanel = ({
|
||||
return `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||
}, [selectedCount, detailSummary]);
|
||||
|
||||
const [titleEditDocId, setTitleEditDocId] = useState(null);
|
||||
const [titleDraft, setTitleDraft] = useState('');
|
||||
const [titleSaving, setTitleSaving] = useState(false);
|
||||
const [titleError, setTitleError] = useState(null);
|
||||
const headerBreadcrumbs = useMemo(() => {
|
||||
if (selectedCount !== 1 || !singleDoc || typeof resolveFolderPath !== 'function') {
|
||||
return 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 bulkDocumentIds = useMemo(
|
||||
@@ -348,67 +236,10 @@ const DetailPanel = ({
|
||||
[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(() => {
|
||||
setZoomedPreview(null);
|
||||
}, [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(
|
||||
(docId) => {
|
||||
if (!docId) return;
|
||||
@@ -569,134 +400,16 @@ const DetailPanel = ({
|
||||
}, 0);
|
||||
}, [stackPreviews, selectedDocuments]);
|
||||
|
||||
const availableCorrespondents = useMemo(
|
||||
() => (Array.isArray(correspondents) ? correspondents : []),
|
||||
const correspondentOptions = useMemo(
|
||||
() => buildCorrespondentOptions(Array.isArray(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(() => {
|
||||
if (!singleDoc) return [];
|
||||
return sortCorrespondents(singleDoc.correspondents || []);
|
||||
}, [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(() => {
|
||||
if (selectedDocuments.length <= 1) {
|
||||
const doc = selectedDocuments[0];
|
||||
@@ -971,15 +684,35 @@ const DetailPanel = ({
|
||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||
}
|
||||
|
||||
const displayName = singleDoc.title || singleDoc.original_name;
|
||||
const isEditingTitle = titleEditDocId === singleDoc.id;
|
||||
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 effectiveCardinality =
|
||||
singlePreviewNavigator.cardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
|
||||
const canGoPrev = singlePreviewNavigator.canGoPrev;
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -991,148 +724,64 @@ const DetailPanel = ({
|
||||
<PreviewStack
|
||||
items={singlePreviewItems}
|
||||
maxItems={1}
|
||||
emptyMessage="Preview loading…"
|
||||
emptyMessage={emptyMessage}
|
||||
emptyContent={previewFallback}
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
onZoomPreview={handleSingleZoom}
|
||||
/>
|
||||
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
||||
<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>
|
||||
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||
<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>
|
||||
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
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="doc-title-row">
|
||||
{isEditingTitle ? (
|
||||
<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
|
||||
/>
|
||||
<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}
|
||||
<DocumentSummarySection
|
||||
document={singleDoc}
|
||||
tagLookupById={tagLookupById}
|
||||
tagOptions={tags}
|
||||
onTagAdd={(doc, value, extras) => onTagAdd(doc, value, extras)}
|
||||
onTagRemove={(docId, tagId) => onTagRemove(docId, tagId)}
|
||||
correspondents={singleCorrespondents}
|
||||
correspondentOptions={correspondentOptions}
|
||||
onCorrespondentAdd={onCorrespondentAdd}
|
||||
onCorrespondentRemove={onCorrespondentRemove}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onUpdateIssued={onUpdateIssued}
|
||||
/>
|
||||
<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 countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
||||
const headerLabel = `${countLabel}${sizeLabel ? ` (${sizeLabel})` : ''}`;
|
||||
const topDocIdLocal = topDocId;
|
||||
const topCardinalityLocal = topEffectiveCardinality;
|
||||
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
||||
@@ -1200,35 +850,26 @@ const DetailPanel = ({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
||||
<div className="meta">
|
||||
<div>
|
||||
<strong>Total size (stack):</strong> {sizeLabel}
|
||||
</div>
|
||||
</div>
|
||||
<h3 style={{ margin: 0 }}>{headerLabel}</h3>
|
||||
<TagSection
|
||||
title="Tags"
|
||||
tags={bulkTagUnion}
|
||||
emptyMessage="No tags assigned."
|
||||
onRemove={(tag) => onBulkTagRemove?.({ label: tag.label, documentIds })}
|
||||
onAdd={({ value, input }) =>
|
||||
onBulkTagAdd?.({ label: value, input, documentIds })
|
||||
onAdd={({ value }) =>
|
||||
onBulkTagAdd?.({ label: value, input: null, documentIds })
|
||||
}
|
||||
addPlaceholder="Add tag to selection"
|
||||
addButtonLabel="Add tag"
|
||||
datalistId="tag-catalog-bulk"
|
||||
datalistOptions={tags}
|
||||
className="bulk-tags"
|
||||
/>
|
||||
<CorrespondentSection
|
||||
title="Correspondents"
|
||||
entries={bulkCorrespondents}
|
||||
onRemove={handleBulkCorrespondentRemove}
|
||||
onAdd={({ name, input }) =>
|
||||
onBulkCorrespondentAdd?.({ name, input, documentIds })
|
||||
onAdd={({ name }) =>
|
||||
onBulkCorrespondentAdd?.({ name, input: null, documentIds })
|
||||
}
|
||||
addPlaceholder="Add correspondent to selection"
|
||||
datalistId="correspondent-catalog-bulk"
|
||||
datalistOptions={correspondentOptions}
|
||||
showCount
|
||||
className="bulk-correspondents"
|
||||
@@ -1238,7 +879,6 @@ const DetailPanel = ({
|
||||
};
|
||||
|
||||
const isBulkSelection = selectedCount > 1;
|
||||
const showOcrAction = Boolean(singleDoc && singleHasOcr);
|
||||
|
||||
const headerLeading = [
|
||||
(
|
||||
@@ -1250,7 +890,7 @@ const DetailPanel = ({
|
||||
aria-label="Close detail panel"
|
||||
title="Close detail panel"
|
||||
>
|
||||
<ChevronsRightIcon />
|
||||
<DetailPanelCollapseIcon />
|
||||
</button>
|
||||
),
|
||||
];
|
||||
@@ -1265,11 +905,10 @@ const DetailPanel = ({
|
||||
event.stopPropagation();
|
||||
onOpenPreview(singleDoc.id);
|
||||
}}
|
||||
aria-label="Open preview"
|
||||
title="Open preview"
|
||||
disabled={!singleHasPreview}
|
||||
aria-label="Maximize"
|
||||
title="Maximize"
|
||||
>
|
||||
<WindowMaximizeIcon />
|
||||
<WindowMaximizeIcon className="icon--flip-y" />
|
||||
</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) {
|
||||
headerActions.push(
|
||||
<button
|
||||
@@ -1352,7 +973,18 @@ const DetailPanel = ({
|
||||
<aside className="detail-panel panel">
|
||||
<PanelHeader
|
||||
leading={headerLeading}
|
||||
title={headerTitle}
|
||||
title={
|
||||
headerBreadcrumbs ? (
|
||||
<BreadcrumbTrail
|
||||
entries={headerBreadcrumbs}
|
||||
separator="/"
|
||||
className="panel-header__breadcrumbs"
|
||||
truncateFromStart
|
||||
/>
|
||||
) : (
|
||||
headerTitle
|
||||
)
|
||||
}
|
||||
titleTag="h3"
|
||||
actions={headerActions.length ? headerActions : null}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||
import usePointerTap from '../ui/usePointerTap';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
@@ -17,6 +18,8 @@ const ensureDocumentRoot = () => {
|
||||
return document.body;
|
||||
};
|
||||
|
||||
const CLICK_DELAY_MS = 240;
|
||||
|
||||
const PreviewZoomOverlay = ({
|
||||
open = false,
|
||||
display = null,
|
||||
@@ -170,13 +173,33 @@ const PreviewZoomOverlay = ({
|
||||
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();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
if (key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowLeft') {
|
||||
if (activeDisplay?.canGoPrev && activeDisplay?.goPrev) {
|
||||
event.preventDefault();
|
||||
activeDisplay.goPrev();
|
||||
@@ -184,7 +207,7 @@ const PreviewZoomOverlay = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
if (key === 'ArrowRight') {
|
||||
if (activeDisplay?.canGoNext && activeDisplay?.goNext) {
|
||||
event.preventDefault();
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -225,11 +281,13 @@ const PreviewZoomOverlay = ({
|
||||
height: naturalSize.height ? `${naturalSize.height}px` : 'auto',
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
touchAction: 'manipulation',
|
||||
}
|
||||
: {
|
||||
cursor: 'zoom-in',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
touchAction: 'manipulation',
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
@@ -263,26 +321,7 @@ const PreviewZoomOverlay = ({
|
||||
height: event.currentTarget.naturalHeight || null,
|
||||
});
|
||||
}}
|
||||
onClick={(event) => {
|
||||
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);
|
||||
}}
|
||||
onPointerDown={handleImagePointerDown}
|
||||
style={imageStyle}
|
||||
/>
|
||||
</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)) {
|
||||
return [];
|
||||
}
|
||||
return tags.filter((tag) => tag && (tag.label || tag.id));
|
||||
return tags.filter(Boolean);
|
||||
};
|
||||
|
||||
const sanitizeCorrespondents = (entries) => {
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
return entries.filter((entry) => entry && (entry.name || entry.id));
|
||||
return entries.filter(Boolean);
|
||||
};
|
||||
|
||||
export const describeDocumentSummary = (document, options = {}) => {
|
||||
@@ -47,7 +47,7 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
mimeTypeLabel: '—',
|
||||
sizeLabel: '—',
|
||||
createdAtLabel: '—',
|
||||
issuedAtLabel: '—',
|
||||
issuedLabel: '—',
|
||||
updatedAtLabel: '—',
|
||||
pageCount: null,
|
||||
pageCountLabel: '—',
|
||||
@@ -64,7 +64,6 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
formatDateTime = defaultFormatDateTime,
|
||||
} = options;
|
||||
|
||||
const title = document.title;
|
||||
const originalName = document.original_name;
|
||||
const mimeTypeLabel = document.content_type || 'Unknown';
|
||||
|
||||
@@ -76,10 +75,10 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
|
||||
|
||||
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 folderLabel = document.folder_path || document.folder_name || null;
|
||||
const folderLabel = document.folder_path;
|
||||
|
||||
const tags = sanitizeTags(document.tags);
|
||||
const correspondents = sanitizeCorrespondents(document.correspondents);
|
||||
@@ -96,21 +95,21 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
{ key: 'created', label: 'Created', value: createdAtLabel },
|
||||
{ key: 'size', label: 'Size', value: sizeLabel },
|
||||
{ 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: 'updated', label: 'Updated', value: updatedAtLabel },
|
||||
{ key: 'folder', label: 'Folder', value: folderLabel || '—' },
|
||||
{ key: 'folder', label: 'Folder', value: folderLabel },
|
||||
{ key: 'tags', label: 'Tags', value: tagsSummary },
|
||||
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary },
|
||||
];
|
||||
|
||||
return {
|
||||
title,
|
||||
title: document.title,
|
||||
originalName,
|
||||
mimeTypeLabel,
|
||||
sizeLabel,
|
||||
createdAtLabel,
|
||||
issuedAtLabel,
|
||||
issuedLabel,
|
||||
updatedAtLabel,
|
||||
pageCount,
|
||||
pageCountLabel,
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import React from 'react';
|
||||
import { ArrowUpIcon } from '../ui/icons';
|
||||
|
||||
export const createWorkspaceSurfaceConfig = ({
|
||||
title,
|
||||
subtitle = null,
|
||||
sidebarToggle = null,
|
||||
parentBreadcrumb = null,
|
||||
onNavigateParent = null,
|
||||
actions = null,
|
||||
breadcrumbs = null,
|
||||
content = null,
|
||||
@@ -14,25 +11,10 @@ export const createWorkspaceSurfaceConfig = ({
|
||||
variant = 'documents',
|
||||
key = 'documents',
|
||||
}) => {
|
||||
const showParent = parentBreadcrumb && typeof onNavigateParent === 'function';
|
||||
|
||||
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
|
||||
const leading = sidebarToggle
|
||||
? (
|
||||
<>
|
||||
{sidebarToggle}
|
||||
{parentControl}
|
||||
</>
|
||||
)
|
||||
: 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) {
|
||||
return <div className="preview-workspace__message">Loading preview…</div>;
|
||||
return <div className="document-viewer__message">Loading preview…</div>;
|
||||
}
|
||||
|
||||
return <Navigate to="/documents" replace />;
|
||||
|
||||
@@ -7,8 +7,8 @@ const SECTIONS = [
|
||||
label: 'Passkeys',
|
||||
},
|
||||
{
|
||||
id: 'webdav',
|
||||
label: 'WebDAV',
|
||||
id: 'apiTokens',
|
||||
label: 'API tokens',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -20,10 +20,12 @@ const SettingsModal = ({
|
||||
creating = false,
|
||||
deletingId = null,
|
||||
regeneratingId = null,
|
||||
updatingId = null,
|
||||
onRefresh,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
onUpdateCapabilities,
|
||||
createdToken = null,
|
||||
onDismissCreatedToken,
|
||||
passkeys = [],
|
||||
@@ -39,6 +41,7 @@ const SettingsModal = ({
|
||||
const [activeSection, setActiveSection] = useState(defaultSection);
|
||||
const [newTokenLabel, setNewTokenLabel] = useState('');
|
||||
const [newTokenExpires, setNewTokenExpires] = useState('');
|
||||
const [newTokenCapabilities, setNewTokenCapabilities] = useState([]);
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [newPasskeyNickname, setNewPasskeyNickname] = useState('');
|
||||
|
||||
@@ -55,6 +58,7 @@ const SettingsModal = ({
|
||||
setActiveSection(defaultSection);
|
||||
setNewTokenLabel('');
|
||||
setNewTokenExpires('');
|
||||
setNewTokenCapabilities([]);
|
||||
setFormError(null);
|
||||
setNewPasskeyNickname('');
|
||||
}
|
||||
@@ -116,6 +120,57 @@ const SettingsModal = ({
|
||||
[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(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -135,18 +190,25 @@ const SettingsModal = ({
|
||||
normalizedExpires = parsed.toISOString();
|
||||
}
|
||||
|
||||
if (!newTokenCapabilities.length) {
|
||||
setFormError('Select at least one capability.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await onCreate?.({
|
||||
label: normalizedLabel,
|
||||
expires_at: normalizedExpires,
|
||||
capabilities: newTokenCapabilities,
|
||||
});
|
||||
|
||||
if (result !== false) {
|
||||
setNewTokenLabel('');
|
||||
setNewTokenExpires('');
|
||||
setNewTokenCapabilities([]);
|
||||
setFormError(null);
|
||||
}
|
||||
},
|
||||
[newTokenExpires, newTokenLabel, onCreate],
|
||||
[newTokenExpires, newTokenLabel, newTokenCapabilities, onCreate],
|
||||
);
|
||||
|
||||
const handleRegenerateToken = useCallback(
|
||||
@@ -159,7 +221,7 @@ const SettingsModal = ({
|
||||
[onRegenerate],
|
||||
);
|
||||
|
||||
const renderWebdavSection = useMemo(() => {
|
||||
const renderApiTokensSection = useMemo(() => {
|
||||
const hasTokens = Array.isArray(tokens) && tokens.length > 0;
|
||||
|
||||
return (
|
||||
@@ -175,6 +237,11 @@ const SettingsModal = ({
|
||||
</button>
|
||||
</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 ? (
|
||||
<div className="settings-notice">
|
||||
<p>
|
||||
@@ -194,24 +261,44 @@ const SettingsModal = ({
|
||||
|
||||
<form className="settings-form" onSubmit={handleCreateToken}>
|
||||
<div className="settings-form__field">
|
||||
<label htmlFor="webdav-token-label">Label</label>
|
||||
<label htmlFor="api-token-label">Label</label>
|
||||
<input
|
||||
id="webdav-token-label"
|
||||
id="api-token-label"
|
||||
type="text"
|
||||
value={newTokenLabel}
|
||||
onChange={(event) => setNewTokenLabel(event.target.value)}
|
||||
placeholder="Personal WebDAV token"
|
||||
placeholder="Personal API token"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-form__field">
|
||||
<label htmlFor="webdav-token-expires">Expires at</label>
|
||||
<label htmlFor="api-token-expires">Expires at</label>
|
||||
<input
|
||||
id="webdav-token-expires"
|
||||
id="api-token-expires"
|
||||
type="datetime-local"
|
||||
value={newTokenExpires}
|
||||
onChange={(event) => setNewTokenExpires(event.target.value)}
|
||||
/>
|
||||
</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">
|
||||
<button type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create token'}
|
||||
@@ -225,7 +312,7 @@ const SettingsModal = ({
|
||||
) : null}
|
||||
|
||||
{!loading && !hasTokens ? (
|
||||
<p className="settings-empty">No WebDAV tokens yet.</p>
|
||||
<p className="settings-empty">No API tokens yet.</p>
|
||||
) : null}
|
||||
|
||||
{hasTokens ? (
|
||||
@@ -236,18 +323,54 @@ const SettingsModal = ({
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Last used</th>
|
||||
<th scope="col">Expires</th>
|
||||
<th scope="col">Capabilities</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => {
|
||||
const isRevoked = Boolean(token?.revoked_at);
|
||||
const capabilitySet = Array.isArray(token?.capabilities)
|
||||
? token.capabilities
|
||||
: [];
|
||||
return (
|
||||
<tr key={token.id} className={isRevoked ? 'is-revoked' : undefined}>
|
||||
<td>{token.label || '—'}</td>
|
||||
<td>{formatDateTime(token.created_at)}</td>
|
||||
<td>{formatDateTime(token.last_used_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">
|
||||
{isRevoked ? (
|
||||
<span className="settings-status">Revoked</span>
|
||||
@@ -291,9 +414,12 @@ const SettingsModal = ({
|
||||
creating,
|
||||
deletingId,
|
||||
regeneratingId,
|
||||
updatingId,
|
||||
newTokenLabel,
|
||||
newTokenExpires,
|
||||
newTokenCapabilities,
|
||||
formError,
|
||||
capabilityOptions,
|
||||
formatDateTime,
|
||||
handleCopyToken,
|
||||
handleCreateToken,
|
||||
@@ -301,6 +427,8 @@ const SettingsModal = ({
|
||||
handleRefresh,
|
||||
onDelete,
|
||||
handleDismissSecret,
|
||||
handleNewCapabilityChange,
|
||||
handleToggleTokenCapability,
|
||||
]);
|
||||
|
||||
const renderPasskeysSection = useMemo(() => {
|
||||
@@ -465,8 +593,8 @@ const SettingsModal = ({
|
||||
</nav>
|
||||
<div className="settings-modal__content">
|
||||
{activeSection === 'passkeys' ? renderPasskeysSection : null}
|
||||
{activeSection === 'webdav' ? renderWebdavSection : null}
|
||||
{activeSection !== 'passkeys' && activeSection !== 'webdav' ? (
|
||||
{activeSection === 'apiTokens' ? renderApiTokensSection : null}
|
||||
{activeSection !== 'passkeys' && activeSection !== 'apiTokens' ? (
|
||||
<p>Select a settings section.</p>
|
||||
) : null}
|
||||
</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,
|
||||
EditIcon,
|
||||
FolderIcon,
|
||||
ChevronsLeftIcon,
|
||||
SidebarCollapseIcon,
|
||||
LogoutIcon,
|
||||
ChevronDownIcon,
|
||||
SettingsIcon,
|
||||
@@ -21,6 +21,7 @@ import PanelHeader from '../ui/PanelHeader';
|
||||
import useFloatingMenu from '../ui/useFloatingMenu';
|
||||
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { useSidebarContext } from './SidebarContext';
|
||||
|
||||
const FolderNode = ({
|
||||
node,
|
||||
@@ -64,6 +65,11 @@ const FolderNode = ({
|
||||
className={rowClasses.join(' ')}
|
||||
draggable={canDrag}
|
||||
onClick={() => onSelect(node.id)}
|
||||
onDoubleClick={() => {
|
||||
if (canToggle) {
|
||||
onToggle(node.id);
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => onDragOver(event, node.id)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(event) => onDrop(event, node.id)}
|
||||
@@ -83,9 +89,11 @@ const FolderNode = ({
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="name">
|
||||
<span className="name-wrap">
|
||||
<FolderIcon className="folder-icon-image" size={16} />
|
||||
{node.name}
|
||||
<span className="name__label" title={node.name}>
|
||||
{node.name}
|
||||
</span>
|
||||
</span>
|
||||
{node.id !== 'root' && (
|
||||
<div className="folder-row__actions">
|
||||
@@ -156,10 +164,6 @@ const Sidebar = ({
|
||||
draggedFolderId,
|
||||
onCreateFolder,
|
||||
creatingFolder = false,
|
||||
onNeutralHueChange,
|
||||
neutralHue,
|
||||
themeMode = 'system',
|
||||
onThemeModeChange,
|
||||
tags = [],
|
||||
activeTagIds = [],
|
||||
onToggleTagFilter,
|
||||
@@ -177,13 +181,24 @@ const Sidebar = ({
|
||||
isFilterActive,
|
||||
onLogout,
|
||||
status,
|
||||
onCollapse,
|
||||
tenantName,
|
||||
tenants = [],
|
||||
activeTenantId = null,
|
||||
onSelectTenant,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const {
|
||||
setCollapsed,
|
||||
neutralHue,
|
||||
setNeutralHue,
|
||||
resetNeutralHue,
|
||||
themeMode,
|
||||
cycleThemeMode,
|
||||
themeModes,
|
||||
} = useSidebarContext();
|
||||
const handleCollapse = useCallback(() => {
|
||||
setCollapsed(true);
|
||||
}, [setCollapsed]);
|
||||
const neutralHueInputId = useId();
|
||||
const sortedCorrespondents = useMemo(() => {
|
||||
if (!Array.isArray(correspondents)) {
|
||||
@@ -242,15 +257,31 @@ const Sidebar = ({
|
||||
}, [creatingFolder, onCreateFolder]);
|
||||
|
||||
const handleNeutralHueReset = useCallback(() => {
|
||||
onNeutralHueChange?.('');
|
||||
}, [onNeutralHueChange]);
|
||||
resetNeutralHue();
|
||||
}, [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 resolvedThemeMode = THEME_MODES[safeThemeModeIndex];
|
||||
const nextThemeMode = THEME_MODES[(safeThemeModeIndex + 1) % THEME_MODES.length];
|
||||
const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode];
|
||||
const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode];
|
||||
const resolvedThemeMode = themeModeList[safeThemeModeIndex] || THEME_MODES[0];
|
||||
const nextThemeMode = themeModeList[(safeThemeModeIndex + 1) % themeModeList.length];
|
||||
const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode] || THEME_MODE_LABELS.system;
|
||||
const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode] || THEME_MODE_LABELS.system;
|
||||
const themeModeIcon = resolvedThemeMode === 'dark'
|
||||
? <MoonIcon size={16} />
|
||||
: resolvedThemeMode === 'light'
|
||||
@@ -258,14 +289,8 @@ const Sidebar = ({
|
||||
: <DesktopIcon size={16} />;
|
||||
|
||||
const handleThemeModeToggle = useCallback(() => {
|
||||
if (!onThemeModeChange) {
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
cycleThemeMode();
|
||||
}, [cycleThemeMode]);
|
||||
|
||||
const handleSearchInputChange = useCallback(
|
||||
(event) => {
|
||||
@@ -411,7 +436,7 @@ const Sidebar = ({
|
||||
max="360"
|
||||
step="1"
|
||||
value={neutralHue}
|
||||
onChange={(event) => onNeutralHueChange?.(event.target.value)}
|
||||
onChange={(event) => handleNeutralHueChange(event.target.value)}
|
||||
/>
|
||||
<span className="sidebar-slider__value">{neutralHue}°</span>
|
||||
</label>
|
||||
@@ -504,18 +529,18 @@ const Sidebar = ({
|
||||
</>
|
||||
)}
|
||||
actions={
|
||||
onCollapse
|
||||
handleCollapse
|
||||
? [
|
||||
(
|
||||
<button
|
||||
key="collapse"
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onCollapse}
|
||||
onClick={handleCollapse}
|
||||
aria-label="Collapse sidebar"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronsLeftIcon />
|
||||
<SidebarCollapseIcon />
|
||||
</button>
|
||||
),
|
||||
]
|
||||
@@ -656,7 +681,6 @@ const Sidebar = ({
|
||||
{sortedCorrespondents.map((correspondent) => {
|
||||
const isActive = activeCorrespondentSet.has(correspondent.id);
|
||||
const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`;
|
||||
const label = correspondent.name || 'Unnamed';
|
||||
const handleSelect = () => {
|
||||
const nextId = isActive ? null : correspondent.id;
|
||||
onToggleCorrespondentFilter?.(nextId);
|
||||
@@ -674,7 +698,7 @@ const Sidebar = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{correspondent.name}
|
||||
</span>
|
||||
</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;
|
||||
}
|
||||
|
||||
.icon--flip-y {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -671,6 +675,10 @@ button.danger:hover:not([disabled]) {
|
||||
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 {
|
||||
padding-right: var(--detail-panel-width);
|
||||
}
|
||||
@@ -752,13 +760,13 @@ button.danger:hover:not([disabled]) {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-workspace {
|
||||
.document-viewer {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 30em) minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
min-height: 0;
|
||||
padding: 1rem 1.5rem;
|
||||
padding: 1rem 1rem;
|
||||
}
|
||||
|
||||
.document-drag-preview {
|
||||
@@ -859,31 +867,38 @@ button.danger:hover:not([disabled]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-workspace__details {
|
||||
.document-viewer__details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
overflow-y: auto;
|
||||
gap: 0.5rem;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.document-viewer__tabs-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
background: var(--surface-subtle);
|
||||
box-shadow: inset 0 0 0 1px var(--outline-subtle);
|
||||
padding: 1.25rem 1.5rem;
|
||||
.document-viewer__section {
|
||||
display: flex;
|
||||
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;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.preview-section__list {
|
||||
.document-viewer__section-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
@@ -892,66 +907,182 @@ button.danger:hover:not([disabled]) {
|
||||
gap: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.preview-section__item {
|
||||
.document-viewer__section-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.preview-section__item dt {
|
||||
.document-viewer__section-item dt {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview-section__item dd {
|
||||
.document-viewer__section-item dd {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview-section__placeholder {
|
||||
.document-viewer__section-placeholder {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview-section__payload {
|
||||
.document-viewer__section-payload {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.preview-section__payload summary {
|
||||
.document-viewer__section-payload summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: var(--accent-strong, var(--accent));
|
||||
}
|
||||
|
||||
.preview-section__payload pre {
|
||||
.document-viewer__section-payload pre {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface);
|
||||
box-shadow: inset 0 0 0 1px var(--outline-subtle);
|
||||
border-radius: 6px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
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;
|
||||
min-width: 0;
|
||||
background: var(--surface-subtle);
|
||||
box-shadow: inset 0 0 0 1px var(--outline-subtle);
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.preview-workspace__object {
|
||||
.document-viewer__object {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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 {
|
||||
@@ -1079,12 +1210,7 @@ button.danger:hover:not([disabled]) {
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.correspondent-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.correspondent-pill {
|
||||
display: inline-flex;
|
||||
@@ -1094,11 +1220,10 @@ button.danger:hover:not([disabled]) {
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.correspondent-pill__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -1112,8 +1237,9 @@ button.danger:hover:not([disabled]) {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1133,7 +1259,7 @@ button.danger:hover:not([disabled]) {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.preview-workspace__message {
|
||||
.document-viewer__message {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@@ -1287,16 +1413,29 @@ button.danger:hover:not([disabled]) {
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.folder-row span.name {
|
||||
.folder-row .name-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.folder-row .name-wrap .name__label {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-row .folder-icon-image {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-row:hover {
|
||||
background: var(--sidebar-hover-bg);
|
||||
color: var(--fg);
|
||||
@@ -1888,6 +2027,12 @@ button.danger:hover:not([disabled]) {
|
||||
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 {
|
||||
max-width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -1931,6 +2076,16 @@ button.danger:hover:not([disabled]) {
|
||||
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 {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -2077,7 +2232,8 @@ button.danger:hover:not([disabled]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.documents-panel tbody tr.document.selected {
|
||||
.documents-panel tbody tr.document.selected,
|
||||
.documents-panel tbody tr.folder.selected {
|
||||
background: var(--selection-soft);
|
||||
box-shadow: inset 2px 0 0 var(--accent-outline-strong);
|
||||
}
|
||||
@@ -2116,7 +2272,8 @@ button.danger:hover:not([disabled]) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2145,7 +2302,8 @@ button.danger:hover:not([disabled]) {
|
||||
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);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
@@ -2194,6 +2352,8 @@ button.danger:hover:not([disabled]) {
|
||||
text-overflow: ellipsis;
|
||||
white-space: break-word;
|
||||
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;
|
||||
}
|
||||
|
||||
.tag-chip__label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tag-chip__remove {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -2345,7 +2501,6 @@ button.danger:hover:not([disabled]) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
@@ -2377,10 +2532,10 @@ button.danger:hover:not([disabled]) {
|
||||
}
|
||||
|
||||
.panel.detail-panel {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: var(--detail-panel-width);
|
||||
width: min(100vw, var(--detail-panel-width));
|
||||
min-height: 100vh;
|
||||
height: 100%;
|
||||
height: 100%;
|
||||
@@ -2389,7 +2544,7 @@ button.danger:hover:not([disabled]) {
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 20;
|
||||
z-index: 10000000;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@@ -2438,12 +2593,105 @@ button.danger:hover:not([disabled]) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
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 {
|
||||
margin-top: 0.4rem;
|
||||
@@ -2470,54 +2718,92 @@ button.danger:hover:not([disabled]) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.detail-panel .meta {
|
||||
.detail-panel .meta,
|
||||
.document-summary .meta {
|
||||
font-size: 0.9rem;
|
||||
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;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
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);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__separator {
|
||||
.detail-panel .detail-folder-path__separator,
|
||||
.document-summary .detail-folder-path__separator {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-panel .detail-folder-path__segment {
|
||||
.detail-panel .detail-folder-path__segment,
|
||||
.document-summary .detail-folder-path__segment {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-row {
|
||||
.detail-panel .doc-title-row,
|
||||
.document-summary .doc-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin: 0.25rem 0 1.25rem;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-panel .doc-title-edit {
|
||||
.doc-title-row__primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
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;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -2527,6 +2813,63 @@ button.danger:hover:not([disabled]) {
|
||||
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 {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -2567,6 +2910,12 @@ button.danger:hover:not([disabled]) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-stack--empty {
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-stack__item {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -2620,6 +2969,40 @@ button.danger:hover:not([disabled]) {
|
||||
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 {
|
||||
min-height: 460px;
|
||||
position: relative;
|
||||
@@ -2729,11 +3112,18 @@ button.danger:hover:not([disabled]) {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.detail-panel .tag-list {
|
||||
.detail-panel .tag-list,
|
||||
.document-summary .tag-list,
|
||||
.correspondent-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-list__empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-metadata__block {
|
||||
@@ -2944,6 +3334,19 @@ form.inline {
|
||||
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='datetime-local'] {
|
||||
padding: 0.45rem 0.6rem;
|
||||
@@ -2953,6 +3356,33 @@ form.inline {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -26,8 +26,18 @@ const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null,
|
||||
const WIDTH_TOLERANCE = 1;
|
||||
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 shouldTruncateFromStart = truncateFromStart !== false;
|
||||
const measurementEntries = useMemo(
|
||||
() => (shouldTruncateFromStart ? normalized : normalized.slice().reverse()),
|
||||
[normalized, shouldTruncateFromStart],
|
||||
);
|
||||
const containerRef = useRef(null);
|
||||
const measurementRef = useRef(null);
|
||||
const ellipsisButtonRef = useRef(null);
|
||||
@@ -50,7 +60,7 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
||||
useEffect(() => {
|
||||
setStartIndex(0);
|
||||
closeEllipsisMenu();
|
||||
}, [normalized, closeEllipsisMenu]);
|
||||
}, [normalized, shouldTruncateFromStart, closeEllipsisMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -58,10 +68,12 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const target = container.parentElement || container;
|
||||
|
||||
const updateWidth = () => {
|
||||
const nextWidth = target.getBoundingClientRect().width;
|
||||
const host = containerRef.current;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
const nextWidth = host.getBoundingClientRect().width;
|
||||
if (!nextWidth) {
|
||||
return;
|
||||
}
|
||||
@@ -71,12 +83,12 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
||||
updateWidth();
|
||||
|
||||
const observer = new ResizeObserver(updateWidth);
|
||||
observer.observe(target);
|
||||
observer.observe(container.parentElement || container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!normalized.length) {
|
||||
if (!measurementEntries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,7 +148,7 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
||||
ellipsisNode.style.display = originalEllipsisDisplay ?? 'none';
|
||||
}
|
||||
|
||||
const available = availableWidth ?? container.clientWidth;
|
||||
const available = availableWidth ?? container.getBoundingClientRect().width;
|
||||
if (!available || !widths.length) {
|
||||
return;
|
||||
}
|
||||
@@ -156,13 +168,24 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
||||
if (nextStart !== startIndex) {
|
||||
setStartIndex(nextStart);
|
||||
}
|
||||
}, [normalized, separator, availableWidth, startIndex]);
|
||||
}, [measurementEntries, separator, availableWidth, startIndex]);
|
||||
|
||||
const sliceStart = Math.min(startIndex, Math.max(0, normalized.length - 1));
|
||||
const slice = normalized.slice(sliceStart);
|
||||
const visibleEntries = sliceStart > 0 ? [ELLIPSIS, ...slice] : slice;
|
||||
const hiddenEntries = sliceStart > 0 ? normalized.slice(0, sliceStart) : [];
|
||||
const trimmedCount = Math.min(startIndex, Math.max(0, normalized.length - 1));
|
||||
const visibleEntries = shouldTruncateFromStart
|
||||
? normalized.slice(trimmedCount)
|
||||
: 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 ellipsisPlacement = shouldTruncateFromStart ? 'start' : 'end';
|
||||
const displayEntries = hasHiddenEntries
|
||||
? ellipsisPlacement === 'start'
|
||||
? [ELLIPSIS, ...visibleEntries]
|
||||
: [...visibleEntries, ELLIPSIS]
|
||||
: visibleEntries;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHiddenEntries) {
|
||||
@@ -191,9 +214,9 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
||||
return (
|
||||
<>
|
||||
<span ref={containerRef} className={wrapperClassName}>
|
||||
{visibleEntries.map((entry, index) => {
|
||||
{displayEntries.map((entry, index) => {
|
||||
const isEllipsis = entry.id === ELLIPSIS.id;
|
||||
const isLast = index === visibleEntries.length - 1;
|
||||
const isLast = index === displayEntries.length - 1;
|
||||
|
||||
if (isEllipsis) {
|
||||
return (
|
||||
@@ -273,7 +296,7 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
|
||||
>
|
||||
{ELLIPSIS.label}
|
||||
</span>
|
||||
{normalized.map((entry, index) => (
|
||||
{measurementEntries.map((entry, index) => (
|
||||
<React.Fragment key={`measure-${entry.id || index}`}>
|
||||
{index > 0 ? (
|
||||
<span
|
||||
@@ -342,6 +365,7 @@ BreadcrumbTrail.propTypes = {
|
||||
entries: PropTypes.arrayOf(breadcrumbEntryShape),
|
||||
className: PropTypes.string,
|
||||
separator: PropTypes.string,
|
||||
truncateFromStart: PropTypes.bool,
|
||||
};
|
||||
|
||||
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 {
|
||||
IconChevronRight as TablerChevronRight,
|
||||
IconDownload as TablerDownload,
|
||||
@@ -11,8 +10,6 @@ import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconChevronsRight,
|
||||
IconChevronsLeft,
|
||||
IconAnalyze,
|
||||
IconWindowMaximize,
|
||||
IconTextScan2,
|
||||
@@ -22,13 +19,17 @@ import {
|
||||
IconMinusVertical,
|
||||
IconLogout,
|
||||
IconChevronDown,
|
||||
IconX,
|
||||
IconX as TablerIconX,
|
||||
IconSettings,
|
||||
IconCheck,
|
||||
IconPlus,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconDeviceLaptop,
|
||||
IconLayoutSidebarLeftCollapse,
|
||||
IconLayoutSidebarLeftExpand,
|
||||
IconLayoutSidebarRightCollapse,
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
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 }) => (
|
||||
<IconChevronsLeft
|
||||
export const SidebarCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<IconLayoutSidebarLeftCollapse
|
||||
className={composeClassName('icon', className)}
|
||||
size={size}
|
||||
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 }) => (
|
||||
<IconChevronsRight
|
||||
export const SidebarExpandIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||
<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)}
|
||||
size={size}
|
||||
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 }) => (
|
||||
<IconX
|
||||
className={composeClassName('icon', className)}
|
||||
className={className}
|
||||
size={size}
|
||||
stroke={stroke}
|
||||
{...rest}
|
||||
@@ -348,8 +376,9 @@ export default {
|
||||
DownloadIcon,
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
SidebarCollapseIcon,
|
||||
SidebarExpandIcon,
|
||||
DetailPanelCollapseIcon,
|
||||
AnalyzeIcon,
|
||||
WindowMaximizeIcon,
|
||||
FolderPlusIcon,
|
||||
|
||||
@@ -64,7 +64,10 @@ const useFloatingMenu = ({
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
||||
const viewportWidth = resolveViewportWidth();
|
||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
||||
const menu = menuRef.current;
|
||||
const menuHeight = menu?.offsetHeight ?? 0;
|
||||
|
||||
let left;
|
||||
if (align === 'end') {
|
||||
@@ -75,14 +78,17 @@ const useFloatingMenu = ({
|
||||
left = rect.left;
|
||||
}
|
||||
|
||||
const maxLeft = viewportWidth > 0
|
||||
? viewportWidth - desiredWidth - safeMargin
|
||||
: left;
|
||||
const clampedLeft = viewportWidth > 0
|
||||
? clamp(left, safeMargin, Math.max(maxLeft, safeMargin))
|
||||
: left;
|
||||
const maxLeft = viewportWidth > 0 ? viewportWidth - desiredWidth - safeMargin : 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({
|
||||
top,
|
||||
|
||||
Reference in New Issue
Block a user