Compare commits

...

5 Commits

Author SHA1 Message Date
Corentin Thomasset
13889c1c42 wip 2025-06-29 15:28:17 +02:00
Corentin Thomasset
6cedc30716 chore(deps): updated dependencies (#379) 2025-06-24 20:52:15 +02:00
Corentin Thomasset
f1e1b4037b feat(tags): add color picker and swatches for tag creation (#378) 2025-06-24 20:27:58 +02:00
Corentin Thomasset
205c6cfd46 feat(preview): improved document preview for text-like files (#377) 2025-06-24 00:11:40 +02:00
Alex
c54a71d2c5 fix(tags): allow for uppercase tag color code (#346)
* Update tags.page.tsx

* Fixes 400 error when submitting tags with uppercase hex colour codes.

Fixes 400 error when submitting tags with uppercase hex colour codes.

* Update .changeset/few-toes-ask.md

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-06-19 11:45:06 +02:00
33 changed files with 1710 additions and 795 deletions

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Improve file preview for text-like files (.env, yaml, extension-less text files,...)

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Fixes 400 error when submitting tags with uppercase hex colour codes.

View File

@@ -0,0 +1,10 @@
---
"@papra/app-client": patch
"@papra/app-server": patch
"@papra/webhooks": patch
"@papra/api-sdk": patch
"@papra/cli": patch
"@papra/docs": patch
---
Updated dependencies

View File

@@ -0,0 +1,5 @@
---
"@papra/app-client": patch
---
Added tag color swatches and picker

View File

@@ -3,7 +3,7 @@
"type": "module",
"version": "0.5.1",
"private": true,
"packageManager": "pnpm@10.9.0",
"packageManager": "pnpm@10.12.3",
"description": "Papra documentation website",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",

View File

@@ -3,7 +3,7 @@
"type": "module",
"version": "0.6.3",
"private": true,
"packageManager": "pnpm@10.9.0",
"packageManager": "pnpm@10.12.3",
"description": "Papra frontend client",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -31,13 +31,13 @@
},
"dependencies": {
"@corentinth/chisels": "^1.3.1",
"@kobalte/core": "^0.13.9",
"@kobalte/core": "^0.13.10",
"@kobalte/utils": "^0.9.1",
"@modular-forms/solid": "^0.25.1",
"@pdfslick/solid": "^2.3.0",
"@solid-primitives/storage": "^4.3.2",
"@solidjs/router": "^0.14.10",
"@tanstack/solid-query": "^5.77.2",
"@tanstack/solid-query": "^5.81.2",
"@tanstack/solid-table": "^8.21.3",
"@unocss/reset": "^0.64.1",
"better-auth": "catalog:",
@@ -47,7 +47,7 @@
"date-fns": "^4.1.0",
"lodash-es": "^4.17.21",
"ofetch": "^1.4.1",
"posthog-js": "^1.246.0",
"posthog-js": "^1.255.1",
"radix3": "^1.1.2",
"solid-js": "^1.9.7",
"solid-sonner": "^0.2.8",
@@ -59,18 +59,18 @@
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"@iconify-json/tabler": "^1.2.18",
"@playwright/test": "^1.52.0",
"@iconify-json/tabler": "^1.2.19",
"@playwright/test": "^1.53.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "catalog:",
"eslint": "catalog:",
"jsdom": "^25.0.1",
"tinyglobby": "^0.2.14",
"tsx": "^4.19.4",
"tsx": "^4.20.3",
"typescript": "catalog:",
"unocss": "0.65.0-beta.2",
"vite": "^5.4.19",
"vite-plugin-solid": "^2.11.6",
"vite-plugin-solid": "^2.11.7",
"vitest": "catalog:",
"yaml": "^2.8.0"
}

View File

@@ -256,6 +256,9 @@ documents.deleted.deleted-at: Gelöscht
documents.deleted.restoring: Wiederherstellen...
documents.deleted.deleting: Löschen...
documents.preview.unknown-file-type: Kein Vorschau verfügbar für diesen Dateityp
documents.preview.binary-file: Dies scheint eine Binärdatei zu sein und kann nicht als Text angezeigt werden
trash.delete-all.button: Alles löschen
trash.delete-all.confirm.title: Alle Dokumente dauerhaft löschen?
trash.delete-all.confirm.description: Sind Sie sicher, dass Sie alle Dokumente aus dem Papierkorb dauerhaft löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.
@@ -306,7 +309,6 @@ tags.form.name.placeholder: Z.B. Verträge
tags.form.name.required: Bitte geben Sie einen Tag-Namen ein
tags.form.name.max-length: Tag-Name muss weniger als 64 Zeichen lang sein
tags.form.color.label: Farbe
tags.form.color.placeholder: 'Z.B. #FF0000'
tags.form.color.required: Bitte geben Sie eine Farbe ein
tags.form.color.invalid: Die Hex-Farbe ist falsch formatiert.
tags.form.description.label: Beschreibung
@@ -550,3 +552,11 @@ demo.popup.discord: Treten Sie dem {{ discordLink }} bei, um Support zu erhalten
demo.popup.discord-link-label: Discord-Server
demo.popup.reset: Demo-Daten zurücksetzen
demo.popup.hide: Ausblenden
# Color picker
color-picker.hue: Farbton
color-picker.saturation: Sättigung
color-picker.lightness: Helligkeit
color-picker.select-color: Farbe auswählen
color-picker.select-a-color: Eine Farbe auswählen

View File

@@ -256,6 +256,9 @@ documents.deleted.deleted-at: Deleted
documents.deleted.restoring: Restoring...
documents.deleted.deleting: Deleting...
documents.preview.unknown-file-type: No preview available for this file type
documents.preview.binary-file: This appears to be a binary file and cannot be displayed as text
trash.delete-all.button: Delete all
trash.delete-all.confirm.title: Permanently delete all documents?
trash.delete-all.confirm.description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
@@ -306,7 +309,6 @@ tags.form.name.placeholder: Eg. Contracts
tags.form.name.required: Please enter a tag name
tags.form.name.max-length: Tag name must be less than 64 characters
tags.form.color.label: Color
tags.form.color.placeholder: 'Eg. #FF0000'
tags.form.color.required: Please enter a color
tags.form.color.invalid: The hex color is badly formatted.
tags.form.description.label: Description
@@ -550,3 +552,11 @@ demo.popup.discord: Join the {{ discordLink }} to get support, propose features
demo.popup.discord-link-label: Discord server
demo.popup.reset: Reset demo data
demo.popup.hide: Hide
# Color picker
color-picker.hue: Hue
color-picker.saturation: Saturation
color-picker.lightness: Lightness
color-picker.select-color: Select color
color-picker.select-a-color: Select a color

View File

@@ -256,6 +256,9 @@ documents.deleted.deleted-at: Supprimé
documents.deleted.restoring: Restauration...
documents.deleted.deleting: Suppression...
documents.preview.unknown-file-type: Aucun aperçu disponible pour ce type de fichier
documents.preview.binary-file: Cela semble être un fichier binaire et ne peut pas être affiché en texte
trash.delete-all.button: Supprimer tous les documents
trash.delete-all.confirm.title: Supprimer définitivement tous les documents ?
trash.delete-all.confirm.description: Êtes-vous sûr de vouloir supprimer définitivement tous les documents de la corbeille ? Cette action est irréversible.
@@ -306,7 +309,6 @@ tags.form.name.placeholder: 'Exemple: Contrats'
tags.form.name.required: Veuillez entrer un nom pour le tag
tags.form.name.max-length: Le nom du tag doit contenir moins de 64 caractères
tags.form.color.label: Couleur
tags.form.color.placeholder: 'Exemple: #FF0000'
tags.form.color.required: Veuillez entrer une couleur
tags.form.color.invalid: La couleur hexadécimale est mal formatée.
tags.form.description.label: Description
@@ -550,3 +552,11 @@ demo.popup.discord: Rejoignez le {{ discordLink }} pour obtenir de l'aide, propo
demo.popup.discord-link-label: Serveur Discord
demo.popup.reset: Réinitialiser la démo
demo.popup.hide: Masquer
# Color picker
color-picker.hue: Teinte
color-picker.saturation: Saturation
color-picker.lightness: Luminosité
color-picker.select-color: Sélectionner la couleur
color-picker.select-a-color: Sélectionner une couleur

View File

@@ -2,13 +2,14 @@ import type { Component } from 'solid-js';
import type { Document } from '../documents.types';
import { useQuery } from '@tanstack/solid-query';
import { createResource, Match, Suspense, Switch } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Card } from '@/modules/ui/components/card';
import { fetchDocumentFile } from '../documents.services';
import { PdfViewer } from './pdf-viewer.component';
const imageMimeType = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const pdfMimeType = ['application/pdf'];
const txtLikeMimeType = ['text/plain', 'text/markdown', 'text/csv', 'text/html'];
const txtLikeMimeType = ['application/x-yaml', 'application/json', 'application/xml'];
function blobToString(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
@@ -19,6 +20,83 @@ function blobToString(blob: Blob): Promise<string> {
});
}
/**
* TODO: IA generated code, add some tests
* Detects if a blob can be safely displayed as text by checking for valid UTF-8 encoding
* and common text patterns (low ratio of control characters, presence of readable text)
*/
async function isBlobTextSafe(blob: Blob): Promise<boolean> {
try {
const text = await blobToString(blob);
// Check if the text contains mostly printable characters
const totalChars = text.length;
if (totalChars === 0) {
return true;
} // Empty files are considered text-safe
// Count control characters (excluding common whitespace and newlines)
// Use a simpler approach to avoid linter issues with Unicode escapes
let controlCharCount = 0;
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
// Check for control characters (0-31, 127-159) excluding common whitespace
if ((charCode >= 0 && charCode <= 31 && ![9, 10, 13, 12, 11].includes(charCode))
|| (charCode >= 127 && charCode <= 159)) {
controlCharCount++;
}
}
// If more than 10% of characters are control characters, it's likely binary
const controlCharRatio = controlCharCount / totalChars;
if (controlCharRatio > 0.1) {
return false;
}
// Check for common binary file signatures in the first few bytes
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// Common binary file signatures to check
const binarySignatures = [
[0xFF, 0xD8, 0xFF], // JPEG
[0x89, 0x50, 0x4E, 0x47], // PNG
[0x47, 0x49, 0x46], // GIF
[0x25, 0x50, 0x44, 0x46], // PDF
[0x50, 0x4B, 0x03, 0x04], // ZIP/DOCX/XLSX
[0x7F, 0x45, 0x4C, 0x46], // ELF executable
[0x4D, 0x5A], // Windows executable
];
for (const signature of binarySignatures) {
if (uint8Array.length >= signature.length) {
const matches = signature.every((byte, index) => uint8Array[index] === byte);
if (matches) {
return false;
}
}
}
// Check if the text contains mostly ASCII printable characters
let asciiPrintableCount = 0;
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
// ASCII printable characters (32-126) excluding common whitespace
if (charCode >= 32 && charCode <= 126 && ![9, 10, 13, 12, 11].includes(charCode)) {
asciiPrintableCount++;
}
}
const asciiRatio = asciiPrintableCount / totalChars;
// If less than 70% are ASCII printable, it's likely binary
return asciiRatio > 0.7;
} catch {
// If we can't read as text, it's definitely not text-safe
return false;
}
}
const TextFromBlob: Component<{ blob: Blob }> = (props) => {
const [txt] = createResource(() => blobToString(props.blob));
@@ -34,12 +112,25 @@ const TextFromBlob: Component<{ blob: Blob }> = (props) => {
export const DocumentPreview: Component<{ document: Document }> = (props) => {
const getIsImage = () => imageMimeType.includes(props.document.mimeType);
const getIsPdf = () => pdfMimeType.includes(props.document.mimeType);
const getIsTxtLike = () => txtLikeMimeType.includes(props.document.mimeType) || props.document.mimeType.startsWith('text/');
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['organizations', props.document.organizationId, 'documents', props.document.id, 'file'],
queryFn: () => fetchDocumentFile({ documentId: props.document.id, organizationId: props.document.organizationId }),
}));
// Create a resource to check if octet-stream blob is text-safe
const [isOctetStreamTextSafe] = createResource(
() => query.data && props.document.mimeType === 'application/octet-stream' ? query.data : null,
async (blob) => {
if (!blob) {
return false;
}
return await isBlobTextSafe(blob);
},
);
return (
<Suspense>
<Switch>
@@ -48,12 +139,30 @@ export const DocumentPreview: Component<{ document: Document }> = (props) => {
<img src={URL.createObjectURL(query.data!)} class="w-full h-full object-contain" />
</div>
</Match>
<Match when={getIsPdf() && query.data}>
<PdfViewer url={URL.createObjectURL(query.data!)} />
</Match>
<Match when={txtLikeMimeType.includes(props.document.mimeType) && query.data}>
<Match when={getIsTxtLike() && query.data}>
<TextFromBlob blob={query.data!} />
</Match>
<Match when={props.document.mimeType === 'application/octet-stream' && query.data && isOctetStreamTextSafe()}>
<TextFromBlob blob={query.data!} />
</Match>
<Match when={props.document.mimeType === 'application/octet-stream' && query.data && !isOctetStreamTextSafe()}>
<Card class="px-6 py-12 text-center text-sm text-muted-foreground">
<p>{t('documents.preview.binary-file')}</p>
</Card>
</Match>
<Match when={query.data}>
<Card class="px-6 py-12 text-center text-sm text-muted-foreground">
<p>{t('documents.preview.unknown-file-type')}</p>
</Card>
</Match>
</Switch>
</Suspense>
);

View File

@@ -229,6 +229,8 @@ export type LocaleKeys =
| 'documents.deleted.deleted-at'
| 'documents.deleted.restoring'
| 'documents.deleted.deleting'
| 'documents.preview.unknown-file-type'
| 'documents.preview.binary-file'
| 'trash.delete-all.button'
| 'trash.delete-all.confirm.title'
| 'trash.delete-all.confirm.description'
@@ -272,7 +274,6 @@ export type LocaleKeys =
| 'tags.form.name.required'
| 'tags.form.name.max-length'
| 'tags.form.color.label'
| 'tags.form.color.placeholder'
| 'tags.form.color.required'
| 'tags.form.color.invalid'
| 'tags.form.description.label'
@@ -484,4 +485,9 @@ export type LocaleKeys =
| 'demo.popup.discord'
| 'demo.popup.discord-link-label'
| 'demo.popup.reset'
| 'demo.popup.hide';
| 'demo.popup.hide'
| 'color-picker.hue'
| 'color-picker.saturation'
| 'color-picker.lightness'
| 'color-picker.select-color'
| 'color-picker.select-a-color';

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { getRgbChannelsFromHex } from './color-formats';
describe('color-formats', () => {
describe('getRgbChannelsFromHex', () => {
test('extracts the rgb channels values from a hex color', () => {
expect(getRgbChannelsFromHex('#000000')).toEqual({ r: 0, g: 0, b: 0 });
expect(getRgbChannelsFromHex('#FFFFFF')).toEqual({ r: 255, g: 255, b: 255 });
expect(getRgbChannelsFromHex('#FF0000')).toEqual({ r: 255, g: 0, b: 0 });
expect(getRgbChannelsFromHex('#00FF00')).toEqual({ r: 0, g: 255, b: 0 });
expect(getRgbChannelsFromHex('#0000FF')).toEqual({ r: 0, g: 0, b: 255 });
expect(getRgbChannelsFromHex('#0000FF')).toEqual({ r: 0, g: 0, b: 255 });
});
test('is case insensitive', () => {
expect(getRgbChannelsFromHex('#ff0000')).toEqual({ r: 255, g: 0, b: 0 });
expect(getRgbChannelsFromHex('#00ff00')).toEqual({ r: 0, g: 255, b: 0 });
expect(getRgbChannelsFromHex('#0000ff')).toEqual({ r: 0, g: 0, b: 255 });
});
test('returns 0, 0, 0 for invalid colors', () => {
expect(getRgbChannelsFromHex('lorem')).toEqual({ r: 0, g: 0, b: 0 });
});
});
});

View File

@@ -0,0 +1,5 @@
export function getRgbChannelsFromHex(color: string) {
const [r, g, b] = color.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i)?.slice(1).map(c => Number.parseInt(c, 16)) ?? [0, 0, 0];
return { r, g, b };
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, test } from 'vitest';
import { getLuminance } from './luminance';
describe('luminance', () => {
describe('getLuminance', () => {
test(`the relative luminance of a color is the relative brightness of any point in a color space, normalized to 0 for darkest black and 1 for lightest white
the formula is: 0.2126 * R + 0.7152 * G + 0.0722 * B
where R, G, B are the red, green, and blue channels of the color, normalized to 0-1 and gamma corrected (sRGB):
if the channel value is less than 0.03928, it is divided by 12.92, otherwise it is raised to the power of 2.4
Source: https://www.w3.org/TR/WCAG20/#relativeluminancedef
`, () => {
expect(getLuminance('#000000')).toBe(0);
expect(getLuminance('#FFFFFF')).toBe(1);
expect(getLuminance('#FF0000')).toBeCloseTo(0.2126, 4);
expect(getLuminance('#00FF00')).toBeCloseTo(0.7152, 4);
expect(getLuminance('#0000FF')).toBeCloseTo(0.0722, 4);
});
});
});

