dockerfiles, TagsWorkspace
This commit is contained in:
@@ -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"]
|
||||
@@ -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;"]
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user