Skip to content

Commit 1e101dc

Browse files
[add] File Preview component
[refactor] simplify Copilot codes [optimize] upgrade to ESLint 10 & other latest Upstream packages
1 parent 42f2789 commit 1e101dc

File tree

6 files changed

+2274
-2314
lines changed

6 files changed

+2274
-2314
lines changed

components/FilePreview.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { ComponentProps, FC } from 'react';
2+
3+
import { SymbolIcon } from './Icon';
4+
5+
export interface FilePreviewProps extends Omit<ComponentProps<'figure'>, 'children'> {
6+
path: string;
7+
type?: ComponentProps<'input'>['accept'];
8+
}
9+
10+
const ExtensionNameMap: Record<string, string> = {
11+
stream: 'binary',
12+
compressed: 'zip',
13+
msword: 'doc',
14+
document: 'docx',
15+
powerpoint: 'ppt',
16+
presentation: 'pptx',
17+
excel: 'xls',
18+
sheet: 'xlsx',
19+
};
20+
21+
const ImageExtension = /\.(apng|avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i;
22+
const AudioExtension = /\.(aac|flac|m4a|mp3|ogg|wav|weba)$/i;
23+
const VideoExtension = /\.(avi|m4v|mov|mp4|mpeg|mpg|webm|wmv)$/i;
24+
25+
function inferFileMeta(path: string, type?: string) {
26+
const [pathname] = path.split(/[?#]/),
27+
[category = '', ...kind] = type?.split(/\W+/) || [];
28+
29+
const fileName = decodeURI(pathname.split('/').at(-1) || pathname);
30+
const extension =
31+
kind[0] === '*' || !kind[0]
32+
? fileName.includes('.')
33+
? fileName.split('.').at(-1)?.toLowerCase()
34+
: ''
35+
: (ExtensionNameMap[kind.at(-1)!] || kind.at(-1))?.toLowerCase();
36+
37+
const isImage = category === 'image' || (!category && ImageExtension.test(pathname));
38+
const isAudio = category === 'audio' || (!category && AudioExtension.test(pathname));
39+
const isVideo = category === 'video' || (!category && VideoExtension.test(pathname));
40+
41+
return { fileName, extension, isImage, isAudio, isVideo };
42+
}
43+
44+
export const FilePreview: FC<FilePreviewProps> = ({ path, type, className = '' }) => {
45+
const { fileName, extension, isImage, isAudio, isVideo } = inferFileMeta(path, type);
46+
const caption = fileName || extension || path;
47+
48+
return (
49+
<figure className={`m-0 ${className}`}>
50+
{isImage ? (
51+
<img
52+
className="max-h-60 max-w-full rounded object-contain"
53+
loading="lazy"
54+
src={path}
55+
alt={fileName}
56+
/>
57+
) : isAudio ? (
58+
<audio className="max-w-full" controls preload="metadata" src={path} />
59+
) : isVideo ? (
60+
<video className="max-h-72 max-w-full rounded" controls preload="metadata" src={path} />
61+
) : null}
62+
63+
<figcaption className="mt-0.5 max-w-full truncate align-middle text-[0.75rem] text-inherit opacity-80">
64+
{isImage || isAudio || isVideo ? (
65+
caption
66+
) : (
67+
<a
68+
className="inline-flex max-w-full items-center gap-1 text-inherit underline"
69+
href={path}
70+
target="_blank"
71+
rel="noreferrer"
72+
download={fileName}
73+
>
74+
<SymbolIcon name="insert_drive_file" className="text-[1.25rem]" />
75+
{caption}
76+
</a>
77+
)}
78+
</figcaption>
79+
</figure>
80+
);
81+
};
82+
83+
FilePreview.displayName = 'FilePreview';

components/PasteDropBox.tsx

Lines changed: 57 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,70 @@
1-
import { ClipboardEvent, DragEvent, FC, PropsWithChildren } from 'react';
1+
import { ClipboardEvent, Component, DragEvent, HTMLAttributes, PropsWithChildren } from 'react';
2+
import { groupBy } from 'web-utility';
23

3-
export interface PasteDropBoxProps {
4-
className?: string;
5-
onText?: (text: string) => void;
6-
onHTML?: (html: string) => void;
7-
onFiles?: (files: File[]) => void;
4+
export interface PasteDropData {
5+
kind: string;
6+
type: string;
7+
data: string | File;
88
}
99

10-
export const PasteDropBox: FC<PropsWithChildren<PasteDropBoxProps>> = ({
11-
children,
12-
className,
13-
onFiles,
14-
onHTML,
15-
onText,
16-
}) => {
17-
const handlePasteDrop = async (event: ClipboardEvent | DragEvent) => {
18-
const items =
19-
event.type === 'paste'
20-
? [...(event as ClipboardEvent).clipboardData.items]
21-
: [...(event as DragEvent).dataTransfer.items];
10+
export interface PasteDropEvent extends Record<
11+
`${'kind' | 'type'}Map`,
12+
Record<string, PasteDropData[]>
13+
> {
14+
type: string;
15+
}
16+
17+
export interface PasteDropBoxProps extends Omit<
18+
PropsWithChildren<HTMLAttributes<HTMLDivElement>>,
19+
'onChange' | 'onDragOver' | 'onDrop' | 'onPaste'
20+
> {
21+
onChange?: (event: PasteDropEvent) => void;
22+
}
23+
24+
export class PasteDropBox extends Component<PasteDropBoxProps> {
25+
static async *transferData(items: DataTransferItemList) {
26+
for (const item of items) {
27+
const { kind, type } = item;
2228

23-
const files = items
24-
.filter(item => item.kind === 'file')
25-
.map(item => item.getAsFile())
26-
.filter((file): file is File => file !== null);
29+
if (kind === 'file') {
30+
const data = item.getAsFile();
2731

28-
if (files.length > 0) {
29-
event.preventDefault();
30-
onFiles?.(files);
32+
if (data) yield { kind, type, data };
33+
} else if (kind === 'string') {
34+
const data = await new Promise<string>(resolve => item.getAsString(resolve));
3135

32-
return;
36+
yield { kind, type, data };
37+
}
3338
}
39+
}
40+
handlePasteDrop = async (event: ClipboardEvent | DragEvent) => {
41+
event.preventDefault();
3442

35-
const htmlItem = items.find(({ type }) => type === 'text/html');
36-
const plainItem = items.find(({ type }) => type === 'text/plain');
43+
const items =
44+
event.type === 'paste'
45+
? (event as ClipboardEvent).clipboardData.items
46+
: (event as DragEvent).dataTransfer.items;
3747

38-
if (htmlItem && onHTML) {
39-
const html = await new Promise<string>(resolve => htmlItem.getAsString(resolve));
48+
const list = await Array.fromAsync(PasteDropBox.transferData(items));
4049

41-
event.preventDefault();
42-
onHTML(html);
43-
} else if (plainItem && onText) {
44-
const text = await new Promise<string>(resolve => plainItem.getAsString(resolve));
50+
const kindMap = groupBy(list, 'kind'),
51+
typeMap = groupBy(list, 'type');
4552

46-
event.preventDefault();
47-
onText(text);
48-
}
53+
this.props.onChange?.({ type: event.type, kindMap, typeMap });
4954
};
5055

51-
return (
52-
<div
53-
className={className}
54-
onDragOver={e => e.preventDefault()}
55-
onDrop={handlePasteDrop}
56-
onPaste={handlePasteDrop}
57-
>
58-
{children}
59-
</div>
60-
);
61-
};
56+
render() {
57+
const { children, onChange, ...props } = this.props;
58+
59+
return (
60+
<div
61+
{...props}
62+
onDragOver={event => event.preventDefault()}
63+
onDrop={this.handlePasteDrop}
64+
onPaste={this.handlePasteDrop}
65+
>
66+
{children}
67+
</div>
68+
);
69+
}
70+
}

models/File.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,19 @@
1-
import { Base } from '@idea2app/data-server';
2-
import { toggle } from 'mobx-restful';
1+
import { SignedLink } from '@idea2app/data-server';
2+
import { BaseModel, toggle } from 'mobx-restful';
33

4-
import { TableModel } from './Base';
54
import userStore from './User';
65

7-
interface SignedLink {
8-
putLink: string;
9-
getLink: string;
10-
}
11-
12-
export class FileModel extends TableModel<Base> {
6+
export class FileModel extends BaseModel {
137
baseURI = 'file';
148
client = userStore.client;
159

1610
@toggle('uploading')
1711
async upload(file: File | Blob) {
1812
const name = file instanceof File ? file.name : crypto.randomUUID();
1913

20-
const { body } = await this.client.post<SignedLink>(`file/signed-link/${name}`);
14+
const { body } = await this.client.post<SignedLink>(`${this.baseURI}/signed-link/${name}`);
2115

22-
await fetch(body!.putLink, {
23-
method: 'PUT',
24-
body: file,
25-
headers: { 'Content-Type': file.type },
26-
});
16+
await this.client.put(body!.putLink, file, { 'Content-Type': file.type });
2717

2818
return body!.getLink;
2919
}

package.json

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,74 +12,73 @@
1212
"@emotion/server": "^11.11.0",
1313
"@emotion/styled": "^11.14.1",
1414
"@giscus/react": "^3.1.0",
15-
"@koa/bodyparser": "^6.0.0",
16-
"@mui/lab": "^7.0.1-beta.21",
17-
"@mui/material": "^7.3.7",
18-
"@mui/material-nextjs": "^7.3.7",
19-
"@passwordless-id/webauthn": "^2.3.1",
20-
"@sentry/nextjs": "^10.35.0",
15+
"@koa/bodyparser": "^6.1.0",
16+
"@mui/lab": "^7.0.1-beta.23",
17+
"@mui/material": "^7.3.9",
18+
"@mui/material-nextjs": "^7.3.9",
19+
"@passwordless-id/webauthn": "^2.3.5",
20+
"@sentry/nextjs": "^10.42.0",
2121
"file-type": "^21.3.0",
2222
"idb-keyval": "^6.2.2",
2323
"jsonwebtoken": "^9.0.3",
24-
"koa": "^3.1.1",
24+
"koa": "^3.1.2",
2525
"koa-jwt": "^4.0.4",
2626
"koajax": "^3.1.2",
2727
"lodash.debounce": "^4.0.8",
28-
"marked": "^17.0.1",
28+
"marked": "^17.0.4",
2929
"mime": "^4.1.0",
3030
"mobx": "^6.15.0",
3131
"mobx-github": "^0.6.2",
3232
"mobx-i18n": "^0.7.2",
33-
"mobx-lark": "^2.6.4",
33+
"mobx-lark": "^2.7.0",
3434
"mobx-react": "^9.2.1",
3535
"mobx-react-helper": "^0.5.1",
3636
"mobx-restful": "^2.1.4",
37-
"next": "^16.1.4",
37+
"next": "^16.1.6",
3838
"next-pwa": "~5.6.0",
3939
"next-ssr-middleware": "^1.1.0",
40-
"react": "^19.2.3",
41-
"react-dom": "^19.2.3",
40+
"react": "^19.2.4",
41+
"react-dom": "^19.2.4",
4242
"web-utility": "^4.6.4",
43-
"webpack": "^5.104.1"
43+
"webpack": "^5.105.4"
4444
},
4545
"devDependencies": {
46-
"@babel/plugin-proposal-decorators": "^7.28.6",
46+
"@babel/plugin-proposal-decorators": "^7.29.0",
4747
"@babel/plugin-transform-typescript": "^7.28.6",
4848
"@babel/preset-react": "^7.28.5",
49-
"@cspell/eslint-plugin": "^9.6.0",
50-
"@eslint/js": "^9.39.2",
51-
"@idea2app/data-server": "^1.0.0-rc.9",
52-
"@next/eslint-plugin-next": "^16.1.4",
53-
"@stylistic/eslint-plugin": "^5.7.0",
54-
"@tailwindcss/postcss": "^4.1.18",
49+
"@cspell/eslint-plugin": "^9.7.0",
50+
"@eslint/js": "^10.0.1",
51+
"@idea2app/data-server": "^1.0.0-rc.10",
52+
"@next/eslint-plugin-next": "^16.1.6",
53+
"@stylistic/eslint-plugin": "^5.10.0",
54+
"@tailwindcss/postcss": "^4.2.1",
5555
"@tailwindcss/typography": "^0.5.19",
5656
"@types/eslint-config-prettier": "^6.11.3",
5757
"@types/jsonwebtoken": "^9.0.10",
5858
"@types/koa": "^3.0.1",
5959
"@types/lodash.debounce": "^4.0.9",
6060
"@types/next-pwa": "^5.6.9",
61-
"@types/node": "^24.10.9",
62-
"@types/react": "^19.2.8",
63-
"eslint": "^9.39.2",
64-
"eslint-config-next": "^16.1.4",
61+
"@types/node": "^24.12.0",
62+
"@types/react": "^19.2.14",
63+
"eslint": "^10.0.3",
64+
"eslint-config-next": "^16.1.6",
6565
"eslint-config-prettier": "^10.1.8",
6666
"eslint-plugin-react": "^7.37.5",
6767
"eslint-plugin-simple-import-sort": "^12.1.1",
6868
"git-utility": "^0.3.0",
69-
"globals": "^17.0.0",
69+
"globals": "^17.4.0",
7070
"husky": "^9.1.7",
7171
"jiti": "^2.6.1",
72-
"lint-staged": "^16.2.7",
73-
"postcss": "^8.5.6",
74-
"prettier": "^3.8.0",
72+
"lint-staged": "^16.3.2",
73+
"postcss": "^8.5.8",
74+
"prettier": "^3.8.1",
7575
"prettier-plugin-css-order": "^2.2.0",
7676
"prettier-plugin-tailwindcss": "^0.7.2",
77-
"tailwindcss": "^4.1.18",
77+
"tailwindcss": "^4.2.1",
7878
"typescript": "~5.9.3",
79-
"typescript-eslint": "^8.53.1"
79+
"typescript-eslint": "^8.56.1"
8080
},
8181
"resolutions": {
82-
"mobx-github": "$mobx-github",
8382
"next": "$next"
8483
},
8584
"pnpm": {

0 commit comments

Comments
 (0)