View File

@@ -0,0 +1,17 @@
import { getRgbChannelsFromHex } from './color-formats';
// https://www.w3.org/TR/WCAG20/#relativeluminancedef
export function getLuminance(color: string) {
const { r, g, b } = getRgbChannelsFromHex(color);
const toLinear = (channelValue: number) => {
const normalized = channelValue / 255;
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
};
const R = toLinear(r);
const G = toLinear(g);
const B = toLinear(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}

View File

@@ -2,7 +2,7 @@ import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import type { Tag as TagType } from '../tags.types';
import { safely } from '@corentinth/chisels';
import { getValues } from '@modular-forms/solid';
import { getValues, setValue } from '@modular-forms/solid';
import { A, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, For, Show, Suspense } from 'solid-js';
@@ -14,6 +14,7 @@ import { createForm } from '@/modules/shared/form/form';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { ColorSwatchPicker } from '@/modules/ui/components/color-swatch-picker';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
import { EmptyState } from '@/modules/ui/components/empty';
import { createToast } from '@/modules/ui/components/sonner';
@@ -23,6 +24,26 @@ import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/component
import { Tag } from '../components/tag.component';
import { createTag, deleteTag, fetchTags, updateTag } from '../tags.services';
// To keep, useful for generating swatches
// function generateSwatches(count = 9, saturation = 100, lightness = 74) {
// const colors = [];
// for (let i = 0; i < count; i++) {
// const hue = Math.round((78 + i * 360 / count) % 360);
// const hsl = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
// colors.push(parseColor(hsl).toString('hex').toUpperCase());
// }
// return colors;
// }
const defaultColors = ['#D8FF75', '#7FFF7A', '#7AFFCE', '#7AD7FF', '#7A7FFF', '#CE7AFF', '#FF7AD7', '#FF7A7F', '#FFCE7A', '#FFFFFF'];
const TagColorPicker: Component<{
color: string;
onChange: (color: string) => void;
}> = (props) => {
return <ColorSwatchPicker value={props.color} onChange={props.onChange} colors={defaultColors} />;
};
const TagForm: Component<{
onSubmit: (values: { name: string; color: string; description: string }) => Promise<void>;
initialValues?: { name?: string; color?: string; description?: string | null };
@@ -71,10 +92,10 @@ const TagForm: Component<{
</Field>
<Field name="color">
{(field, inputProps) => (
{field => (
<TextFieldRoot class="flex flex-col gap-1 mb-4">
<TextFieldLabel for="color">{t('tags.form.color.label')}</TextFieldLabel>
<TextField id="color" {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} placeholder={t('tags.form.color.placeholder')} />
<TagColorPicker color={field.value ?? ''} onChange={color => setValue(form, 'color', color)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
@@ -119,7 +140,7 @@ export const CreateTagModal: Component<{
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
const [,error] = await safely(createTag({
name,
color,
color: color.toLowerCase(),
description,
organizationId: props.organizationId,
}));
@@ -153,7 +174,7 @@ export const CreateTagModal: Component<{
<DialogTitle>{t('tags.create')}</DialogTitle>
</DialogHeader>
<TagForm onSubmit={onSubmit} initialValues={{ color: '#d8ff75' }} />
<TagForm onSubmit={onSubmit} initialValues={{ color: '#D8FF75' }} />
</DialogContent>
</Dialog>
);
@@ -170,7 +191,7 @@ const UpdateTagModal: Component<{
const onSubmit = async ({ name, color, description }: { name: string; color: string; description: string }) => {
await updateTag({
name,
color,
color: color.toLowerCase(),
description,
organizationId: props.organizationId,
tagId: props.tag.id,

View File

@@ -0,0 +1,178 @@
import type { Color } from '@kobalte/core/colors';
import type { VariantProps } from 'class-variance-authority';
import type { Component, ParentProps } from 'solid-js';
import { ColorSlider } from '@kobalte/core/color-slider';
import { parseColor } from '@kobalte/core/colors';
import { cva } from 'class-variance-authority';
import { createSignal, For, splitProps } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { getLuminance } from '@/modules/shared/colors/luminance';
import { cn } from '@/modules/shared/style/cn';
import { Button } from './button';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { TextField, TextFieldRoot } from './textfield';
const Slider: Component<{
channel: 'hue' | 'saturation' | 'lightness';
label: string;
value: Color;
onChange?: (value: Color) => void;
}> = (props) => {
return (
<ColorSlider channel={props.channel} class="relative flex flex-col gap-0.5 w-full" value={props.value} onChange={props.onChange}>
<div class="flex items-center justify-between text-xs font-medium text-muted-foreground">
<ColorSlider.Label>{props.label}</ColorSlider.Label>
<ColorSlider.ValueLabel />
</div>
<ColorSlider.Track class="w-full h-24px rounded relative ">
<ColorSlider.Thumb class="w-4 h-4 top-4px rounded-full bg-[var(--kb-color-current)] border-2 border-#0a0a0a">
<ColorSlider.Input />
</ColorSlider.Thumb>
</ColorSlider.Track>
</ColorSlider>
);
};
const ColorPicker: Component<{
color: string;
onChange?: (color: string) => void;
}> = (props) => {
const { t } = useI18n();
const [color, setColor] = createSignal<Color>(parseColor(props.color).toFormat('hsl'));
const onUpdateColor = (color: Color) => {
setColor(color.toFormat('hsl'));
props.onChange?.(color.toString('hex').toUpperCase());
};
const onInputColorChange = (e: Event) => {
const color = (e.target as HTMLInputElement).value;
try {
const parsedColor = parseColor(color);
onUpdateColor(parsedColor);
} catch (_error) {
}
};
return (
<div class="flex flex-col gap-2">
<Slider channel="hue" label={t('color-picker.hue')} value={color()} onChange={onUpdateColor} />
<Slider channel="saturation" label={t('color-picker.saturation')} value={color()} onChange={onUpdateColor} />
<Slider channel="lightness" label={t('color-picker.lightness')} value={color()} onChange={onUpdateColor} />
<TextFieldRoot>
<TextField value={color().toString('hex').toUpperCase()} onInput={onInputColorChange} placeholder="#000000" />
</TextFieldRoot>
</div>
);
};
export const colorSwatchVariants = cva(
'rounded-lg border-2 border-background shadow-sm transition-all hover:scale-110 focus-visible:(outline-none ring-1.5 ring-ring ring-offset-1)',
{
variants: {
size: {
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-10 w-10',
},
selected: {
true: 'ring-1.5 ring-primary! ring-offset-1',
false: '',
},
},
defaultVariants: {
size: 'md',
selected: false,
},
},
);
type ColorSwatchPickerProps = ParentProps<{
value?: string;
onChange?: (color: string) => void;
colors?: string[];
size?: VariantProps<typeof colorSwatchVariants>['size'];
class?: string;
disabled?: boolean;
}>;
export function ColorSwatchPicker(props: ColorSwatchPickerProps) {
const { t } = useI18n();
const [local, rest] = splitProps(props, [
'value',
'onChange',
'colors',
'size',
'class',
'disabled',
'children',
]);
const colors = () => local.colors ?? [];
const selectedColor = () => local.value ?? colors()[0];
const handleColorSelect = (color: string) => {
if (!local.disabled && local.onChange) {
local.onChange(color);
}
};
const getIsNotInSwatch = (color?: string) => color && !colors().includes(color);
function getContrastTextColor(color: string) {
const luminance = getLuminance(color);
// 0.179 is the threshold for WCAG 2.0 level AA
return luminance > 0.179 ? 'black' : 'white';
}
return (
<div
class={cn(
'inline-flex items-center gap-1 flex-wrap',
local.disabled && 'opacity-50 cursor-not-allowed',
local.class,
)}
{...rest}
>
<For each={colors()}>
{color => (
<button
type="button"
class={cn(
colorSwatchVariants({
size: local.size,
selected: selectedColor() === color,
}),
)}
style={{ 'background-color': color }}
onClick={() => handleColorSelect(color)}
disabled={local.disabled}
aria-label={`${t('color-picker.select-color')} ${color}`}
title={color}
/>
)}
</For>
<Popover>
<PopoverTrigger
as={Button}
variant="outline"
size="icon"
class={cn(getIsNotInSwatch(local.value) && 'ring-1.5 ring-primary! ring-offset-1')}
style={{ 'background-color': getIsNotInSwatch(local.value) ? local.value : '' }}
aria-label={t('color-picker.select-a-color')}
>
<div class="i-tabler-plus size-4" style={{ color: getIsNotInSwatch(local.value) ? getContrastTextColor(local.value ?? '') : undefined }}></div>
</PopoverTrigger>
<PopoverContent>
<p class="text-sm font-medium mb-4">{t('color-picker.select-a-color')}</p>
<ColorPicker color={local.value ?? ''} onChange={local?.onChange} />
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -3,7 +3,7 @@
"type": "module",
"version": "0.6.3",
"private": true,
"packageManager": "pnpm@10.9.0",
"packageManager": "pnpm@10.12.3",
"description": "Papra app server",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",
@@ -30,21 +30,21 @@
"stripe:webhook": "stripe listen --forward-to localhost:1221/api/stripe/webhook"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.817.0",
"@aws-sdk/lib-storage": "^3.817.0",
"@aws-sdk/client-s3": "^3.835.0",
"@aws-sdk/lib-storage": "^3.835.0",
"@azure/storage-blob": "^12.27.0",
"@corentinth/chisels": "^1.3.1",
"@corentinth/friendly-ids": "^0.0.1",
"@crowlog/async-context-plugin": "^1.2.1",
"@crowlog/logger": "^1.2.1",
"@hono/node-server": "^1.14.3",
"@hono/node-server": "^1.14.4",
"@libsql/client": "^0.14.0",
"@owlrelay/api-sdk": "^0.0.2",
"@owlrelay/webhook": "^0.0.3",
"@papra/lecture": "^0.0.4",
"@papra/webhooks": "workspace:*",
"@paralleldrive/cuid2": "^2.2.2",
"backblaze-b2": "^1.7.0",
"backblaze-b2": "^1.7.1",
"better-auth": "catalog:",
"c12": "^3.0.4",
"chokidar": "^4.0.3",
@@ -52,7 +52,7 @@
"drizzle-kit": "^0.30.6",
"drizzle-orm": "^0.38.4",
"figue": "^2.2.3",
"hono": "^4.7.10",
"hono": "^4.8.2",
"lodash-es": "^4.17.21",
"mime-types": "^3.0.1",
"nanoid": "^5.1.5",
@@ -61,12 +61,12 @@
"p-limit": "^6.2.0",
"p-queue": "^8.1.0",
"picomatch": "^4.0.2",
"posthog-node": "^4.17.2",
"resend": "^4.5.1",
"posthog-node": "^4.18.0",
"resend": "^4.6.0",
"sanitize-html": "^2.17.0",
"stripe": "^17.7.0",
"tsx": "^4.19.4",
"zod": "^3.25.28"
"tsx": "^4.20.3",
"zod": "^3.25.67"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",

View File

@@ -0,0 +1,4 @@
export const DOCUMENTS_REQUESTS_ID_PREFIX = 'dr';
export const DOCUMENTS_REQUESTS_FILES_ID_PREFIX = 'dr_files';
export const DOCUMENTS_REQUESTS_FILE_TAGS_ID_PREFIX = 'dr_file_tags';
export const DOCUMENTS_REQUESTS_TOKEN_LENGTH = 32;

View File

@@ -0,0 +1,76 @@
import type { Database } from '../app/database/database.types';
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
import { randomBytes } from 'node:crypto';
import { injectArguments } from '@corentinth/chisels';
import { generateId } from '../shared/random/ids';
import { DOCUMENTS_REQUESTS_FILES_ID_PREFIX, DOCUMENTS_REQUESTS_ID_PREFIX } from './documents-requests.constants';
import { documentsRequestsFilesTable, documentsRequestsFileTagsTable, documentsRequestsTable } from './documents-requests.tables';
export function createDocumentsRequestsRepository({ db }: { db: Database }) {
return injectArguments(
{
createDocumentsRequest,
},
{
db,
},
);
}
async function createDocumentsRequest({
documentsRequest,
files,
db,
}: {
documentsRequest: {
token: string;
organizationId: string;
createdBy: string | null;
title: string;
description?: string;
useLimit?: number;
expiresAt?: Date;
accessLevel: DocumentsRequestAccessLevel;
isEnabled?: boolean;
};
files: {
title: string;
description?: string;
allowedMimeTypes: string[];
sizeLimit?: number;
tags: string[];
}[];
db: Database;
}) {
const [createdDocumentsRequest] = await db
.insert(documentsRequestsTable)
.values(documentsRequest)
.returning();
for (const file of files) {
const [createdFile] = await db
.insert(documentsRequestsFilesTable)
.values({
documentsRequestId: createdDocumentsRequest.id,
title: file.title,
description: file.description,
allowedMimeTypes: file.allowedMimeTypes,
sizeLimit: file.sizeLimit,
})
.returning();
for (const tag of file.tags) {
await db
.insert(documentsRequestsFileTagsTable)
.values({
documentsRequestId: createdDocumentsRequest.id,
fileId: createdFile.id,
tagId: tag,
});
}
}
return { documentsRequest: createdDocumentsRequest };
}

View File

@@ -0,0 +1,68 @@
import type { RouteDefinitionContext } from '../app/server.types';
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { organizationIdSchema } from '../organizations/organization.schemas';
import { createOrganizationsRepository } from '../organizations/organizations.repository';
import { ensureUserIsInOrganization } from '../organizations/organizations.usecases';
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { tagIdSchema } from '../tags/tags.schemas';
import { createDocumentsRequestsRepository } from './documents-requests.repository';
import { createDocumentsRequest } from './documents-requests.usecases';
export function registerDocumentsRequestsRoutes(context: RouteDefinitionContext) {
setupCreateDocumentsRequestRoute(context);
}
function setupCreateDocumentsRequestRoute({ app, db }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/documents-requests',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
validateJsonBody(z.object({
title: z.string().min(1).max(100),
description: z.string().max(512).optional(),
useLimit: z.number().positive().optional(),
expiresAt: z.date().optional(),
accessLevel: z.enum(['organization_members', 'authenticated_users', 'public'] as const),
isEnabled: z.boolean().optional().default(true),
files: z.array(z.object({
title: z.string().min(1).max(100),
description: z.string().max(512).optional(),
allowedMimeTypes: z.array(z.string()).optional().default(['*/*']),
sizeLimit: z.number().positive().optional(),
tags: z.array(tagIdSchema).optional().default([]),
})).min(1).max(32),
})),
async (context) => {
const { userId } = getUser({ context });
const { organizationId } = context.req.valid('param');
const { title, description, useLimit, expiresAt, accessLevel, isEnabled, files } = context.req.valid('json');
const organizationsRepository = createOrganizationsRepository({ db });
const documentsRequestsRepository = createDocumentsRequestsRepository({ db });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
const { documentsRequest } = await createDocumentsRequest({
organizationId,
createdBy: userId,
title,
description,
useLimit,
expiresAt,
accessLevel: accessLevel as DocumentsRequestAccessLevel,
isEnabled,
documentsRequestsRepository,
files,
});
return context.json({
documentsRequest,
});
},
);
}

View File

@@ -0,0 +1,8 @@
import { generateToken } from '../shared/random/random.services';
import { DOCUMENTS_REQUESTS_TOKEN_LENGTH } from './documents-requests.constants';
export function generateDocumentsRequestToken() {
const { token } = generateToken({ length: DOCUMENTS_REQUESTS_TOKEN_LENGTH });
return { token };
}

View File

@@ -0,0 +1,59 @@
Here's the complete specification for implementing the **Document Request Feature** in Papra, including the required database schema adjustments:
## Feature Overview
The **Document Request Feature** allows Papra users to create links through which others can upload documents directly into an organization's document archive. These links can be configured for multiple specific file types, restricted to one-time or multiple uses, pre-assigned tags per file type, and configured access levels (organization members only, authenticated users, or public access).
---
## Functional Requirements
### 1. Creating a Document Request
Users should be able to configure the following when creating a request:
* **Title**: Descriptive title for the request (e.g., "Quarterly Reports Submission").
* **Description** (optional): Brief context/instructions.
* **File Types Configuration**: Define multiple specific file types that can be uploaded:
* **File Title**: Descriptive name for each file type (e.g., "Financial Report", "Supporting Documents").
* **File Description** (optional): Specific instructions for each file type.
* **Allowed MIME Types**: Specify accepted file formats (e.g., `['application/pdf', 'image/jpeg']` or `['*/*']` for all types).
* **Size Limit** (optional): Maximum file size in bytes for each file type.
* **Predefined Tags**: Tags automatically applied to uploaded documents of this specific file type.
* **Use Limit**:
* Single-use: Link is valid for only one submission.
* Multi-use: Link allows multiple submissions.
* Unlimited submissions option (toggle on/off).
* **Expiration Date** (optional): Request becomes invalid after a specific date.
* **Access Restrictions**:
* **Org Members Only**: Only current organization members can submit.
* **Authenticated Users**: Any logged-in user can submit.
* **Public Access**: Anyone with the link can submit.
### 2. Document Upload via Request Link
When a recipient accesses the link:
* They see the request details (title, description, required file types).
* If access restricted, validation occurs based on the specified type.
* User uploads documents for each configured file type:
* Each file type shows its specific title, description, and requirements.
* Files are validated against the configured MIME types and size limits.
* Users can see which tags will be automatically applied to each file type.
* Documents are tagged automatically based on predefined tags for each file type.
### 3. Managing Requests
* Creator can view active/inactive requests.
* Creator can disable, edit, or delete a request (deletion/archive keeps submitted docs intact).
* Creator can modify file type configurations, including adding/removing file types.
### 4. Notifications & Tracking
* Optional notifications via email or app notifications upon document upload.
* Request creator receives updates about submissions.
## Conclusion
This spec outlines a robust document request feature with multi-file type support, integrating smoothly with Papra's existing architecture, providing flexibility, security, and ease-of-use, fulfilling both individual and organizational needs effectively.

View File

@@ -0,0 +1,45 @@
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { organizationsTable } from '../organizations/organizations.table';
import { createPrimaryKeyField, createTimestampColumns } from '../shared/db/columns.helpers';
import { tagsTable } from '../tags/tags.table';
import { usersTable } from '../users/users.table';
import { DOCUMENTS_REQUESTS_FILE_TAGS_ID_PREFIX, DOCUMENTS_REQUESTS_FILES_ID_PREFIX, DOCUMENTS_REQUESTS_ID_PREFIX } from './documents-requests.constants';
export const documentsRequestsTable = sqliteTable('documents_requests', {
...createPrimaryKeyField({ prefix: DOCUMENTS_REQUESTS_ID_PREFIX }),
...createTimestampColumns(),
token: text('token').notNull().unique(),
organizationId: text('organization_id').notNull().references(() => organizationsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
createdBy: text('created_by').references(() => usersTable.id, { onDelete: 'set null', onUpdate: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
useLimit: integer('use_limit').default(1), // null means unlimited
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
accessLevel: text('access_level').notNull().$type<DocumentsRequestAccessLevel>().default('organization_members'),
isEnabled: integer('is_enabled', { mode: 'boolean' }).notNull().default(true),
});
// To store the files that are allowed to be uploaded to the documents request
export const documentsRequestsFilesTable = sqliteTable('documents_requests_files', {
...createPrimaryKeyField({ prefix: DOCUMENTS_REQUESTS_FILES_ID_PREFIX }),
...createTimestampColumns(),
documentsRequestId: text('documents_request_id').notNull().references(() => documentsRequestsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
allowedMimeTypes: text('allowed_mime_types', { mode: 'json' }).notNull().$type<string[]>().default(['*/*']),
sizeLimit: integer('size_limit'), // null for no limit
});
export const documentsRequestsFileTagsTable = sqliteTable('documents_requests_file_tags', {
...createPrimaryKeyField({ prefix: DOCUMENTS_REQUESTS_FILE_TAGS_ID_PREFIX }),
...createTimestampColumns(),
documentsRequestId: text('documents_request_id').notNull().references(() => documentsRequestsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
fileId: text('file_id').notNull().references(() => documentsRequestsFilesTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
tagId: text('tag_id').notNull().references(() => tagsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
});

View File

@@ -0,0 +1 @@
export type DocumentsRequestAccessLevel = 'organization_members' | 'authenticated_users' | 'public';

View File

@@ -0,0 +1,55 @@
import type { DocumentsRequestAccessLevel } from './documents-requests.types';
import { generateDocumentsRequestToken } from './documents-requests.services';
export type DocumentsRequestsRepository = ReturnType<typeof import('./documents-requests.repository').createDocumentsRequestsRepository>;
export async function createDocumentsRequest({
organizationId,
createdBy,
title,
description,
useLimit,
expiresAt,
accessLevel,
isEnabled,
documentsRequestsRepository,
files,
generateToken = generateDocumentsRequestToken,
}: {
organizationId: string;
createdBy: string | null;
title: string;
description?: string;
useLimit?: number;
expiresAt?: Date;
accessLevel: DocumentsRequestAccessLevel;
isEnabled?: boolean;
documentsRequestsRepository: DocumentsRequestsRepository;
files: {
title: string;
description?: string;
allowedMimeTypes: string[];
sizeLimit?: number;
tags: string[];
}[];
generateToken?: () => { token: string };
}) {
const { token } = generateToken();
const { documentsRequest } = await documentsRequestsRepository.createDocumentsRequest({
documentsRequest: {
token,
organizationId,
createdBy,
title,
description,
useLimit,
expiresAt,
accessLevel,
isEnabled,
},
files,
});
return { documentsRequest };
}

View File

@@ -4,7 +4,7 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@10.9.0 --activate
RUN corepack prepare pnpm@10.12.3 --activate
# Build stage
FROM base AS build

View File

@@ -4,7 +4,7 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@10.9.0 --activate
RUN corepack prepare pnpm@10.12.3 --activate
# Build stage
FROM base AS build

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/root",
"version": "0.3.0",
"packageManager": "pnpm@10.9.0",
"packageManager": "pnpm@10.12.3",
"description": "Papra document management monorepo root",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",

View File

@@ -2,7 +2,7 @@
"name": "@papra/api-sdk",
"type": "module",
"version": "1.0.1",
"packageManager": "pnpm@10.9.0",
"packageManager": "pnpm@10.12.3",
"description": "Api SDK for Papra, the document archiving platform.",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",

View File

@@ -2,7 +2,7 @@
"name": "@papra/cli",
"type": "module",
"version": "0.0.1",
"packageManager": "pnpm@10.9.0",
"packageManager": "pnpm@10.12.3",
"description": "Command line interface for Papra, the document archiving platform.",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",

View File

@@ -2,7 +2,7 @@
"name": "@papra/webhooks",
"type": "module",
"version": "0.1.0",
"packageManager": "pnpm@10.9.0",
"packageManager": "pnpm@10.12.3",
"description": "Webhooks helper library for Papra, the document archiving platform.",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later",

1677
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff