dockerfiles, TagsWorkspace

This commit is contained in:
2025-10-12 01:59:26 +02:00
parent f30e455c2d
commit d9a36a99a7
5 changed files with 271 additions and 229 deletions
+48
View File
@@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1
FROM rust:1-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libssl-dev \
libpq-dev \
libjpeg-dev \
libpng-dev \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
COPY tests ./tests
COPY diesel.toml ./
RUN cargo build --release --bin paperless-backend --bin worker
RUN cargo install diesel_cli --no-default-features --features postgres
FROM debian:bookworm-slim AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 \
libpq5 \
libjpeg62-turbo \
libpng16-16 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --uid 10001 appuser
COPY --from=builder /app/target/release/paperless-backend /usr/local/bin/paperless-backend
COPY --from=builder /app/target/release/worker /usr/local/bin/paperless-worker
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
COPY migrations ./migrations
COPY diesel.toml ./
ENV RUST_LOG=info
USER appuser
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/paperless-backend"]
+19
View File
@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
FROM nginx:alpine
WORKDIR /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist ./
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+11
View File
@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}
+186 -226
View File
@@ -4,6 +4,7 @@ import React, {
useMemo,
useRef,
useState,
useContext,
} from 'react';
import { createRoot } from 'react-dom/client';
import axios from 'axios';
@@ -12,8 +13,11 @@ import {
Navigate,
Route,
Routes,
Outlet,
useLocation,
useMatch,
useNavigate,
useParams,
matchPath,
} from 'react-router-dom';
import './styles.css';
@@ -1174,7 +1178,13 @@ const Sidebar = ({
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onShowTags,
tags = [],
}) => {
const handleShowTags = onShowTags || (() => {});
const tagsRouteMatch = useMatch('/tags');
const isTagsRoute = Boolean(tagsRouteMatch);
const renderNodes = useCallback(
(ids, depth) =>
ids.map((id) => {
@@ -1249,8 +1259,8 @@ const Sidebar = ({
</div>
<button
type="button"
className={`sidebar-link${isTagsView ? ' active' : ''}`}
onClick={onShowTags}
className={`sidebar-link${isTagsRoute ? ' active' : ''}`}
onClick={handleShowTags}
>
All tags
</button>
@@ -1282,6 +1292,17 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
setError(null);
}, []);
const colorPickerValue = useMemo(() => {
if (!draftColor) {
return '#3366ff';
}
const match = HEX_COLOR_PATTERN.exec(draftColor.trim());
if (!match) {
return '#3366ff';
}
return `#${match[1].toLowerCase()}`;
}, [draftColor]);
const handleSave = useCallback(async () => {
if (!editingId) return;
@@ -1391,13 +1412,12 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag }) => {
{isEditing ? (
<div className="tags-table__color-editor">
<input
className="tags-table__color-field"
value={draftColor}
type="color"
className="tags-table__color-picker"
value={colorPickerValue}
onChange={(event) => setDraftColor(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="#3366ff"
spellCheck="false"
disabled={saving}
aria-label="Pick tag color"
/>
{draftColor && (
<button
@@ -1470,51 +1490,17 @@ const MainLayout = ({ sidebarProps, children, className = 'app-main' }) => (
</main>
);
const DocumentsWorkspace = ({
sidebarProps,
previewActive,
previewWorkspaceDocument,
previewWorkspaceDetail,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
documentsTableProps,
detailPanelProps,
}) => {
if (previewActive && previewWorkspaceDocument) {
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
detail={previewWorkspaceDetail}
previewEntry={previewWorkspaceEntry}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
}
const AppShellContext = React.createContext(null);
return (
<MainLayout sidebarProps={sidebarProps}>
<DocumentsTable {...documentsTableProps} />
<DetailPanel {...detailPanelProps} />
</MainLayout>
);
};
const TagsWorkspace = ({ sidebarProps, tags, onRefresh, onUpdateTag }) => (
<MainLayout sidebarProps={sidebarProps} className="tags-main">
<TagsPanel tags={tags} onRefresh={onRefresh} onUpdateTag={onUpdateTag} />
</MainLayout>
);
function App({
routeFolderId = null,
routeDocumentId = null,
navigate,
mode = 'documents',
}) {
const AppLayout = () => {
const navigate = useNavigate();
const location = useLocation();
const folderMatch = matchPath('/folders/:folderId', location.pathname);
const nestedDocMatch = matchPath('/folders/:folderId/documents/:documentId', location.pathname);
const docMatch = nestedDocMatch || matchPath('/documents/:documentId', location.pathname);
const routeFolderId =
folderMatch?.params?.folderId || nestedDocMatch?.params?.folderId || null;
const routeDocumentId = docMatch?.params?.documentId || null;
const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || '');
const [status, setStatus] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => {
@@ -3789,15 +3775,7 @@ function App({
return pool.find((doc) => doc.id === previewDocumentId) || null;
}, [previewDocumentId, previewWorkspaceDetail, searchResults, documents]);
const isTagsView = mode === 'tags';
const previewActive = !isTagsView && Boolean(previewDocumentId && previewWorkspaceDocument);
const goToTagsView = useCallback(() => {
if (!navigate || isTagsView) {
return;
}
navigate('/tags');
}, [navigate, isTagsView]);
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
const sidebarProps = {
folderNodes,
@@ -3812,8 +3790,7 @@ function App({
onFolderDragStart: handleFolderDragStart,
onFolderDragEnd: handleFolderDragEnd,
draggedFolderId,
onShowTags: goToTagsView,
isTagsView,
onShowTags: () => navigate('/tags'),
tags,
};
@@ -3865,180 +3842,163 @@ function App({
activePreviewId,
};
return (
<div className="app-shell">
{token ? (
<>
<DropOverlay
active={dropOverlayState.active}
folderName={dropOverlayState.folderName}
/>
<header className="app-bar">
<div className="app-bar__main">
<div className="app-bar__meta">
<h1>Paperless-NEO</h1>
<span className="app-bar__hint">
{previewActive
? 'Viewing document preview. Press ← Back to return to the library.'
: 'Drag files here to upload.'}
</span>
</div>
{status && (
<div className="app-bar__status">
<StatusBanner status={status} />
</div>
)}
<div className="app-bar__actions">
<button
className="secondary"
type="button"
onClick={handleBulkReanalyze}
>
Re-analyze All
</button>
<button className="secondary" onClick={handleLogout}>
Log out
</button>
</div>
</div>
</header>
<main className={previewActive ? 'preview-main' : 'app-main'}>
{previewActive && previewWorkspaceDocument ? (
<PreviewWorkspace
document={previewWorkspaceDocument}
detail={previewWorkspaceDetail}
previewEntry={previewWorkspaceEntry}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
) : (
<>
<Sidebar
folderNodes={folderNodes}
onToggle={folderClickHandlers.onToggle}
onSelect={folderClickHandlers.onSelect}
onDrop={folderClickHandlers.onDrop}
onDragOver={folderClickHandlers.onDragOver}
onDragLeave={folderClickHandlers.onDragLeave}
onCreateFolder={handleFolderCreate}
onDeleteFolder={handleFolderDelete}
selectedFolder={selectedFolder}
onFolderDragStart={handleFolderDragStart}
onFolderDragEnd={handleFolderDragEnd}
draggedFolderId={draggedFolderId}
/>
<DocumentsTable
currentFolderName={currentFolderName}
breadcrumbs={breadcrumbs}
onRefresh={refreshCurrentFolder}
subfolders={currentSubfolders}
documents={documents}
searchResults={searchResults}
isFilterActive={isFilterActive}
onFolderSelect={selectFolder}
onFolderDrop={folderClickHandlers.onDrop}
onFolderDragOver={folderClickHandlers.onDragOver}
onFolderDragLeave={folderClickHandlers.onDragLeave}
onFolderDragStart={handleFolderDragStart}
onFolderDragEnd={handleFolderDragEnd}
draggedFolderId={draggedFolderId}
onFolderDelete={handleFolderDelete}
onDocumentRowClick={handleDocumentRowClick}
onDocumentOpen={openDocumentPreview}
selectedDocumentIds={selectedDocumentIds}
focusedDocumentId={focusedDocumentId}
draggingDocumentIds={draggedDocumentIds}
onDocumentDragStart={(event, documentId) => {
const selection = selectedDocumentIds.includes(documentId)
? selectedDocumentIds
: [documentId];
if (!selectedDocumentIds.includes(documentId)) {
applySelection([documentId], {
anchor: documentId,
interactedIds: [documentId],
});
}
setDraggedDocumentIds(selection);
event.dataTransfer.effectAllowed = 'move';
try {
event.dataTransfer.setData(
'application/x-paperless-doc-list',
JSON.stringify(selection),
);
} catch (
// eslint-disable-next-line no-empty
error
) {}
event.currentTarget.classList.add('dragging');
}}
onDocumentDragEnd={(event) => {
setDraggedDocumentIds([]);
event.currentTarget.classList.remove('dragging');
}}
filterBar={filterBar}
/>
<DetailPanel
selectedDocuments={orderedSelectedDocuments}
detailMap={documentDetails}
tags={tags}
onTagAdd={handleTagAdd}
onTagRemove={handleTagRemove}
onRegenerateThumbnails={handleThumbnailRegeneration}
previewEntry={selectedPreviewEntry}
onOpenPreview={openDocumentPreview}
onBulkTagAdd={handleBulkTagAddFromDetail}
onBulkTagRemove={handleBulkTagRemoveFromDetail}
onBulkMove={handleBulkMoveFromDetail}
onBulkReanalyze={handleBulkSelectionReanalyze}
folderOptions={folderOptions}
defaultMoveTarget={defaultMoveTarget}
onPromoteSelection={promoteSelectionOrder}
activePreviewId={activePreviewId}
/>
</>
)}
</main>
</>
) : (
<LoginView onSubmit={handleLogin} status={status} />
)}
</div>
const contextValue = useMemo(
() => ({
token,
status,
dropOverlayState,
handleBulkReanalyze,
handleLogout,
sidebarProps,
tags,
refreshTags,
handleTagUpdate,
previewActive,
previewWorkspaceDocument,
previewWorkspaceDetail,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
documentsTableProps,
detailPanelProps,
}),
[
token,
status,
dropOverlayState,
handleBulkReanalyze,
handleLogout,
sidebarProps,
tags,
refreshTags,
handleTagUpdate,
previewActive,
previewWorkspaceDocument,
previewWorkspaceDetail,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
documentsTableProps,
detailPanelProps,
],
);
}
const RoutedDocumentsApp = () => {
const navigate = useNavigate();
const params = useParams();
const folderId = params.folderId ?? null;
const documentId = params.documentId ?? null;
if (!token) {
return (
<div className="app-shell">
<LoginView onSubmit={handleLogin} status={status} />
</div>
);
}
return (
<App
mode="documents"
routeFolderId={folderId}
routeDocumentId={documentId}
navigate={navigate}
/>
<AppShellContext.Provider value={contextValue}>
<div className="app-shell">
<DropOverlay
active={dropOverlayState.active}
folderName={dropOverlayState.folderName}
/>
<header className="app-bar">
<div className="app-bar__main">
<div className="app-bar__meta">
<h1>Paperless-NEO</h1>
<span className="app-bar__hint">
{previewActive
? 'Viewing document preview. Press ← Back to return to the library.'
: 'Drag files here to upload.'}
</span>
</div>
{status && (
<div className="app-bar__status">
<StatusBanner status={status} />
</div>
)}
<div className="app-bar__actions">
<button
className="secondary"
type="button"
onClick={handleBulkReanalyze}
>
Re-analyze All
</button>
<button className="secondary" onClick={handleLogout}>
Log out
</button>
</div>
</div>
</header>
<Outlet />
</div>
</AppShellContext.Provider>
);
};
const RoutedTagsApp = () => {
const navigate = useNavigate();
return <App mode="tags" navigate={navigate} />;
const useAppShell = () => {
const context = useContext(AppShellContext);
if (!context) {
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
}
return context;
};
const DocumentsRoute = () => {
const {
sidebarProps,
previewActive,
previewWorkspaceDocument,
previewWorkspaceDetail,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
documentsTableProps,
detailPanelProps,
} = useAppShell();
if (previewActive && previewWorkspaceDocument) {
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
detail={previewWorkspaceDetail}
previewEntry={previewWorkspaceEntry}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
}
return (
<MainLayout sidebarProps={sidebarProps}>
<DocumentsTable {...documentsTableProps} />
<DetailPanel {...detailPanelProps} />
</MainLayout>
);
};
const TagsRoute = () => {
const { sidebarProps, tags, refreshTags, handleTagUpdate } = useAppShell();
return (
<MainLayout sidebarProps={sidebarProps} className="tags-main">
<TagsPanel tags={tags} onRefresh={refreshTags} onUpdateTag={handleTagUpdate} />
</MainLayout>
);
};
const AppRouter = () => (
<Routes>
<Route path="/" element={<Navigate to="/folders" replace />} />
<Route path="/folders" element={<RoutedDocumentsApp />} />
<Route path="/folders/:folderId" element={<RoutedDocumentsApp />} />
<Route path="/documents/:documentId" element={<RoutedDocumentsApp />} />
<Route
path="/folders/:folderId/documents/:documentId"
element={<RoutedDocumentsApp />}
/>
<Route path="/tags" element={<RoutedTagsApp />} />
<Route path="*" element={<Navigate to="/folders" replace />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/folders" replace />} />
<Route path="/folders" element={<DocumentsRoute />} />
<Route path="/folders/:folderId" element={<DocumentsRoute />} />
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
<Route
path="/folders/:folderId/documents/:documentId"
element={<DocumentsRoute />}
/>
<Route path="/tags" element={<TagsRoute />} />
<Route path="*" element={<Navigate to="/folders" replace />} />
</Route>
</Routes>
);
+7 -3
View File
@@ -361,9 +361,13 @@ button.icon-button.ghost:hover:not([disabled]) {
gap: 0.4rem;
}
.tags-table__color-field {
width: 7rem;
font-family: var(--monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace);
.tags-table__color-picker {
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
background: none;
cursor: pointer;
}
.tags-table__edit-controls {