44 Commits
Author SHA1 Message Date
nils 4ec19dbd70 fix
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 15m41s
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 15m46s
2025-11-02 15:56:12 +01:00
nils f8ab674b84 spacebar 2025-11-02 15:44:09 +01:00
nils 866a31b89d persistence 2025-11-02 15:34:26 +01:00
nils 4f296ac1e3 fix 2025-11-02 15:20:41 +01:00
nils d6a6d1233c various 2025-11-02 15:03:23 +01:00
nils 8f86a71c39 fix 2025-11-02 14:27:13 +01:00
nils 1cddc3c373 fix 2025-11-02 14:20:11 +01:00
nils bd2669af4d viewer 2025-11-02 14:12:40 +01:00
nils 65495a07b2 fix 2025-11-02 14:08:08 +01:00
nils a30b6eaa02 backend 409 on document conflict 2025-11-02 13:22:16 +01:00
nils 9ddaa59155 desktop selection 2025-11-02 13:12:36 +01:00
nils 5bf64fc5f3 icons 2025-11-02 12:22:02 +01:00
nils 824db2087c dbltap 2025-11-02 12:17:32 +01:00
nils 1135873968 ui 2025-11-02 12:12:11 +01:00
nils e7e7881772 playful 2025-11-02 04:40:07 +01:00
nils f7b274c1ec cors 2025-11-02 02:57:02 +01:00
nils b561f46d2c content disposition 2025-11-02 02:26:40 +01:00
nils b9c95437d2 details 2025-11-02 02:05:05 +01:00
nils 7d8595b63f rename 2025-11-02 01:18:24 +01:00
nils 32d627fd6d fix 2025-11-02 00:42:47 +01:00
nils 09430c6f40 refactor 2025-11-02 00:35:32 +01:00
nils 49998fa23d DocumentsPanel 2025-11-02 00:23:25 +01:00
nils 14077d26d7 style 2025-11-01 23:50:30 +01:00
nils 40832ff1c3 styling 2025-11-01 23:49:04 +01:00
nils 0b8f856876 folder-selection 2025-11-01 23:37:12 +01:00
nils 16ac16cd30 ui 2025-11-01 22:49:50 +01:00
nils 326a6042ec api-tokens 2025-11-01 22:46:04 +01:00
nils 8efe2d19ba user_sessions 2025-11-01 22:45:46 +01:00
nils 1c5b920e13 gitea-workflow 2025-11-01 21:43:32 +01:00
nils 00f5d3adea api-tokens 2025-11-01 21:42:52 +01:00
nils f6ceff5444 selection 2025-11-01 20:28:00 +01:00
nils 9559a1b5e2 fix 2025-11-01 19:59:39 +01:00
nils e752361f39 sidebar 2025-11-01 19:50:29 +01:00
nils 41c9ae30e9 fix 2025-11-01 19:45:16 +01:00
nils 03bc311403 detailpanel 2025-11-01 19:39:51 +01:00
nils 366f716b1c more screenshots 2025-11-01 15:13:47 +01:00
nils 3731fe8e40 clean 2025-11-01 15:01:35 +01:00
nils 3c416600a6 screenshot 2025-11-01 14:57:37 +01:00
nils 6f6ee59461 fix 2025-11-01 13:59:46 +01:00
nils 9aed7ec524 fix 2025-11-01 13:51:43 +01:00
nils 816c880e8e cleanup 2025-11-01 13:40:10 +01:00
nils 816910ca40 fix 2025-11-01 04:29:08 +01:00
nils 6eb3f8a3d9 detailpanel 2025-11-01 04:22:51 +01:00
nils 303ece0529 detailpanel 2025-11-01 04:16:31 +01:00
68 changed files with 7828 additions and 3945 deletions
+13 -4
View File
@@ -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 }}
-197
View File
@@ -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"
+6
View File
@@ -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
![Document detail view](docs/detailview.png)
![Desk overview](docs/deskview.png)
![Document view](docs/documentview.png)
---
For development workflows (local stack, integration tests, migrations, and
+17
View File
@@ -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
View File
@@ -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();
+339
View File
@@ -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 -1
View File
@@ -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};
-240
View File
@@ -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());
}
}
+11
View File
@@ -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
View File
@@ -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,
+6 -5
View File
@@ -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
View File
@@ -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")
}
+31 -18
View File
@@ -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 {
+9 -5
View File
@@ -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
View File
@@ -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
))
+29 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+4 -4
View File
@@ -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(),
}),
+283
View File
@@ -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, &regenerated.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
View File
@@ -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
View File
@@ -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())
-219
View File
@@ -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(&regenerate_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(&regenerate_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(())
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+1 -1
View File
@@ -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

+56
View File
@@ -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

+115 -1
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -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;
+16 -15
View File
@@ -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;
+40 -28
View File
@@ -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}
+162
View File
@@ -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;
+250
View File
@@ -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;
+47 -8
View File
@@ -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) {
+747 -67
View File
@@ -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
View File
@@ -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}
/>
+62 -23
View File
@@ -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 };
+209
View File
@@ -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;
+322
View File
@@ -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;
+640
View File
@@ -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
+38
View File
@@ -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;
+9 -10
View File
@@ -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 -19
View File
@@ -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,
};
};
-261
View File
@@ -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,
};
};
+1 -1
View File
@@ -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 />;
+140 -12
View File
@@ -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>
+196
View File
@@ -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;
+53 -29
View File
@@ -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>
);
+226
View File
@@ -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
View File
@@ -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;
+40 -16
View File
@@ -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;
+205
View File
@@ -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
View File
@@ -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,
+13 -7
View File
@@ -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,