Skip to content

Commit

Permalink
feat(note): share file assets
Browse files Browse the repository at this point in the history
  • Loading branch information
CorentinTh committed Sep 8, 2024
1 parent 1181fa6 commit 3de8416
Show file tree
Hide file tree
Showing 51 changed files with 1,380 additions and 594 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ logs

.wrangler
coverage
cache
cache
.zed
2 changes: 2 additions & 0 deletions packages/app-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@corentinth/chisels": "^1.0.2",
"@enclosed/lib": "workspace:*",
"@kobalte/core": "^0.13.4",
"@solidjs/router": "^0.14.3",
"@unocss/reset": "^0.62.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"solid-js": "^1.8.11",
"tailwind-merge": "^2.5.2",
Expand Down
82 changes: 82 additions & 0 deletions packages/app-client/src/modules/files/files.models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { values } from 'lodash-es';
import { describe, expect, test } from 'vitest';
import { icons as tablerIconSet } from '@iconify-json/tabler';
import { getFileIcon, iconByFileType } from './files.models';

describe('files models', () => {
describe('iconByFileType', () => {
const icons = values(iconByFileType);

test('they must at least have the default icon', () => {
expect(iconByFileType['*']).toBeDefined();
});

test('all the icons should be from tabler icon set', () => {
for (const icon of icons) {
expect(icon).to.match(/^i-tabler-/, `Icon ${icon} is not from tabler icon set`);
}
});

test('icons should not contain any spaces', () => {
for (const icon of icons) {
expect(icon).not.to.match(/\s/, `Icon ${icon} contains spaces`);
}
});

test('the icons used for showing file types should exists with current iconify configuration', () => {
for (const icon of icons) {
const iconName = icon.replace('i-tabler-', '');
const iconData = tablerIconSet.icons[iconName] ?? tablerIconSet.aliases?.[iconName];

expect(iconData).to.not.eql(undefined, `Icon ${icon} does not exist in tabler icon set`);
}
});
});

describe('getFileIcon', () => {
test('a file icon is selected based on the file type', () => {
const file = new File([''], 'test.txt', { type: 'text/plain' });
const iconsMap = {
'*': 'i-tabler-file',
'text/plain': 'i-tabler-file-text',
};
const icon = getFileIcon({ file, iconsMap });

expect(icon).to.eql('i-tabler-file-text');
});

test('if a file type is not associated with an icon, the default icon is used', () => {
const file = new File([''], 'test.txt', { type: 'text/html' });
const iconsMap = {
'*': 'i-tabler-file',
'text/plain': 'i-tabler-file-text',
};
const icon = getFileIcon({ file, iconsMap });

expect(icon).to.eql('i-tabler-file');
});

test('a file icon can be selected based on the file type group', () => {
const file = new File([''], 'test.html', { type: 'text/html' });
const iconsMap = {
'*': 'i-tabler-file',
'text': 'i-tabler-file-text',
};
const icon = getFileIcon({ file, iconsMap });

expect(icon).to.eql('i-tabler-file-text');
});

test('when an icon is defined for both the whole type and the group type, the file type icon is used', () => {
const file = new File([''], 'test.html', { type: 'text/html' });
const iconsMap = {
'*': 'i-tabler-file',
'text': 'i-tabler-file-text',
'text/html': 'i-tabler-file-type-html',
};
const icon = getFileIcon({ file, iconsMap });

expect(icon).to.eql('i-tabler-file-type-html');
});
});
});
42 changes: 42 additions & 0 deletions packages/app-client/src/modules/files/files.models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export { getFileIcon };

// Available icons :
// i-tabler-file-3d i-tabler-file-ai i-tabler-file-alert i-tabler-file-analytics i-tabler-file-arrow-left i-tabler-file-arrow-right i-tabler-file-barcode i-tabler-file-bitcoin i-tabler-file-broken i-tabler-file-certificate i-tabler-file-chart i-tabler-file-check i-tabler-file-code i-tabler-file-code-2 i-tabler-file-cv i-tabler-file-database i-tabler-file-delta i-tabler-file-description i-tabler-file-diff i-tabler-file-digit i-tabler-file-dislike i-tabler-file-dollar i-tabler-file-dots i-tabler-file-download i-tabler-file-euro i-tabler-file-excel i-tabler-file-export i-tabler-file-filled i-tabler-file-function i-tabler-file-horizontal i-tabler-file-import i-tabler-file-infinity i-tabler-file-info i-tabler-file-invoice i-tabler-file-isr i-tabler-file-lambda i-tabler-file-like i-tabler-file-minus i-tabler-file-music i-tabler-file-neutral i-tabler-file-off i-tabler-file-orientation i-tabler-file-pencil i-tabler-file-percent i-tabler-file-phone i-tabler-file-plus i-tabler-file-power i-tabler-file-report i-tabler-file-rss i-tabler-file-sad i-tabler-file-scissors i-tabler-file-search i-tabler-file-settings i-tabler-file-shredder i-tabler-file-signal i-tabler-file-smile i-tabler-file-spreadsheet i-tabler-file-stack i-tabler-file-star i-tabler-file-symlink i-tabler-file-text i-tabler-file-text-ai i-tabler-file-time i-tabler-file-type-bmp i-tabler-file-type-css i-tabler-file-type-csv i-tabler-file-type-doc i-tabler-file-type-docx i-tabler-file-type-html i-tabler-file-type-jpg i-tabler-file-type-js i-tabler-file-type-jsx i-tabler-file-type-pdf i-tabler-file-type-php i-tabler-file-type-png i-tabler-file-type-ppt i-tabler-file-type-rs i-tabler-file-type-sql i-tabler-file-type-svg i-tabler-file-type-ts i-tabler-file-type-tsx i-tabler-file-type-txt i-tabler-file-type-vue i-tabler-file-type-xls i-tabler-file-type-xml i-tabler-file-type-zip i-tabler-file-typography i-tabler-file-unknown i-tabler-file-upload i-tabler-file-vector i-tabler-file-word i-tabler-file-x i-tabler-file-x-filled i-tabler-file-zip

