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