Skip to content

Commit

Permalink
feat(notes): share file assets (#95)
Browse files Browse the repository at this point in the history
  • Loading branch information
CorentinTh authored Sep 12, 2024
1 parent 05eee82 commit 013a6f1
Show file tree
Hide file tree
Showing 59 changed files with 1,863 additions and 666 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;
}
104 changes: 104 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,104 @@
import { type Component, type ComponentProps, type ParentComponent, createSignal, onCleanup, splitProps } from 'solid-js';
import { Button } from '@/modules/ui/components/button';
import { cn } from '@/modules/shared/style/cn';

const DropArea: Component<{ onFilesDrop?: (args: { files: File[] }) => void }> = (props) => {
const [isDragging, setIsDragging] = createSignal(false);

const handleDragOver = (e: DragEvent) => {
e.preventDefault();
setIsDragging(true);
};

const handleDragLeave = (e: DragEvent) => {
if (e.relatedTarget === null) {
setIsDragging(false);
}
};

const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer?.files ?? []);

if (files.length === 0) {
return;
}

props.onFilesDrop?.({ files });
};

// Adding global event listeners for drag and drop
document.addEventListener('dragover', handleDragOver);
document.addEventListener('dragleave', handleDragLeave);
document.addEventListener('drop', handleDrop);

// Cleanup listeners when component unmounts
onCleanup(() => {
document.removeEventListener('dragover', handleDragOver);
document.removeEventListener('dragleave', handleDragLeave);
document.removeEventListener('drop', handleDrop);
});

return (
<div
class={cn('fixed top-0 left-0 w-screen h-screen z-50 bg-background bg-opacity-50 backdrop-blur transition-colors', isDragging() ? 'block' : 'hidden')}
>
<div class="flex items-center justify-center h-full text-center flex-col">
<div class="i-tabler-file-plus text-6xl text-muted-foreground mx-auto"></div>
<div class="text-xl my-2 font-semibold text-muted-foreground">Drop files here</div>
<div class="text-base text-muted-foreground">
Drag and drop files here to attach them to the note
</div>
</div>
</div>
);
};

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 uploadFiles = ({ files }: { files: File[] }) => {
local.onFilesUpload?.({ files });

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

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

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

uploadFiles({ files });
};

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

return (
<>
<input
type="file"
class="hidden"
onChange={onFileChange}
ref={setFileInputRef}
multiple={local.multiple}
/>
<DropArea onFilesDrop={uploadFiles} />
<Button onClick={onButtonClick} {...rest}>
{props.children ?? 'Upload File'}
</Button>
</>
);
};
28 changes: 25 additions & 3 deletions packages/app-client/src/modules/notes/notes.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,45 @@ 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({
payload,
isPasswordProtected,
ttlInSeconds,
deleteAfterReading,
encryptionAlgorithm,
serializationFormat,
}: {
payload: string;
isPasswordProtected: boolean;
ttlInSeconds: number;
deleteAfterReading: boolean;
encryptionAlgorithm: string;
serializationFormat: string;
}) {
const { noteId } = await apiClient<{ noteId: string }>({
path: '/api/notes',
method: 'POST',
body: {
content,
payload,
isPasswordProtected,
ttlInSeconds,
deleteAfterReading,
serializationFormat,
encryptionAlgorithm,
},
});

return { noteId };
}

async function fetchNoteById({ noteId }: { noteId: string }) {
const { note } = await apiClient<{ note: { content: string; isPasswordProtected: boolean } }>({
const { note } = await apiClient<{ note: {
payload: string;
isPasswordProtected: boolean;
assets: string[];
serializationFormat: string;
encryptionAlgorithm: 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 }),
],
});
}
Loading

0 comments on commit 013a6f1

Please sign in to comment.