customizable icons #487
Massimo Melina committed
Jan 11, 2025 at 17:08 UTC
0d9c267f920c6554802f2599a9dd3f0c6a3e1810
13 files changed
+102
-20
admin/src/CustomHtmlPage.ts
+6
-3
@@ -36,9 +36,12 @@ export default function CustomHtmlPage({ setTitleSide }: PageProps) {
36
}, [useDebounce(all, 500)])
37
const anyChange = useMemo(() => !_.isEqualWith(saved, all, (a,b) => !a && !b || undefined),
38
[saved, all])
39
- setTitleSide(useMemo(() => h(Alert, { severity: 'info', sx: { display: { xs: 'none', md: 'inherit' } } },
40
- md("Add HTML code to some parts of the Front-end. It's saved to file `custom.html`, that you can edit directly with your editor of choice. "),
41
- wikiLink('customization', "More help")
39
+ setTitleSide(useMemo(() => h(Box, { sx: { display: { xs: 'none', md: 'block' } } },
40
+ h(Alert, { severity: 'info' },
41
+ md("Add HTML code to some parts of the Front-end. It's saved to file `custom.html`, that you can edit directly with your editor of choice. "),
42
+ wikiLink('customization', "More help")
43
+ ),
44
+ h(Alert, { severity: 'info' }, md("To customize icons "), wikiLink('customization#icons', "read documentation") ),
45
), []))
46
return h(Fragment, {},
47
h(Box, { display: 'flex', alignItems: 'center', gap: 1, mb: 1 },
dev-plugins.md
+13
@@ -15,6 +15,19 @@ but nothing is preventing a single plug-in from doing both tasks.
15
16
Plugins can run both in backend (the server) and frontend (the browser). Frontend files reside in the "public" folder, while all the rest is backend.
17
18
+## System icons
19
+
20
+HFS defines "system icons" that will be used in the frontend, like the icon for the login.
21
+A plugin can customize such icons by creating a folder called "icons" and putting an image file with
22
+its name (excluding extension) matching one of the list:
23
+*login, user, filter, search, search_off, close, error, stop, options, archive, logout, home, parent, folder, file,
24
+spinner, password, download, upload, reload, lock, admin, check, to_start, to_end, menu, list, play, pause, edit, zoom,
25
+delete, comment, link, info, cut, paste, shuffle, repeat, success, warning, audio, video, image, cancel, total*.
26
+
27
+The list above may become outdated, but you can always find an updated version at https://github.com/rejetto/hfs/blob/main/frontend/src/sysIcons.ts.
28
+
29
+For example, put a file "login.png" into "icons" to customize that icon.
30
+
31
## Exported object
32
33
`plugin.js` is a javascript module (executed by Node.js), and its main way to communicate with HFS is by exporting things.
frontend/src/icons.ts
+3
-1
@@ -3,6 +3,7 @@
3
import { state, useSnapState } from './state'
4
import { createElement as h, memo } from 'react'
5
import { SYS_ICONS } from './sysIcons'
6
+import { getHFS } from '@hfs/shared'
7
8
const documentComplete = document.readyState === 'complete' ? Promise.resolve()
9
: new Promise(res => document.addEventListener('readystatechange', res))
@@ -19,6 +20,7 @@ interface IconProps { name:string, className?:string, alt?:string, [rest:string]
20
// name = null ? none : unicode ? unicode : "?" ? file_url : font_icon_class
21
export const Icon = memo(({ name, alt, className='', ...props }: IconProps) => {
22
if (!name) return null
23
+ name = getHFS().icons?.[name] ?? name
24
const [emoji, clazz=name] = SYS_ICONS[name] || []
25
const { iconsReady } = useSnapState()
26
className += ' icon'
@@ -26,7 +28,7 @@ export const Icon = memo(({ name, alt, className='', ...props }: IconProps) => {
28
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]*$/)
29
const nameIsUrl = !nameIsTheIcon && /[/?]/.test(name)
30
const isFontIcon = iconsReady && clazz
29
- className += nameIsUrl ? ' file-icon' : isFontIcon ? ` fa-${clazz}` : ' emoji-icon'
31
+ className += nameIsUrl ? ' file-icon' : isFontIcon ? ` font-icon fa-${clazz}` : ' emoji-icon'
32
return h('span',{
33
...alt ? { 'aria-label': alt } : { 'aria-hidden': true },
34
role: 'img',
frontend/src/index.scss
+5
-1
@@ -117,9 +117,11 @@ button {
117
background-color: var(--button-bg);
118
color: var(--button-text);
119
padding: .5em;
120
+ display: inline-flex; align-items: center; justify-content: center; // get closer results between chrome and safari
121
&:not(.before-sliding) { min-width: min-content; }
122
&.small { padding: 0 0.4em; height: 30px; }
122
- .icon { position: relative; top: .05em; margin: -.2em 0; }
123
+ .icon { margin: -.2em 0; }
124
+ .font-icon { vertical-align: middle; }
125
border: transparent;
126
text-decoration: none;
127
border-radius: 0.3em;
@@ -242,6 +244,7 @@ kbd {
244
margin-right: -0.1em;
245
&:nth-child(-n+3) .icon {
246
padding: 0 0.2em;
247
+ height: 1em; // effective only on file-icon
248
}
249
}
250
#folder-stats, #filter-bar>span {
@@ -515,6 +518,7 @@ form label+input { margin-top: .2em; }
518
.popup-menu-button {
519
font-size: .8em; padding: .2em .3em; position: absolute; opacity: .8; white-space: nowrap;
520
&:hover,&:focus { opacity: 1 }
521
+ .icon { margin-right: 0.1em; }
522
}
523
524
.file-dialog .dialog { min-width: 13em; } /* more room for title */
frontend/src/menu.ts
+1
-1
@@ -90,7 +90,7 @@ export function MenuPanel() {
90
} : getSearchProps()),
91
h(Btn, {
92
id: 'options-button',
93
- icon: 'settings',
93
+ icon: 'options',
94
label: t`Options`,
95
onClick: showOptions
96
}),
frontend/src/options.ts
+1
-1
@@ -14,7 +14,7 @@ export function showOptions (){
14
newDialog({
15
title: t`Options`,
16
className: 'options-dialog',
17
- icon: () => hIcon('settings'),
17
+ icon: () => hIcon('options'),
18
Content
19
})
20
frontend/src/sysIcons.ts
+1
-2
@@ -7,7 +7,7 @@ export const SYS_ICONS: Record<string, [string] | [string, string | false]> = {
7
close: ['❌','cancel'],
8
error: ['❌','cancel'],
9
stop: ['⏹️'],
10
- settings: ['⚙','cog'],
10
+ options: ['⚙','cog'],
11
archive: ['📦'],
12
logout: ['🚪'],
13
home: ['🏠'],
@@ -46,4 +46,3 @@ export const SYS_ICONS: Record<string, [string] | [string, string | false]> = {
46
cancel: ['❌','cancel'],
47
total: ['➕', 'spin6'],
48
}
49
-
src/cross-const.ts
+1
@@ -3,6 +3,7 @@ export const FRONTEND_URI = SPECIAL_URI + 'frontend/'
3
export const ADMIN_URI = SPECIAL_URI + 'admin/'
4
export const API_URI = SPECIAL_URI + 'api/'
5
export const PLUGINS_PUB_URI = SPECIAL_URI + 'plugins/'
6
+export const ICONS_URI = SPECIAL_URI + 'icons/'
7
export const PORT_DISABLED = -1
8
export const NBSP = '\xA0'
9
export const PLUGIN_CUSTOM_REST_PREFIX = '_'
src/icons.ts
new
+30
@@ -0,0 +1,30 @@
1
+import { Callback, Dict } from "./cross"
2
+import { basename, extname, join } from 'path'
3
+import { watchDir } from './util-files'
4
+import { debounceAsync } from './debounceAsync'
5
+import { readdir } from 'fs/promises'
6
+import events from './events'
7
+
8
+export const ICONS_FOLDER = 'icons'
9
+
10
+export type CustomizedIcons = undefined | Dict<string>
11
+export let customizedIcons: CustomizedIcons
12
+events.once('configReady', () => { // wait for cwd to be defined
13
+ watchIconsFolder('.', v => customizedIcons = v)
14
+})
15
+export function watchIconsFolder(parentFolder: string, cb: Callback<CustomizedIcons>) {
16
+ const iconsFolder = join(parentFolder, ICONS_FOLDER)
17
+ const watcher = watchDir(iconsFolder, debounceAsync(async () => {
18
+ let res: any = {} // reset
19
+ try {
20
+ for (const f of await readdir(iconsFolder, { withFileTypes: true })) {
21
+ if (!f.isFile()) continue
22
+ const k = basename(f.name, extname(f.name))
23
+ res[k] = f.name
24
+ }
25
+ cb(res)
26
+ }
27
+ catch { cb(undefined) } // no such dir
28
+ }), true)
29
+ return () => watcher.stop()
30
+}
src/plugins.ts
+7
-3
@@ -3,8 +3,9 @@
3
import glob from 'fast-glob'
4
import { watchLoad } from './watchLoad'
5
import _ from 'lodash'
6
-import { API_VERSION, APP_PATH, COMPATIBLE_API_VERSION, HTTP_NOT_FOUND, IS_WINDOWS, MIME_AUTO,
7
- PLUGINS_PUB_URI } from './const'
6
+import {
7
+ API_VERSION, APP_PATH, COMPATIBLE_API_VERSION, HTTP_NOT_FOUND, ICONS_URI, IS_WINDOWS, MIME_AUTO, PLUGINS_PUB_URI
8
+} from './const'
9
import * as Const from './const'
10
import Koa from 'koa'
11
import {
@@ -31,6 +32,7 @@ import { getLangData } from './lang'
32
import { i18nFromTranslations } from './i18n'
33
import { ctxBelongsTo } from './perm'
34
import { getCurrentUsername } from './auth'
35
+import { CustomizedIcons, ICONS_FOLDER, watchIconsFolder } from './icons'
36
37
export const PATH = 'plugins'
38
export const DISABLING_SUFFIX = '-disabled'
@@ -208,6 +210,7 @@ type OnDirEntry = (params:OnDirEntryParams) => void | false
210
211
export class Plugin implements CommonPluginInterface {
212
started: Date | null = new Date()
213
+ icons: CustomizedIcons
214
215
constructor(readonly id:string, readonly folder:string, private readonly data:any, private onUnload:()=>unknown){
216
if (!data) throw 'invalid data'
@@ -511,12 +514,13 @@ function watchPlugin(id: string, path: string) {
514
pluginData.getCustomHtml = () =>
515
Object.assign(Object.fromEntries(sections), callable(pluginData.customHtml) || {})
516
517
+ const unwatchIcons = watchIconsFolder(folder, v => plugin.icons = v)
518
const plugin = new Plugin(id, folder, pluginData, async () => {
519
+ unwatchIcons()
520
unwatch()
521
await Promise.allSettled(dbs.map(x => x.close()))
522
dbs.length = 0
523
})
519
-
524
if (alreadyRunning)
525
events.emit('pluginUpdated', Object.assign(_.pick(plugin, 'started'), getPluginInfo(id)))
526
else {
src/serveGuiAndSharedFiles.ts
+18
-4
@@ -1,15 +1,17 @@
1
import Koa from 'koa'
2
-import { basename, dirname } from 'path'
2
+import { basename, dirname, join } from 'path'
3
import { getNodeName, nodeIsDirectory, statusCodeForMissingPerm, urlToNode, vfs, VfsNode, walkNode } from './vfs'
4
import { sendErrorPage } from './errorPages'
5
import events from './events'
6
-import { ADMIN_URI, FRONTEND_URI, HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND,
7
- HTTP_UNAUTHORIZED, HTTP_SERVER_ERROR, HTTP_OK } from './cross-const'
6
+import {
7
+ ADMIN_URI, FRONTEND_URI, HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND,
8
+ HTTP_UNAUTHORIZED, HTTP_SERVER_ERROR, HTTP_OK, ICONS_URI
9
+} from './cross-const'
10
import { uploadWriter } from './upload'
11
import formidable from 'formidable'
12
import { Writable } from 'stream'
13
import { serveFile, serveFileNode } from './serveFile'
12
-import { BUILD_TIMESTAMP, DEV, VERSION } from './const'
14
+import { BUILD_TIMESTAMP, DEV, MIME_AUTO, VERSION } from './const'
15
import { zipStreamFromFolder } from './zip'
16
import { allowAdmin, favicon } from './adminApis'
17
import { serveGuiFiles } from './serveGuiFiles'
@@ -17,6 +19,8 @@ import mount from 'koa-mount'
19
import { baseUrl } from './listen'
20
import { asyncGeneratorToReadable, deleteNode, filterMapGenerator, pathEncode, try_ } from './misc'
21
import { basicWeb, detectBasicAgent } from './basicWeb'
22
+import { customizedIcons, ICONS_FOLDER } from './icons'
23
+import { getPluginInfo } from './plugins'
24
25
const serveFrontendFiles = serveGuiFiles(process.env.FRONTEND_PROXY, FRONTEND_URI)
26
const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontendFiles)
@@ -38,6 +42,16 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
42
if (path.startsWith(ADMIN_URI))
43
return allowAdmin(ctx) ? serveAdminPrefixed(ctx,next)
44
: sendErrorPage(ctx, HTTP_FORBIDDEN)
45
+ if (path.startsWith(ICONS_URI)) {
46
+ const a = path.substring(ICONS_URI.length).split('/')
47
+ const iconName = a.at(-1)
48
+ if (!iconName) return
49
+ const plugin = a.length > 1 && getPluginInfo(a[0]!) // an extra level in the path indicates a plugin
50
+ const file = plugin ? plugin.icons?.[iconName] : customizedIcons?.[iconName]
51
+ if (!file) return
52
+ ctx.state.considerAsGui = true
53
+ return serveFile(ctx, join(plugin?.folder || '', ICONS_FOLDER, file), MIME_AUTO)
54
+ }
55
if (ctx.method === 'PUT') { // curl -T file url/
56
const decPath = decodeURIComponent(path)
57
let rest = basename(decPath)
src/serveGuiFiles.ts
+13
-3
@@ -2,20 +2,25 @@
2
3
import Koa from 'koa'
4
import fs from 'fs/promises'
5
-import { API_VERSION, MIME_AUTO, FRONTEND_URI, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND,
6
- PLUGINS_PUB_URI, VERSION, SPECIAL_URI } from './const'
5
+import {
6
+ API_VERSION, MIME_AUTO, FRONTEND_URI, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND,
7
+ PLUGINS_PUB_URI, VERSION, SPECIAL_URI, ICONS_URI
8
+} from './const'
9
import { serveFile } from './serveFile'
10
import { getPluginConfigFields, getPluginInfo, mapPlugins, pluginsConfig } from './plugins'
11
import { refresh_session } from './api.auth'
12
import { ApiError } from './apiMiddleware'
13
import { join, extname } from 'path'
12
-import { CFG, debounceAsync, formatBytes, FRONTEND_OPTIONS, isPrimitive, newObj, onlyTruthy, parseFile } from './misc'
14
+import {
15
+ CFG, debounceAsync, formatBytes, FRONTEND_OPTIONS, isPrimitive, newObj, objSameKeys, onlyTruthy, parseFile
16
+} from './misc'
17
import { favicon, title } from './adminApis'
18
import { customHtml, getAllSections, getSection } from './customHtml'
19
import _ from 'lodash'
20
import { defineConfig, getConfig } from './config'
21
import { getLangData } from './lang'
22
import { dontOverwriteUploading } from './upload'
23
+import { customizedIcons, CustomizedIcons } from './icons'
24
25
const size1024 = defineConfig(CFG.size_1024, false, x => formatBytes.k = x ? 1024 : 1000) // we both configure formatBytes, and also provide a compiled version (number instead of boolean)
26
const splitUploads = defineConfig(CFG.split_uploads, 0)
@@ -112,6 +117,7 @@ async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) {
117
forceTheme: mapPlugins(p => _.isString(p.isTheme) ? p.isTheme : undefined).find(Boolean),
118
customHtml: _.omit(getAllSections(), ['top', 'bottom', 'htmlHead', 'style']), // exclude the sections we already apply in this phase
119
...newObj(FRONTEND_OPTIONS, (v, k) => getConfig(k)),
120
+ icons: Object.assign({}, ...mapPlugins(p => iconsToObj(p.icons, p.id + '/')), iconsToObj(customizedIcons)), // name-to-uri
121
lang
122
}, null, 4).replace(/<(\/script)/g, '<"+"$1') /*avoid breaking our script container*/}
123
document.documentElement.setAttribute('ver', HFS.VERSION.split('-')[0])
@@ -121,6 +127,10 @@ async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) {
127
<link rel="shortcut icon" href="/favicon.ico?${timestamp}" />
128
${getSection('htmlHead')}`}
129
`
130
+ function iconsToObj(icons: CustomizedIcons, pre='') {
131
+ return icons && objSameKeys(icons, (v, k) => ICONS_URI + pre + k)
132
+ }
133
+
134
if (isBody && isOpen)
135
return `${all}
136
${isFrontend && getSection('top')}
src/util-files.ts
+3
-1
@@ -25,7 +25,7 @@ export async function readFileBusy(path: string): Promise<string> {
25
})
26
}
27
28
-export function watchDir(dir: string, cb: ()=>void) {
28
+export function watchDir(dir: string, cb: ()=>void, atStart=false) {
29
let watcher: ReturnType<typeof watch>
30
let paused = false
31
try {
@@ -49,6 +49,8 @@ export function watchDir(dir: string, cb: ()=>void) {
49
console.debug(String(e))
50
}
51
}
52
+ if (atStart)
53
+ controlledCb()
54
return {
55
working() { return Boolean(watcher) },
56
stop() { watcher?.close() },