Compare commits
2
Commits
b741c181e0
...
41f17733a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41f17733a5 | ||
|
|
3019172b88 |
Generated
+1
@@ -634,6 +634,7 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ percent-encoding = "2.3"
|
|||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
quick-xml = "0.32"
|
quick-xml = "0.32"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
url = "2.5"
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
|||||||
@@ -32,7 +32,14 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
async fn delete_all_assets() -> Result<()> {
|
async fn delete_all_assets() -> Result<()> {
|
||||||
let config = AppConfig::from_env()?;
|
let config = AppConfig::from_env()?;
|
||||||
let pool = db::init_pool(&config.database_url)?;
|
tracing::info!(
|
||||||
|
component = "maintenance",
|
||||||
|
database_url = %config.redacted_database_url(),
|
||||||
|
pool_size = config.database_max_pool_size,
|
||||||
|
s3_bucket = %config.s3_bucket,
|
||||||
|
"loaded backend configuration"
|
||||||
|
);
|
||||||
|
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||||
|
|
||||||
let s3_client = s3::build_client(&config).await?;
|
let s3_client = s3::build_client(&config).await?;
|
||||||
let storage = S3Storage::new(s3_client, config.s3_bucket.clone());
|
let storage = S3Storage::new(s3_client, config.s3_bucket.clone());
|
||||||
|
|||||||
@@ -19,7 +19,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
init_tracing();
|
init_tracing();
|
||||||
|
|
||||||
let config = AppConfig::from_env()?;
|
let config = AppConfig::from_env()?;
|
||||||
let pool = db::init_pool(&config.database_url)?;
|
tracing::info!(
|
||||||
|
component = "webdav",
|
||||||
|
database_url = %config.redacted_database_url(),
|
||||||
|
pool_size = config.database_max_pool_size,
|
||||||
|
server_host = %config.server_host,
|
||||||
|
server_port = config.server_port,
|
||||||
|
webdav_host = %config.webdav_host,
|
||||||
|
webdav_port = config.webdav_port,
|
||||||
|
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||||
|
s3_bucket = %config.s3_bucket,
|
||||||
|
"loaded backend configuration"
|
||||||
|
);
|
||||||
|
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||||
let s3_client = build_client(&config).await?;
|
let s3_client = build_client(&config).await?;
|
||||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||||
let jwt = JwtService::from_config(&config)?;
|
let jwt = JwtService::from_config(&config)?;
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
init_tracing();
|
init_tracing();
|
||||||
|
|
||||||
let config = AppConfig::from_env()?;
|
let config = AppConfig::from_env()?;
|
||||||
|
tracing::info!(
|
||||||
|
component = "worker",
|
||||||
|
database_url = %config.redacted_database_url(),
|
||||||
|
pool_size = 1,
|
||||||
|
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||||
|
s3_bucket = %config.s3_bucket,
|
||||||
|
"loaded backend configuration"
|
||||||
|
);
|
||||||
let pool = db::init_pool_with_size(&config.database_url, 1)?;
|
let pool = db::init_pool_with_size(&config.database_url, 1)?;
|
||||||
let s3_client = build_client(&config).await?;
|
let s3_client = build_client(&config).await?;
|
||||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::db::DEFAULT_MAX_POOL_SIZE;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
|
pub database_max_pool_size: u32,
|
||||||
pub server_host: String,
|
pub server_host: String,
|
||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
pub webdav_host: String,
|
pub webdav_host: String,
|
||||||
@@ -31,6 +35,10 @@ pub struct AppConfig {
|
|||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||||
|
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.unwrap_or(DEFAULT_MAX_POOL_SIZE);
|
||||||
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||||
let server_port = env::var("SERVER_PORT")
|
let server_port = env::var("SERVER_PORT")
|
||||||
.unwrap_or_else(|_| "3000".to_string())
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
@@ -74,6 +82,7 @@ impl AppConfig {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
database_url,
|
database_url,
|
||||||
|
database_max_pool_size,
|
||||||
server_host,
|
server_host,
|
||||||
server_port,
|
server_port,
|
||||||
webdav_host,
|
webdav_host,
|
||||||
@@ -97,4 +106,42 @@ impl AppConfig {
|
|||||||
quickwit_index,
|
quickwit_index,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn redacted_database_url(&self) -> String {
|
||||||
|
redact_database_url(&self.database_url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact_database_url(raw: &str) -> String {
|
||||||
|
match Url::parse(raw) {
|
||||||
|
Ok(mut parsed) => {
|
||||||
|
let _ = parsed.set_password(Some("*****"));
|
||||||
|
parsed.to_string()
|
||||||
|
}
|
||||||
|
Err(_) => "***".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::redact_database_url;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redacts_password_in_database_url() {
|
||||||
|
let redacted = redact_database_url("postgres://user:secret@localhost/db");
|
||||||
|
assert!(redacted.contains("postgres://user:*****@"));
|
||||||
|
assert!(!redacted.contains("secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_url_without_password() {
|
||||||
|
let redacted = redact_database_url("postgres://localhost/db");
|
||||||
|
assert_eq!(redacted, "postgres://localhost/db");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_when_parse_fails() {
|
||||||
|
let redacted = redact_database_url("not a url");
|
||||||
|
assert_eq!(redacted, "***");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ use diesel::r2d2::{ConnectionManager, Pool};
|
|||||||
|
|
||||||
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
|
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
|
||||||
|
|
||||||
const DEFAULT_MAX_POOL_SIZE: u32 = 2;
|
pub const DEFAULT_MAX_POOL_SIZE: u32 = 2;
|
||||||
|
|
||||||
pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
|
pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
|
||||||
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE)
|
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE)
|
||||||
|
|||||||
+11
-1
@@ -19,7 +19,17 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
init_tracing();
|
init_tracing();
|
||||||
|
|
||||||
let config = AppConfig::from_env()?;
|
let config = AppConfig::from_env()?;
|
||||||
let pool = db::init_pool(&config.database_url)?;
|
tracing::info!(
|
||||||
|
component = "api",
|
||||||
|
database_url = %config.redacted_database_url(),
|
||||||
|
pool_size = config.database_max_pool_size,
|
||||||
|
server_host = %config.server_host,
|
||||||
|
server_port = config.server_port,
|
||||||
|
quickwit_enabled = config.quickwit_endpoint.is_some(),
|
||||||
|
s3_bucket = %config.s3_bucket,
|
||||||
|
"loaded backend configuration"
|
||||||
|
);
|
||||||
|
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||||
let s3_client = build_client(&config).await?;
|
let s3_client = build_client(&config).await?;
|
||||||
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||||
let jwt = JwtService::from_config(&config)?;
|
let jwt = JwtService::from_config(&config)?;
|
||||||
|
|||||||
@@ -116,8 +116,11 @@ impl TestApp {
|
|||||||
|
|
||||||
let config = AppConfig {
|
let config = AppConfig {
|
||||||
database_url: database_url.clone(),
|
database_url: database_url.clone(),
|
||||||
|
database_max_pool_size: db::DEFAULT_MAX_POOL_SIZE,
|
||||||
server_host: "127.0.0.1".to_string(),
|
server_host: "127.0.0.1".to_string(),
|
||||||
server_port: 0,
|
server_port: 0,
|
||||||
|
webdav_host: "127.0.0.1".to_string(),
|
||||||
|
webdav_port: 0,
|
||||||
jwt_secret: "test-secret".to_string(),
|
jwt_secret: "test-secret".to_string(),
|
||||||
jwt_issuer: "test-issuer".to_string(),
|
jwt_issuer: "test-issuer".to_string(),
|
||||||
jwt_audience: "test-audience".to_string(),
|
jwt_audience: "test-audience".to_string(),
|
||||||
@@ -137,7 +140,7 @@ impl TestApp {
|
|||||||
quickwit_index: None,
|
quickwit_index: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let pool = db::init_pool(&config.database_url)?;
|
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
|
||||||
prepare_database(&pool).await?;
|
prepare_database(&pool).await?;
|
||||||
|
|
||||||
let storage = Arc::new(FakeStorage::default());
|
let storage = Arc::new(FakeStorage::default());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
import { resolveDocumentAssetUrl } from '../asset_manager';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { DownloadIcon, FolderIcon, EditIcon } from '../ui/icons';
|
import { DownloadIcon, FolderIcon, EditIcon, ViewListIcon, ViewGridIcon } from '../ui/icons';
|
||||||
|
|
||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
|
|
||||||
@@ -74,6 +74,8 @@ const DocumentsTable = ({
|
|||||||
onTagClick,
|
onTagClick,
|
||||||
isSearchLoading = false,
|
isSearchLoading = false,
|
||||||
onDocumentTagDrop,
|
onDocumentTagDrop,
|
||||||
|
viewMode = 'list',
|
||||||
|
onViewModeChange,
|
||||||
}) => {
|
}) => {
|
||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
const rows = showingSearchResults ? searchResults : documents;
|
const rows = showingSearchResults ? searchResults : documents;
|
||||||
@@ -91,6 +93,19 @@ const DocumentsTable = ({
|
|||||||
[draggingDocumentIds],
|
[draggingDocumentIds],
|
||||||
);
|
);
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
|
const isGridView = viewMode === 'grid';
|
||||||
|
const handleSetViewMode = useCallback(
|
||||||
|
(nextMode) => {
|
||||||
|
if (!onViewModeChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onViewModeChange(nextMode);
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onViewModeChange],
|
||||||
|
);
|
||||||
const isTagDragEvent = useCallback((event) => {
|
const isTagDragEvent = useCallback((event) => {
|
||||||
const types = Array.from(event.dataTransfer?.types || []);
|
const types = Array.from(event.dataTransfer?.types || []);
|
||||||
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
return TAG_MIME_TYPES.some((type) => types.includes(type));
|
||||||
@@ -199,7 +214,9 @@ const DocumentsTable = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="documents-panel column">
|
<section
|
||||||
|
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||||
|
>
|
||||||
<div className="column-header">
|
<div className="column-header">
|
||||||
<div className="column-header__titles">
|
<div className="column-header__titles">
|
||||||
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
|
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
|
||||||
@@ -230,6 +247,26 @@ const DocumentsTable = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`view-toggle__button${isGridView ? '' : ' active'}`}
|
||||||
|
onClick={() => handleSetViewMode('list')}
|
||||||
|
aria-pressed={!isGridView}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRequestCreateFolder}
|
onClick={onRequestCreateFolder}
|
||||||
@@ -263,12 +300,176 @@ const DocumentsTable = ({
|
|||||||
onDocumentListKeyDown(event);
|
onDocumentListKeyDown(event);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-activedescendant={activeDescendantId}
|
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||||
>
|
>
|
||||||
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
|
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
Drop files anywhere or onto a folder to upload documents.
|
Drop files anywhere or onto a folder to upload documents.
|
||||||
</div>
|
</div>
|
||||||
|
) : isGridView ? (
|
||||||
|
<div className="documents-grid" role="list">
|
||||||
|
{!showingSearchResults &&
|
||||||
|
subfolders.map((folder) => {
|
||||||
|
const canDragFolder = folder.id !== 'root';
|
||||||
|
const isDraggingFolder = draggedFolderId === folder.id;
|
||||||
|
const isSelectedFolder = selectedFolderSet.has(folder.id);
|
||||||
|
const classes = ['document-card', 'folder-card'];
|
||||||
|
if (isDraggingFolder) classes.push('is-dragging');
|
||||||
|
if (isSelectedFolder) classes.push('selected');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={folder.id}
|
||||||
|
className={classes.join(' ')}
|
||||||
|
role="listitem"
|
||||||
|
id={`folder-card-${folder.id}`}
|
||||||
|
draggable={canDragFolder}
|
||||||
|
onClick={(event) => {
|
||||||
|
onFolderRowClick?.(folder.id, event);
|
||||||
|
const shouldNavigate =
|
||||||
|
!event.defaultPrevented &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.shiftKey;
|
||||||
|
if (shouldNavigate) {
|
||||||
|
onFolderSelect(folder.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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="icon-inline" size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="folder-card__meta">
|
||||||
|
<div className="folder-card__name" title={folder.name}>
|
||||||
|
{folder.name}
|
||||||
|
</div>
|
||||||
|
<div className="folder-card__badge">Folder</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.map((doc) => {
|
||||||
|
const isSelected = selectedSet.has(doc.id);
|
||||||
|
const isDraggingDoc = draggingSet.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 cardClasses = ['document-card', 'document'];
|
||||||
|
if (isSelected) cardClasses.push('selected');
|
||||||
|
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className={cardClasses.join(' ')}
|
||||||
|
role="listitem"
|
||||||
|
id={`document-card-${doc.id}`}
|
||||||
|
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||||
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => onDocumentDragStart(event, doc)}
|
||||||
|
onDragEnd={onDocumentDragEnd}
|
||||||
|
onDragOver={(event) => handleDocumentTagDragOver(event)}
|
||||||
|
onDragOverCapture={(event) => handleDocumentTagDragOver(event)}
|
||||||
|
onDragLeave={handleDocumentTagDragLeave}
|
||||||
|
onDragLeaveCapture={handleDocumentTagDragLeave}
|
||||||
|
onDrop={(event) => handleDocumentTagDrop(event, doc.id)}
|
||||||
|
onDropCapture={(event) => handleDocumentTagDrop(event, doc.id)}
|
||||||
|
>
|
||||||
|
<DocumentThumbnailImage
|
||||||
|
document={doc}
|
||||||
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
|
getDocumentAsset={getDocumentAsset}
|
||||||
|
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||||
|
/>
|
||||||
|
<div className="document-card__meta">
|
||||||
|
<div
|
||||||
|
className="document-card__title"
|
||||||
|
title={doc.title || doc.original_name}
|
||||||
|
>
|
||||||
|
{doc.title || doc.original_name}
|
||||||
|
</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}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (onTagClick) {
|
||||||
|
onTagClick(tag.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'copyMove';
|
||||||
|
}
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
id: tag.id,
|
||||||
|
label: tag.label,
|
||||||
|
sourceDocId: doc.id,
|
||||||
|
});
|
||||||
|
event.dataTransfer?.setData('application/x-papercrate-tag', payload);
|
||||||
|
event.dataTransfer?.setData('text/papercrate-tag', payload);
|
||||||
|
event.dataTransfer?.setData('text/plain', tag.label || 'Tag');
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {}
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (onTagClick) {
|
||||||
|
onTagClick(tag.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{remainingTagCount > 0 && (
|
||||||
|
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table aria-multiselectable="true">
|
<table aria-multiselectable="true">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -539,7 +740,7 @@ const DocumentsTable = ({
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{rows.length === 0 && showingSearchResults && !isSearchLoading && (
|
{rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
|
||||||
<div className="empty-state">No documents match the current filters.</div>
|
<div className="empty-state">No documents match the current filters.</div>
|
||||||
)}
|
)}
|
||||||
{showingSearchResults && rows.length > 0 && (
|
{showingSearchResults && rows.length > 0 && (
|
||||||
@@ -548,6 +749,11 @@ const DocumentsTable = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
|
||||||
|
<div className="empty-state empty-state--global">
|
||||||
|
No documents match the current filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+22
-1
@@ -364,6 +364,13 @@ const AppLayout = () => {
|
|||||||
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
||||||
const [documents, setDocuments] = useState([]);
|
const [documents, setDocuments] = useState([]);
|
||||||
const [workspaceMode, setWorkspaceMode] = useState('table');
|
const [workspaceMode, setWorkspaceMode] = useState('table');
|
||||||
|
const [documentsViewMode, setDocumentsViewMode] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'list';
|
||||||
|
}
|
||||||
|
const stored = window.localStorage.getItem('papercrate_view_mode');
|
||||||
|
return stored === 'grid' ? 'grid' : 'list';
|
||||||
|
});
|
||||||
const initialRowSelection = routeDocumentId
|
const initialRowSelection = routeDocumentId
|
||||||
? [resolveDocumentRowKey(routeDocumentId)]
|
? [resolveDocumentRowKey(routeDocumentId)]
|
||||||
: [];
|
: [];
|
||||||
@@ -2514,7 +2521,10 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
if (item.type === 'document') {
|
if (item.type === 'document') {
|
||||||
const doc = item.payload;
|
const doc = item.payload;
|
||||||
const rowEl = doc?.id ? document.getElementById(`document-row-${doc.id}`) : null;
|
const rowEl = doc?.id
|
||||||
|
? document.getElementById(`document-row-${doc.id}`) ||
|
||||||
|
document.getElementById(`document-card-${doc.id}`)
|
||||||
|
: null;
|
||||||
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
|
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
|
||||||
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
|
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
|
||||||
|
|
||||||
@@ -4455,6 +4465,15 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const showSkeuoWorkspace = useCallback(() => setWorkspaceMode('skeuo'), [setWorkspaceMode]);
|
const showSkeuoWorkspace = useCallback(() => setWorkspaceMode('skeuo'), [setWorkspaceMode]);
|
||||||
const exitSkeuoWorkspace = useCallback(() => setWorkspaceMode('table'), [setWorkspaceMode]);
|
const exitSkeuoWorkspace = useCallback(() => setWorkspaceMode('table'), [setWorkspaceMode]);
|
||||||
|
const handleDocumentsViewModeChange = useCallback((mode) => {
|
||||||
|
setDocumentsViewMode((previous) => {
|
||||||
|
const next = mode === 'grid' ? 'grid' : 'list';
|
||||||
|
if (next !== previous && typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem('papercrate_view_mode', next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const documentsTableProps = {
|
const documentsTableProps = {
|
||||||
currentFolderName,
|
currentFolderName,
|
||||||
@@ -4501,6 +4520,8 @@ const AppLayout = () => {
|
|||||||
: null,
|
: null,
|
||||||
onTagClick: toggleTagFilter,
|
onTagClick: toggleTagFilter,
|
||||||
onDocumentTagDrop: handleDocumentTagDrop,
|
onDocumentTagDrop: handleDocumentTagDrop,
|
||||||
|
viewMode: documentsViewMode,
|
||||||
|
onViewModeChange: handleDocumentsViewModeChange,
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailPanelProps = {
|
const detailPanelProps = {
|
||||||
|
|||||||
+191
-4
@@ -200,6 +200,47 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle__button {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle__button:hover,
|
||||||
|
.view-toggle__button:focus-visible {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle__button.active {
|
||||||
|
background: var(--accent-soft, rgba(63, 106, 216, 0.16));
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle__icon {
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1132,6 +1173,10 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-panel--view-grid .documents-scroll {
|
||||||
|
padding: 0.35rem 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.documents-panel th.thumb-column,
|
.documents-panel th.thumb-column,
|
||||||
.documents-panel td.thumb-cell {
|
.documents-panel td.thumb-cell {
|
||||||
width: 54px;
|
width: 54px;
|
||||||
@@ -1141,19 +1186,31 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
.document-thumbnail-wrapper {
|
.document-thumbnail-wrapper {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
background: transparent;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-panel--view-grid .document-thumbnail-wrapper {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.document-thumbnail {
|
.document-thumbnail {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: var(--surface-subtle);
|
}
|
||||||
border-radius: 1px;
|
|
||||||
box-shadow: 0 1px 3px var(--shadow-medium);
|
.documents-panel--view-grid .document-thumbnail {
|
||||||
|
box-shadow: 0 1px 12px var(--shadow-medium);
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-placeholder {
|
.thumb-placeholder {
|
||||||
@@ -1171,6 +1228,14 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
box-shadow: 0 1px 3px var(--shadow-medium);
|
box-shadow: 0 1px 3px var(--shadow-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-panel--view-grid .thumb-placeholder {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.thumb-icon {
|
.thumb-icon {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1241,6 +1306,107 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
|
||||||
|
gap: 0.2rem 1.3rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card {
|
||||||
|
padding: 0rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card.selected .document-thumbnail-wrapper {
|
||||||
|
background-color: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card:hover .document-thumbnail-wrapper,
|
||||||
|
.document-card:focus-visible .document-thumbnail-wrapper {
|
||||||
|
background-color: var(--selection-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card.is-dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card__title {
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card.selected .document-card__title {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card__subtitle {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.28rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--fg);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-card__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-self: center;
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
.doc-name__title {
|
.doc-name__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -1260,6 +1426,15 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
box-shadow: inset 0 0 0 2px var(--accent);
|
box-shadow: inset 0 0 0 2px var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-card.tag-drop-target {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card.tag-drop-target .document-card__title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1297,6 +1472,14 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
|
|
||||||
.tag-chip {
|
.tag-chip {
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip--more {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border-strong, rgba(15, 23, 42, 0.24));
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -1309,6 +1492,10 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
background: var(--surface-subtle);
|
background: var(--surface-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state--global {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-panel {
|
.detail-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
IconTagFilled,
|
IconTagFilled,
|
||||||
IconUserFilled,
|
IconUserFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
|
IconLayoutList,
|
||||||
|
IconLayoutGrid,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
|
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
|
||||||
@@ -73,6 +75,24 @@ export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ViewListIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconLayoutList
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconLayoutGrid
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
ChevronIcon,
|
ChevronIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
@@ -81,4 +101,6 @@ export default {
|
|||||||
TagIcon,
|
TagIcon,
|
||||||
CorrespondentIcon,
|
CorrespondentIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
ViewListIcon,
|
||||||
|
ViewGridIcon,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user