export const iconByFileType = {
'*': 'i-tabler-file',
'image': 'i-tabler-photo',
'video': 'i-tabler-video',
'audio': 'i-tabler-file-music',
'application': 'i-tabler-file-code',
'application/pdf': 'i-tabler-file-type-pdf',
'application/zip': 'i-tabler-file-zip',
'application/vnd.ms-excel': 'i-tabler-file-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'i-tabler-file-excel',
'application/msword': 'i-tabler-file-word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'i-tabler-file-word',
'application/json': 'i-tabler-file-code',
'application/xml': 'i-tabler-file-code',
'application/javascript': 'i-tabler-file-type-js',
'application/typescript': 'i-tabler-file-type-ts',
'application/vnd.ms-powerpoint': 'i-tabler-file-type-ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'i-tabler-file-type-ppt',
'text/plain': 'i-tabler-file-text',
'text/html': 'i-tabler-file-type-html',
'text/css': 'i-tabler-file-type-css',
'text/csv': 'i-tabler-file-type-csv',
'text/xml': 'i-tabler-file-type-xml',
'text/javascript': 'i-tabler-file-type-js',
'text/typescript': 'i-tabler-file-type-ts',
};

type FileTypes = keyof typeof iconByFileType;

function getFileIcon({ file, iconsMap = iconByFileType }: { file: File; iconsMap?: Record<string, string> & { '*': string } }): string {
const fileType = file.type;
const fileTypeGroup = fileType?.split('/')[0];

const icon = iconsMap[fileType as FileTypes] ?? iconsMap[fileTypeGroup as FileTypes] ?? iconsMap['*'];

return icon;
}
45 changes: 45 additions & 0 deletions packages/app-client/src/modules/notes/components/file-uploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type ComponentProps, type ParentComponent, createSignal, splitProps } from 'solid-js';
import { Button } from '@/modules/ui/components/button';

export const FileUploaderButton: ParentComponent<{
onFileUpload?: (args: { file: File }) => void;
onFilesUpload?: (args: { files: File[] }) => void;
multiple?: boolean;
} & ComponentProps<typeof Button>> = (props) => {
const [fileInputRef, setFileInputRef] = createSignal<HTMLInputElement | null>(null);
const [local, rest] = splitProps(props, ['onFileUpload', 'multiple', 'onFilesUpload']);

const onFileChange = (e: Event) => {
const target = e.target as HTMLInputElement;
if (!target.files) {
return;
}

const files = Array.from(target.files);

local.onFilesUpload?.({ files });

for (const file of files) {
local.onFileUpload?.({ file });
}
};

const onButtonClick = () => {
fileInputRef()?.click();
};

return (
<>
<input
type="file"
class="hidden"
onChange={onFileChange}
ref={setFileInputRef}
multiple={local.multiple}
/>
<Button onClick={onButtonClick} {...rest}>
{props.children ?? 'Upload File'}
</Button>
</>
);
};
18 changes: 16 additions & 2 deletions packages/app-client/src/modules/notes/notes.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@ import { apiClient } from '../shared/http/http-client';

export { storeNote, fetchNoteById };

