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() },