2 Commits
Author SHA1 Message Date
nils 41f17733a5 grid view
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has started running
2025-10-16 16:03:51 +02:00
nils 3019172b88 backend 2025-10-16 15:10:21 +02:00
13 changed files with 539 additions and 14 deletions
+1
View File
@@ -634,6 +634,7 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"url",
"uuid",
]
+1
View File
@@ -43,6 +43,7 @@ percent-encoding = "2.3"
base64 = "0.21"
quick-xml = "0.32"
futures-util = "0.3"
url = "2.5"
# Error handling
thiserror = "1.0"
+8 -1
View File
@@ -32,7 +32,14 @@ async fn main() -> Result<()> {
async fn delete_all_assets() -> Result<()> {
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 storage = S3Storage::new(s3_client, config.s3_bucket.clone());
+13 -1
View File
@@ -19,7 +19,19 @@ async fn main() -> anyhow::Result<()> {
init_tracing();
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 storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
+8
View File
@@ -14,6 +14,14 @@ async fn main() -> anyhow::Result<()> {
init_tracing();
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 s3_client = build_client(&config).await?;
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
+47
View File
@@ -1,10 +1,14 @@
use std::env;
use anyhow::{Context, Result};
use url::Url;
use crate::db::DEFAULT_MAX_POOL_SIZE;
#[derive(Clone, Debug)]
pub struct AppConfig {
pub database_url: String,
pub database_max_pool_size: u32,
pub server_host: String,
pub server_port: u16,
pub webdav_host: String,
@@ -31,6 +35,10 @@ pub struct AppConfig {
impl AppConfig {
pub fn from_env() -> Result<Self> {
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_port = env::var("SERVER_PORT")
.unwrap_or_else(|_| "3000".to_string())
@@ -74,6 +82,7 @@ impl AppConfig {
Ok(Self {
database_url,
database_max_pool_size,
server_host,
server_port,
webdav_host,
@@ -97,4 +106,42 @@ impl AppConfig {
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
View File
@@ -5,7 +5,7 @@ use diesel::r2d2::{ConnectionManager, Pool};
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> {
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE)
+11 -1
View File
@@ -19,7 +19,17 @@ async fn main() -> anyhow::Result<()> {
init_tracing();
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 storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
+4 -1
View File
@@ -116,8 +116,11 @@ impl TestApp {
let config = AppConfig {
database_url: database_url.clone(),
database_max_pool_size: db::DEFAULT_MAX_POOL_SIZE,
server_host: "127.0.0.1".to_string(),
server_port: 0,
webdav_host: "127.0.0.1".to_string(),
webdav_port: 0,
jwt_secret: "test-secret".to_string(),
jwt_issuer: "test-issuer".to_string(),
jwt_audience: "test-audience".to_string(),
@@ -137,7 +140,7 @@ impl TestApp {
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?;
let storage = Arc::new(FakeStorage::default());
+210 -4
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
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'];
@@ -74,6 +74,8 @@ const DocumentsTable = ({
onTagClick,
isSearchLoading = false,
onDocumentTagDrop,
viewMode = 'list',
onViewModeChange,
}) => {
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
@@ -91,6 +93,19 @@ const DocumentsTable = ({
[draggingDocumentIds],
);
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 types = Array.from(event.dataTransfer?.types || []);
return TAG_MIME_TYPES.some((type) => types.includes(type));
@@ -199,7 +214,9 @@ const DocumentsTable = ({
);
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__titles">
<nav className="breadcrumb" aria-label="Folder breadcrumbs">
@@ -230,6 +247,26 @@ const DocumentsTable = ({
)}
</div>
<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
type="button"
onClick={onRequestCreateFolder}
@@ -263,12 +300,176 @@ const DocumentsTable = ({
onDocumentListKeyDown(event);
}
}}
aria-activedescendant={activeDescendantId}
aria-activedescendant={isGridView ? undefined : activeDescendantId}
>
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</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">
<thead>
@@ -539,7 +740,7 @@ const DocumentsTable = ({
</table>
)}
</div>
{rows.length === 0 && showingSearchResults && !isSearchLoading && (
{rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
<div className="empty-state">No documents match the current filters.</div>
)}
{showingSearchResults && rows.length > 0 && (
@@ -548,6 +749,11 @@ const DocumentsTable = ({
</div>
)}
</div>
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
</section>
);
};
+22 -1
View File
@@ -364,6 +364,13 @@ const AppLayout = () => {
const [currentSubfolders, setCurrentSubfolders] = useState([]);
const [documents, setDocuments] = useState([]);
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
? [resolveDocumentRowKey(routeDocumentId)]
: [];
@@ -2514,7 +2521,10 @@ const AppLayout = () => {
if (item.type === 'document') {
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 placeholderEl = rowEl?.querySelector('.thumb-placeholder');
@@ -4455,6 +4465,15 @@ const AppLayout = () => {
const showSkeuoWorkspace = useCallback(() => setWorkspaceMode('skeuo'), [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 = {
currentFolderName,
@@ -4501,6 +4520,8 @@ const AppLayout = () => {
: null,
onTagClick: toggleTagFilter,
onDocumentTagDrop: handleDocumentTagDrop,
viewMode: documentsViewMode,
onViewModeChange: handleDocumentsViewModeChange,
};
const detailPanelProps = {
+191 -4
View File
@@ -200,6 +200,47 @@ button.icon-button.ghost:hover:not([disabled]) {
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 {
height: 100%;
display: flex;
@@ -1132,6 +1173,10 @@ button.icon-button.ghost:hover:not([disabled]) {
text-align: left;
}
.documents-panel--view-grid .documents-scroll {
padding: 0.35rem 0.5rem 1rem;
}
.documents-panel th.thumb-column,
.documents-panel td.thumb-cell {
width: 54px;
@@ -1141,19 +1186,31 @@ button.icon-button.ghost:hover:not([disabled]) {
.document-thumbnail-wrapper {
width: 48px;
height: 48px;
background: transparent;
display: inline-flex;
align-items: 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 {
max-width: 100%;
max-height: 100%;
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 {
@@ -1171,6 +1228,14 @@ button.icon-button.ghost:hover:not([disabled]) {
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 {
width: 100%;
height: 100%;
@@ -1241,6 +1306,107 @@ button.icon-button.ghost:hover:not([disabled]) {
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 {
font-weight: 600;
}
@@ -1260,6 +1426,15 @@ button.icon-button.ghost:hover:not([disabled]) {
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 {
display: flex;
flex-wrap: wrap;
@@ -1297,6 +1472,14 @@ button.icon-button.ghost:hover:not([disabled]) {
.tag-chip {
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 {
@@ -1309,6 +1492,10 @@ button.icon-button.ghost:hover:not([disabled]) {
background: var(--surface-subtle);
}
.empty-state--global {
border-style: solid;
}
.detail-panel {
position: relative;
padding: 1.25rem;
+22
View File
@@ -6,6 +6,8 @@ import {
IconTagFilled,
IconUserFilled,
IconTrash,
IconLayoutList,
IconLayoutGrid,
} from '@tabler/icons-react';
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 {
ChevronIcon,
TrashIcon,
@@ -81,4 +101,6 @@ export default {
TagIcon,
CorrespondentIcon,
DownloadIcon,
ViewListIcon,
ViewGridIcon,
};