@samitouri / QOSami-HFS / commits / 31893da9

admin/fs: customizable icon #657

Massimo Melina committed Jul 7, 2024 at 11:21 UTC 31893da99bcd4325464c051b11b523b57d9593d9
14 files changed +102 -66
admin/index.html
+2
@@ -7,6 +7,8 @@
7 <title>HFS Admin-panel</title>
8 <script type="module" src="/src/index.ts"></script>
9 <link rel="icon" type="image/svg+xml" href="/hfs-logo-icon.svg" />
10 + <link href="../frontend/fontello.css" rel="stylesheet" />
11 + <link href="../frontend/fontello.woff2" rel="prefetch" />
12 </head>
13 <body>
14 <div id="root"></div>
admin/src/FileForm.ts
+24 -2
@@ -9,9 +9,9 @@ import { apiCall, UseApi } from './api'
9 import {
10 basename, defaultPerms, formatBytes, formatTimestamp, isEqualLax, isWhoObject, newDialog, objSameKeys,
11 onlyTruthy, prefix, VfsPerms, wantArray, Who, WhoObject, matches, HTTP_MESSAGES, xlate, md, Callback,
12 - useRequestRender, splitAt
12 + useRequestRender, splitAt, IMAGE_FILEMASK
13 } from './misc'
14 -import { Btn, IconBtn, LinkBtn, modifiedProps, useBreakpoint, wikiLink } from './mui'
14 +import { Btn, Flex, IconBtn, LinkBtn, modifiedProps, useBreakpoint, wikiLink } from './mui'
15 import { reloadVfs, VfsNode } from './VfsPage'
16 import _ from 'lodash'
17 import FileField from './FileField'
@@ -22,6 +22,8 @@ import { moveVfs } from './VfsTree'
22 import QrCreator from 'qr-creator';
23 import MenuButton from './MenuButton'
24 import addFiles, { addLink, addVirtual } from './addFiles'
25 +import { SYS_ICONS } from '@hfs/frontend/src/sysIcons'
26 +import { hIcon } from '@hfs/frontend/src/misc'
27
28 const ACCEPT_LINK = "https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept"
29
@@ -72,6 +74,8 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }:
74 can_upload: isDir,
75 can_delete: isDir,
76 }
77 + const defaultIcon = !values.icon
78 + const embeddedIcon = values.icon && !values.icon.includes('.')
79 return h(Form, {
80 values,
81 set(v, k) {
@@ -152,6 +156,24 @@ export default function FileForm({ file, addToBar, statusApi, accounts, saved }:
156 : isDir ? "Content from this path on disk will be listed, but you can also add more" : undefined,
157 },
158 { k: 'id', comp: LinkField, statusApi, xs: 12 },
159 + {
160 + k: 'iconType',
161 + comp: SelectField,
162 + options: ['default', 'file', 'embedded'],
163 + value: !values.icon ? 'default' : embeddedIcon ? 'embedded' : 'file',
164 + fromField: v => setValues({ ...values, icon: v === 'default' ? '' : v === 'file' ? 'select.a.file' : Object.keys(SYS_ICONS)[0] }),
165 + xs: defaultIcon ? 12 : true,
166 + },
167 + !defaultIcon && { k: 'icon', xs: 7, md: 8, xl: 10,
168 + ...embeddedIcon ? {
169 + comp: SelectField, // uniqBy to avoid same icon (with different names), but it works only on array, so first step is to convert the object
170 + options: _.map(_.uniqBy(_.map(SYS_ICONS, (v,k) => [k, v[0], v[1] ?? k] as const), x => x[2]), ([k, emoji]) =>
171 + ({ value: k, label: h(Flex, { gap: '.5em' }, hIcon(k), hIcon(emoji), ' ', k) }) ), // show both font-icon and emoji versions
172 + helperText: "Second icon you see is the fallback"
173 + } : {
174 + label: "Icon file", placeholder: "default", comp: FileField, fileMask: IMAGE_FILEMASK,
175 + }
176 + },
177 perm('can_read', "Who can see but not download will be asked to login"),
178 perm('can_archive', "Should this be included when user downloads as ZIP", { lg: isDir ? 6 : 12 }),
179 perm('can_list', "Permission to requests the list of a folder. The list will include only things you can see.", { contentText: "subfolders" }),
admin/src/OptionsPage.ts
+2 -2
@@ -7,7 +7,7 @@ import { state, useSnapState } from './state'
7 import { Link as RouterLink } from 'react-router-dom'
8 import { CardMembership, EditNote, Refresh, Warning } from '@mui/icons-material'
9 import { Dict, MAX_TILE_SIZE, REPO_URL, isIpLocalHost, wait, with_, try_, ipForUrl, SORT_BY_OPTIONS, THEME_OPTIONS,
10 - CFG, md } from './misc'
10 + CFG, md, IMAGE_FILEMASK } from './misc'
11 import { iconTooltip, InLink, LinkBtn, modifiedProps, wikiLink, useBreakpoint, NetmaskField, WildcardsSupported } from './mui'
12 import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form';
13 import { ArrayField } from './ArrayField'
@@ -182,7 +182,7 @@ export default function OptionsPage() {
182 { k: 'invert_order', comp: BoolField, xs: 6, sm: 4, md: 3, },
183 { k: 'folders_first', comp: BoolField, xs: 6, sm: 4, md: 3, },
184 { k: 'sort_numerics', comp: BoolField, xs: 6, sm: 4, md: true, label: "Sort numeric names" },
185 - { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.png|*.ico|*.jpg|*.jpeg|*.gif|*.svg', sm: 12,
185 + { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.ico|' + IMAGE_FILEMASK, sm: 12,
186 helperText: "The icon associated to your website" },
187
188 h(Section, { title: "Uploads" }),
frontend/src/icons.ts
+2 -48
@@ -2,53 +2,7 @@
2
3 import { state, useSnapState } from './state'
4 import { createElement as h, memo } from 'react'
5 -
6 -const SYS_ICONS: Record<string, [string] | [string, string | false]> = { // false means we don't have the icon, only unicode
7 - login: ['👤','user'],
8 - user: ['👤','user'],
9 - filter: ['✂'],
10 - search: ['🔍'],
11 - search_off: ['❌','cancel'],
12 - close: ['❌','cancel'],
13 - error: ['❌','cancel'],
14 - stop: ['⏹️'],
15 - settings: ['⚙','cog'],
16 - archive: ['📦'],
17 - logout: ['🚪'],
18 - home: ['🏠'],
19 - parent: ['⬅','left'],
20 - folder: ['📂'],
21 - file: ['📄','doc'],
22 - spinner: ['🎲','spin6 spinner'],
23 - password: ['🗝️','key'],
24 - download: ['⬇️'],
25 - upload: ['⬆️'],
26 - reload: ['🔄','reload'],
27 - lock: ['🔒','lock'],
28 - admin: ['👑','crown'],
29 - check: ['✔️'],
30 - to_start: ['◀'],
31 - to_end: ['▶'],
32 - menu: ['☰'],
33 - list: ['☰','menu'],
34 - play: ['▶'],
35 - pause: ['⏸'],
36 - edit: ['✏️'],
37 - zoom: ['↔'],
38 - delete: ['🗑️', 'trash'],
39 - comment: ['💬'],
40 - link: ['↗'],
41 - info: ['ⓘ', false],
42 - cut: ['✄'],
43 - paste: ['📋'],
44 - shuffle: ['🔀'],
45 - repeat: ['🔁', 'reload'],
46 - success: ['👍', 'check'],
47 - warning: ['⚠️', false],
48 - audio: ['🎧'],
49 - video: ['🎥'],
50 - image: ['📸'],
51 -}
5 +import { SYS_ICONS } from './sysIcons'
6
7 const documentComplete = document.readyState === 'complete' ? Promise.resolve()
8 : new Promise(res => document.addEventListener('readystatechange', res))
@@ -69,7 +23,7 @@ export const Icon = memo(({ name, alt, className='', ...props }: IconProps) => {
23 className += ' icon'
24 const nameIsTheIcon = name.length === 1 ||
25 name.match(/^[\uD800-\uDFFF\u2600-\u27BF\u2B00-\u2BFF\u3030-\u303F\u3297\u3299\u00A9\u00AE\u200D\u20E3\uFE0F\u2190-\u21FF\u2300-\u23FF\u2400-\u243F\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF]*$/)
72 - const nameIsFile = !nameIsTheIcon && name.includes('.')
26 + const nameIsFile = !nameIsTheIcon && /[.?]/.test(name)
27 const isFontIcon = iconsReady && clazz
28 className += nameIsFile ? ' file-icon' : isFontIcon ? ` fa-${clazz}` : ' emoji-icon'
29 return h('span',{
frontend/src/index.scss
-6
@@ -99,12 +99,6 @@ select { text-align: center; } // it is surely cooler on the options dialog
99 [class^="fa-"]:before, [class*=" fa-"]:before { /* don't need extra margin on fontello icons */
100 margin: 0;
101 }
102 -.icon {
103 - font-size: 1.2em;
104 - height: 1.2em; width: 1.4em; // give similar size to <img> icons
105 -
106 - display: inline-block; text-align: center; // same size for font icons
107 -}
102 img.file-icon { height: 1em; }
103 .file-icon {
104 background-size: contain;
frontend/src/state.ts
+5 -4
@@ -3,7 +3,8 @@
3 import _ from 'lodash'
4 import { proxy, useSnapshot } from 'valtio'
5 import { subscribeKey } from 'valtio/utils'
6 -import { FRONTEND_OPTIONS, getHFS, hIcon, objSameKeys, pathEncode, typedKeys } from './misc'
6 +import { FRONTEND_OPTIONS, getHFS, hIcon, objSameKeys, pathEncode, StringifyProps, typedKeys } from './misc'
7 +import { DirEntry as ServerDirEntry } from '../../src/api.get_file_list'
8
9 export const state = proxy<typeof FRONTEND_OPTIONS & {
10 stopSearch?: ()=>void,
@@ -90,14 +91,14 @@ function storeSettings() {
91 localStorage.setItem(SETTINGS_KEY, JSON.stringify(_.pick(state, SETTINGS_TO_STORE)))
92 }
93
93 -export class DirEntry {
94 +export class DirEntry implements StringifyProps<ServerDirEntry> {
95 static FORBIDDEN = 'FORBIDDEN'
96 public readonly n: string
97 public readonly s?: number
98 public readonly m?: string
99 public readonly c?: string
100 public readonly p?: string
100 - public readonly icon?: string
101 + public readonly icon?: string | true
102 public readonly web?: true
103 public readonly url?: string
104 public readonly target?: string
@@ -146,7 +147,7 @@ export class DirEntry {
147 }
148
149 getDefaultIcon() {
149 - return hIcon(this.icon ?? (this.isFolder || this.web ? 'folder' : this.url ? 'link' : ext2type(this.ext) || 'file'))
150 + return hIcon(this.icon === true ? `${this.n}?get=icon` : (this.icon ?? (this.isFolder || this.web ? 'folder' : this.url ? 'link' : ext2type(this.ext) || 'file')))
151 }
152 }
153 export type DirList = DirEntry[]
frontend/src/sysIcons.ts new
+47
@@ -0,0 +1,47 @@
1 +export const SYS_ICONS: Record<string, [string] | [string, string | false]> = { // false means we don't have the icon, only unicode
2 + login: ['👤','user'],
3 + user: ['👤','user'],
4 + filter: ['✂'],
5 + search: ['🔍'],
6 + search_off: ['❌','cancel'],
7 + close: ['❌','cancel'],
8 + error: ['❌','cancel'],
9 + stop: ['⏹️'],
10 + settings: ['⚙','cog'],
11 + archive: ['📦'],
12 + logout: ['🚪'],
13 + home: ['🏠'],
14 + parent: ['⬅','left'],
15 + folder: ['📂'],
16 + file: ['📄','doc'],
17 + spinner: ['🎲','spin6 spinner'],
18 + password: ['🗝️','key'],
19 + download: ['⬇️'],
20 + upload: ['⬆️'],
21 + reload: ['🔄','reload'],
22 + lock: ['🔒','lock'],
23 + admin: ['👑','crown'],
24 + check: ['✔️'],
25 + to_start: ['◀'],
26 + to_end: ['▶'],
27 + menu: ['☰'],
28 + list: ['☰','menu'],
29 + play: ['▶'],
30 + pause: ['⏸'],
31 + edit: ['✏️'],
32 + zoom: ['↔'],
33 + delete: ['🗑️', 'trash'],
34 + comment: ['💬'],
35 + link: ['↗'],
36 + info: ['ⓘ', false],
37 + cut: ['✄'],
38 + paste: ['📋'],
39 + shuffle: ['🔀'],
40 + repeat: ['🔁', 'reload'],
41 + success: ['👍', 'check'],
42 + warning: ['⚠️', false],
43 + audio: ['🎧'],
44 + video: ['🎥'],
45 + image: ['📸'],
46 +}
47 +
shared/_main.scss
+6
@@ -3,6 +3,12 @@
3 clip-path: rect(1px 1px 1px 1px);
4 clip: rect(1px, 1px, 1px, 1px); // legacy browsers
5 }
6 +.icon { // also used in admin/fs
7 + font-size: 1.2em;
8 + height: 1.2em; width: 1.4em; // give similar size to <img> icons
9 +
10 + display: inline-block; text-align: center; // same size for font icons
11 +}
12
13 @keyframes blink {
14 0% {opacity: 1}
shared/index.ts
+2
@@ -30,6 +30,8 @@ Object.assign(HFS, {
30 cpuSpeedIndex,
31 })
32
33 +export const IMAGE_FILEMASK = '*.jpg|*.jpeg|*.gif|*.svg'
34 +
35 //@ts-ignore
36 if (import.meta.env.PROD) {
37 const was = console.debug
src/api.get_file_list.ts
+7 -2
@@ -17,7 +17,7 @@ import { ctxAdminAccess } from './adminApis'
17 import { dontOverwriteUploading } from './upload'
18 import { SendListReadable } from './SendList'
19
20 -export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean, url?: string, target?: string }
20 +export interface DirEntry { n:string, s?:number, m?:Date, c?:Date, p?: string, comment?: string, web?: boolean, url?: string, target?: string, icon?: string | true }
21
22 export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search, wild, c, onlyFolders, admin }, ctx) => {
23 const node = await urlToNode(uri, ctx)
@@ -46,7 +46,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
46 const can_comment = can_upload && areCommentsEnabled()
47 const can_overwrite = can_upload && (can_delete || !dontOverwriteUploading.get())
48 const comment = node.comment ?? await getCommentFor(node.source)
49 - const props = { can_archive, can_upload, can_delete, can_overwrite, can_comment, comment, accept: node.accept }
49 + const props = { can_archive, can_upload, can_delete, can_overwrite, can_comment, comment, accept: node.accept, icon: getNodeIcon(node) }
50 ctx.state.browsing = uri.replace(/\/{2,}/g, '/')
51 updateConnectionForCtx(ctx)
52 if (!list)
@@ -103,6 +103,10 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
103 }
104 }
105
106 + function getNodeIcon(node: VfsNode) {
107 + return node.icon?.includes('.') || node.icon // true = specific for this file, otherwise is a SYS_ICONS
108 + }
109 +
110 async function nodeToDirEntry(ctx: Koa.Context, node: VfsNode): Promise<DirEntry | null> {
111 const { source, url } = node
112 const name = getNodeName(node)
@@ -127,6 +131,7 @@ export const get_file_list: ApiHandler = async ({ uri='/', offset, limit, search
131 s: isFolder ? undefined : st?.size,
132 p: (pr + pl + pd + pa) || undefined,
133 comment: node.comment ?? await getCommentFor(source),
134 + icon: getNodeIcon(node),
135 web: await hasDefaultFile(node, ctx) ? true : undefined,
136 }
137 }
src/api.vfs.ts
+1 -1
@@ -23,7 +23,7 @@ async function urlToNodeOriginal(uri: string) {
23 return n?.isTemp ? n.original : n
24 }
25
26 -const ALLOWED_KEYS = ['name','source','masks','default','accept','rename','mime','url','target','comment', ...PERM_KEYS]
26 +const ALLOWED_KEYS = ['name','source','masks','default','accept','rename','mime','url','target','comment','icon', ...PERM_KEYS]
27
28 const apis: ApiHandlers = {
29
src/cross.ts
+1 -1
@@ -32,7 +32,7 @@ export type Falsy = false | null | undefined | '' | 0
32 type Truthy<T> = T extends false | '' | 0 | null | undefined | void ? never : T
33 export type Callback<IN=void, OUT=void> = (x:IN) => OUT
34 export type Promisable<T> = T | Promise<T>
35 -
35 +export type StringifyProps<T> = { [P in keyof T]: Exclude<T[P], Date> extends T[P] ? string | Exclude<T[P], Date> : T[P] }
36 export interface VfsPerms {
37 can_see?: Who
38 can_read?: Who
src/serveGuiAndSharedFiles.ts
+2
@@ -98,6 +98,8 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
98 const { get } = ctx.query
99 if (node.default && path.endsWith('/') && !get) // final/ needed on browser to make resource urls correctly with html pages
100 node = await urlToNode(node.default, ctx, node) ?? node
101 + if (get === 'icon')
102 + return serveFile(ctx, node.icon || '|') // pipe to cause not-found
103 if (!await nodeIsDirectory(node))
104 return node.url ? ctx.redirect(node.url)
105 : !node.source ? sendErrorPage(ctx, HTTP_METHOD_NOT_ALLOWED) // !dir && !source is not supported at this moment
src/vfs.ts
+1
@@ -43,6 +43,7 @@ export interface VfsNodeStored extends VfsPerms {
43 masks?: Masks // express fields for descendants that are not in the tree
44 accept?: string
45 comment?: string
46 + icon?: string
47 }
48 export interface VfsNode extends VfsNodeStored { // include fields that are only filled at run-time
49 isTemp?: true // this node doesn't belong to the tree and was created by necessity