async function storeNote({ content, isPasswordProtected, ttlInSeconds, deleteAfterReading }: { content: string; isPasswordProtected: boolean; ttlInSeconds: number; deleteAfterReading: boolean }) {
async function storeNote({
content,
isPasswordProtected,
ttlInSeconds,
deleteAfterReading,
}: {
content: string;
isPasswordProtected: boolean;
ttlInSeconds: number;
deleteAfterReading: boolean;
}) {
const { noteId } = await apiClient<{ noteId: string }>({
path: '/api/notes',
method: 'POST',
Expand All @@ -18,7 +28,11 @@ async function storeNote({ content, isPasswordProtected, ttlInSeconds, deleteAft
}

async function fetchNoteById({ noteId }: { noteId: string }) {
const { note } = await apiClient<{ note: { content: string; isPasswordProtected: boolean } }>({
const { note } = await apiClient<{ note: {
content: string;
isPasswordProtected: boolean;
assets: string[];
}; }>({
path: `/api/notes/${noteId}`,
method: 'GET',
});
Expand Down
6 changes: 5 additions & 1 deletion packages/app-client/src/modules/notes/notes.usecases.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createNote } from '@enclosed/lib';
import { createNote, filesToNoteAssets } from '@enclosed/lib';
import { storeNote } from './notes.services';

export { encryptAndCreateNote };
Expand All @@ -8,10 +8,14 @@ async function encryptAndCreateNote(args: {
password?: string;
ttlInSeconds: number;
deleteAfterReading: boolean;
fileAssets: File[];
}) {
return createNote({
...args,
storeNote,
clientBaseUrl: window.location.origin,
assets: [
...await filesToNoteAssets({ files: args.fileAssets }),
],
});
}
38 changes: 33 additions & 5 deletions packages/app-client/src/modules/notes/pages/create-note.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type Component, Match, Show, Switch, createSignal, onCleanup, onMount }
import { encryptAndCreateNote } from '../notes.usecases';
import { useNoteContext } from '../notes.context';
import { NotePasswordField } from '../components/note-password-field';
import { FileUploaderButton } from '../components/file-uploader';
import { TextArea } from '@/modules/ui/components/textarea';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { Button } from '@/modules/ui/components/button';
Expand All @@ -10,6 +11,8 @@ import { SwitchControl, SwitchLabel, SwitchThumb, Switch as SwitchUiComponent }
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
import { CopyButton } from '@/modules/shared/utils/copy';
import { isHttpErrorWithCode, isRateLimitError } from '@/modules/shared/http/http-errors';
import { cn } from '@/modules/shared/style/cn';
import { getFileIcon } from '@/modules/files/files.models';

export const CreateNotePage: Component = () => {
const [getContent, setContent] = createSignal('');
Expand All @@ -19,6 +22,7 @@ export const CreateNotePage: Component = () => {
const [getIsNoteCreated, setIsNoteCreated] = createSignal(false);
const [getTtlInSeconds, setTtlInSeconds] = createSignal(3600);
const [getDeleteAfterReading, setDeleteAfterReading] = createSignal(false);
const [getUploadedFiles, setUploadedFiles] = createSignal<File[]>([]);

const { onResetNoteForm, removeResetNoteFormHandler } = useNoteContext();

Expand All @@ -41,8 +45,8 @@ export const CreateNotePage: Component = () => {
});

const createNote = async () => {
if (!getContent()) {
setErrorMessage('Please enter a note content.');
if (!getContent() && getUploadedFiles().length === 0) {
setErrorMessage('Please enter a note content or attach a file.');
return;
}

Expand All @@ -52,6 +56,7 @@ export const CreateNotePage: Component = () => {
password: getPassword(),
ttlInSeconds: getTtlInSeconds(),
deleteAfterReading: getDeleteAfterReading(),
fileAssets: getUploadedFiles(),
});

setNoteUrl(noteUrl);
Expand Down Expand Up @@ -137,9 +142,32 @@ export const CreateNotePage: Component = () => {
</SwitchUiComponent>
</TextFieldRoot>

<Button class="w-full mt-2" onClick={createNote}>
Create note
</Button>
<div>
<FileUploaderButton variant="secondary" class="mt-2 w-full" multiple onFilesUpload={({ files }) => setUploadedFiles(prevFiles => [...prevFiles, ...files])}>
<div class="i-tabler-upload mr-2 text-lg text-muted-foreground"></div>
Attach files
</FileUploaderButton>

<Button class="mt-2 w-full" onClick={createNote}>
Create note
</Button>
</div>

<div class="flex flex-col gap-1">
{getUploadedFiles().map(file => (
<div class="flex items-center gap-2">
<div class={cn('text-lg text-muted-foreground flex-shrink-0', getFileIcon({ file }))} />
<div class="truncate" title={file.name}>
{file.name}
</div>
{/* <div class="text-muted-foreground text-sm">{(file.size)}</div> */}

<Button class="size-9 ml-auto" variant="ghost" onClick={() => setUploadedFiles(prevFiles => prevFiles.filter(f => f !== file))}>
<div class="i-tabler-x text-lg text-muted-foreground cursor-pointer flex-shrink-0"></div>
</Button>
</div>
))}
</div>

<Show when={getErrorMessage()}>
{getMessage => (
Expand Down
Loading

0 comments on commit 3de8416

Please sign in to comment.