Skip to content

Commit

Permalink
plugins: icons #487
Browse files Browse the repository at this point in the history
  • Loading branch information
rejetto committed Jan 12, 2025
1 parent 249bff8 commit 1cd4fe4
Show file tree
Hide file tree
Showing 13 changed files with 102 additions and 20 deletions.
9 changes: 6 additions & 3 deletions admin/src/CustomHtmlPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,12 @@ export default function CustomHtmlPage({ setTitleSide }: PageProps) {
}, [useDebounce(all, 500)])
const anyChange = useMemo(() => !_.isEqualWith(saved, all, (a,b) => !a && !b || undefined),
[saved, all])
setTitleSide(useMemo(() => h(Alert, { severity: 'info', sx: { display: { xs: 'none', md: 'inherit' } } },
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. "),
wikiLink('customization', "More help")
setTitleSide(useMemo(() => h(Box, { sx: { display: { xs: 'none', md: 'block' } } },
h(Alert, { severity: 'info' },
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. "),
wikiLink('customization', "More help")
),
h(Alert, { severity: 'info' }, md("To customize icons "), wikiLink('customization#icons', "read documentation") ),
), []))
return h(Fragment, {},
h(Box, { display: 'flex', alignItems: 'center', gap: 1, mb: 1 },
Expand Down
13 changes: 13 additions & 0 deletions dev-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ but nothing is preventing a single plug-in from doing both tasks.

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.

## System icons

HFS defines "system icons" that will be used in the frontend, like the icon for the login.
A plugin can customize such icons by creating a folder called "icons" and putting an image file with
its name (excluding extension) matching one of the list:
*login, user, filter, search, search_off, close, error, stop, options, archive, logout, home, parent, folder, file,
spinner, password, download, upload, reload, lock, admin, check, to_start, to_end, menu, list, play, pause, edit, zoom,
delete, comment, link, info, cut, paste, shuffle, repeat, success, warning, audio, video, image, cancel, total*.

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.

For example, put a file "login.png" into "icons" to customize that icon.

## Exported object

`plugin.js` is a javascript module (executed by Node.js), and its main way to communicate with HFS is by exporting things.
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { state, useSnapState } from './state'
import { createElement as h, memo } from 'react'
import { SYS_ICONS } from './sysIcons'
import { getHFS } from '@hfs/shared'

const documentComplete = document.readyState === 'complete' ? Promise.resolve()
: new Promise(res => document.addEventListener('readystatechange', res))
Expand All @@ -19,14 +20,15 @@ interface IconProps { name:string, className?:string, alt?:string, [rest:string]
// name = null ? none : unicode ? unicode : "?" ? file_url : font_icon_class
export const Icon = memo(({ name, alt, className='', ...props }: IconProps) => {
if (!name) return null
name = getHFS().icons?.[name] ?? name
const [emoji, clazz=name] = SYS_ICONS[name] || []
const { iconsReady } = useSnapState()
className += ' icon'
const nameIsTheIcon = name.length === 1 ||
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]*$/)
const nameIsUrl = !nameIsTheIcon && /[/?]/.test(name)
const isFontIcon = iconsReady && clazz
className += nameIsUrl ? ' file-icon' : isFontIcon ? ` fa-${clazz}` : ' emoji-icon'
className += nameIsUrl ? ' file-icon' : isFontIcon ? ` font-icon fa-${clazz}` : ' emoji-icon'
return h('span',{
...alt ? { 'aria-label': alt } : { 'aria-hidden': true },
role: 'img',
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,11 @@ button {
background-color: var(--button-bg);
color: var(--button-text);
padding: .5em;
display: inline-flex; align-items: center; justify-content: center; // get closer results between chrome and safari
&:not(.before-sliding) { min-width: min-content; }
&.small { padding: 0 0.4em; height: 30px; }
.icon { position: relative; top: .05em; margin: -.2em 0; }
.icon { margin: -.2em 0; }
.font-icon { vertical-align: middle; }
border: transparent;
text-decoration: none;
border-radius: 0.3em;
Expand Down Expand Up @@ -242,6 +244,7 @@ kbd {
margin-right: -0.1em;
&:nth-child(-n+3) .icon {
padding: 0 0.2em;
height: 1em; // effective only on file-icon
}
}
#folder-stats, #filter-bar>span {
Expand Down Expand Up @@ -515,6 +518,7 @@ form label+input { margin-top: .2em; }
.popup-menu-button {
font-size: .8em; padding: .2em .3em; position: absolute; opacity: .8; white-space: nowrap;
&:hover,&:focus { opacity: 1 }
.icon { margin-right: 0.1em; }
}

.file-dialog .dialog { min-width: 13em; } /* more room for title */
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function MenuPanel() {
} : getSearchProps()),
h(Btn, {
id: 'options-button',
icon: 'settings',
icon: 'options',
label: t`Options`,
onClick: showOptions
}),
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function showOptions (){
newDialog({
title: t`Options`,
className: 'options-dialog',
icon: () => hIcon('settings'),
icon: () => hIcon('options'),
Content
})

Expand Down
3 changes: 1 addition & 2 deletions frontend/src/sysIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const SYS_ICONS: Record<string, [string] | [string, string | false]> = {
close: ['❌','cancel'],
error: ['❌','cancel'],
stop: ['⏹️'],
settings: ['⚙','cog'],
options: ['⚙','cog'],
archive: ['📦'],
logout: ['🚪'],
home: ['🏠'],
Expand Down Expand Up @@ -46,4 +46,3 @@ export const SYS_ICONS: Record<string, [string] | [string, string | false]> = {
cancel: ['❌','cancel'],
total: ['➕', 'spin6'],
}

1 change: 1 addition & 0 deletions src/cross-const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const FRONTEND_URI = SPECIAL_URI + 'frontend/'
export const ADMIN_URI = SPECIAL_URI + 'admin/'
export const API_URI = SPECIAL_URI + 'api/'
export const PLUGINS_PUB_URI = SPECIAL_URI + 'plugins/'
export const ICONS_URI = SPECIAL_URI + 'icons/'
export const PORT_DISABLED = -1
export const NBSP = '\xA0'
export const PLUGIN_CUSTOM_REST_PREFIX = '_'
Expand Down
30 changes: 30 additions & 0 deletions src/icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Callback, Dict } from "./cross"
import { basename, extname, join } from 'path'
import { watchDir } from './util-files'
import { debounceAsync } from './debounceAsync'
import { readdir } from 'fs/promises'
import events from './events'

export const ICONS_FOLDER = 'icons'

export type CustomizedIcons = undefined | Dict<string>
export let customizedIcons: CustomizedIcons
events.once('configReady', () => { // wait for cwd to be defined
watchIconsFolder('.', v => customizedIcons = v)
})
export function watchIconsFolder(parentFolder: string, cb: Callback<CustomizedIcons>) {
const iconsFolder = join(parentFolder, ICONS_FOLDER)
const watcher = watchDir(iconsFolder, debounceAsync(async () => {
let res: any = {} // reset
try {
for (const f of await readdir(iconsFolder, { withFileTypes: true })) {
if (!f.isFile()) continue
const k = basename(f.name, extname(f.name))
res[k] = f.name
}
cb(res)
}
catch { cb(undefined) } // no such dir
}), true)
return () => watcher.stop()
}
10 changes: 7 additions & 3 deletions src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import glob from 'fast-glob'
import { watchLoad } from './watchLoad'
import _ from 'lodash'
import { API_VERSION, APP_PATH, COMPATIBLE_API_VERSION, HTTP_NOT_FOUND, IS_WINDOWS, MIME_AUTO,
PLUGINS_PUB_URI } from './const'
import {
API_VERSION, APP_PATH, COMPATIBLE_API_VERSION, HTTP_NOT_FOUND, ICONS_URI, IS_WINDOWS, MIME_AUTO, PLUGINS_PUB_URI
} from './const'
import * as Const from './const'
import Koa from 'koa'
import {
Expand All @@ -31,6 +32,7 @@ import { getLangData } from './lang'
import { i18nFromTranslations } from './i18n'
import { ctxBelongsTo } from './perm'
import { getCurrentUsername } from './auth'
import { CustomizedIcons, ICONS_FOLDER, watchIconsFolder } from './icons'

export const PATH = 'plugins'
export const DISABLING_SUFFIX = '-disabled'
Expand Down Expand Up @@ -208,6 +210,7 @@ type OnDirEntry = (params:OnDirEntryParams) => void | false

export class Plugin implements CommonPluginInterface {
started: Date | null = new Date()
icons: CustomizedIcons

constructor(readonly id:string, readonly folder:string, private readonly data:any, private onUnload:()=>unknown){
if (!data) throw 'invalid data'
Expand Down Expand Up @@ -511,12 +514,13 @@ function watchPlugin(id: string, path: string) {
pluginData.getCustomHtml = () =>
Object.assign(Object.fromEntries(sections), callable(pluginData.customHtml) || {})

const unwatchIcons = watchIconsFolder(folder, v => plugin.icons = v)
const plugin = new Plugin(id, folder, pluginData, async () => {
unwatchIcons()
unwatch()
await Promise.allSettled(dbs.map(x => x.close()))
dbs.length = 0
})

if (alreadyRunning)
events.emit('pluginUpdated', Object.assign(_.pick(plugin, 'started'), getPluginInfo(id)))
else {
Expand Down
22 changes: 18 additions & 4 deletions src/serveGuiAndSharedFiles.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import Koa from 'koa'
import { basename, dirname } from 'path'
import { basename, dirname, join } from 'path'
import { getNodeName, nodeIsDirectory, statusCodeForMissingPerm, urlToNode, vfs, VfsNode, walkNode } from './vfs'
import { sendErrorPage } from './errorPages'
import events from './events'
import { ADMIN_URI, FRONTEND_URI, HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND,
HTTP_UNAUTHORIZED, HTTP_SERVER_ERROR, HTTP_OK } from './cross-const'
import {
ADMIN_URI, FRONTEND_URI, HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND,
HTTP_UNAUTHORIZED, HTTP_SERVER_ERROR, HTTP_OK, ICONS_URI
} from './cross-const'
import { uploadWriter } from './upload'
import formidable from 'formidable'
import { Writable } from 'stream'
import { serveFile, serveFileNode } from './serveFile'
import { BUILD_TIMESTAMP, DEV, VERSION } from './const'
import { BUILD_TIMESTAMP, DEV, MIME_AUTO, VERSION } from './const'
import { zipStreamFromFolder } from './zip'
import { allowAdmin, favicon } from './adminApis'
import { serveGuiFiles } from './serveGuiFiles'
import mount from 'koa-mount'
import { baseUrl } from './listen'
import { asyncGeneratorToReadable, deleteNode, filterMapGenerator, pathEncode, try_ } from './misc'
import { basicWeb, detectBasicAgent } from './basicWeb'
import { customizedIcons, ICONS_FOLDER } from './icons'
import { getPluginInfo } from './plugins'

const serveFrontendFiles = serveGuiFiles(process.env.FRONTEND_PROXY, FRONTEND_URI)
const serveFrontendPrefixed = mount(FRONTEND_URI.slice(0,-1), serveFrontendFiles)
Expand All @@ -38,6 +42,16 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
if (path.startsWith(ADMIN_URI))
return allowAdmin(ctx) ? serveAdminPrefixed(ctx,next)
: sendErrorPage(ctx, HTTP_FORBIDDEN)
if (path.startsWith(ICONS_URI)) {
const a = path.substring(ICONS_URI.length).split('/')
const iconName = a.at(-1)
if (!iconName) return
const plugin = a.length > 1 && getPluginInfo(a[0]!) // an extra level in the path indicates a plugin
const file = plugin ? plugin.icons?.[iconName] : customizedIcons?.[iconName]
if (!file) return
ctx.state.considerAsGui = true
return serveFile(ctx, join(plugin?.folder || '', ICONS_FOLDER, file), MIME_AUTO)
}
if (ctx.method === 'PUT') { // curl -T file url/
const decPath = decodeURIComponent(path)
let rest = basename(decPath)
Expand Down
16 changes: 13 additions & 3 deletions src/serveGuiFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@

import Koa from 'koa'
import fs from 'fs/promises'
import { API_VERSION, MIME_AUTO, FRONTEND_URI, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND,
PLUGINS_PUB_URI, VERSION, SPECIAL_URI } from './const'
import {
API_VERSION, MIME_AUTO, FRONTEND_URI, HTTP_METHOD_NOT_ALLOWED, HTTP_NO_CONTENT, HTTP_NOT_FOUND,
PLUGINS_PUB_URI, VERSION, SPECIAL_URI, ICONS_URI
} from './const'
import { serveFile } from './serveFile'
import { getPluginConfigFields, getPluginInfo, mapPlugins, pluginsConfig } from './plugins'
import { refresh_session } from './api.auth'
import { ApiError } from './apiMiddleware'
import { join, extname } from 'path'
import { CFG, debounceAsync, formatBytes, FRONTEND_OPTIONS, isPrimitive, newObj, onlyTruthy, parseFile } from './misc'
import {
CFG, debounceAsync, formatBytes, FRONTEND_OPTIONS, isPrimitive, newObj, objSameKeys, onlyTruthy, parseFile
} from './misc'
import { favicon, title } from './adminApis'
import { customHtml, getAllSections, getSection } from './customHtml'
import _ from 'lodash'
import { defineConfig, getConfig } from './config'
import { getLangData } from './lang'
import { dontOverwriteUploading } from './upload'
import { customizedIcons, CustomizedIcons } from './icons'

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)
const splitUploads = defineConfig(CFG.split_uploads, 0)
Expand Down Expand Up @@ -112,6 +117,7 @@ async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) {
forceTheme: mapPlugins(p => _.isString(p.isTheme) ? p.isTheme : undefined).find(Boolean),
customHtml: _.omit(getAllSections(), ['top', 'bottom', 'htmlHead', 'style']), // exclude the sections we already apply in this phase
...newObj(FRONTEND_OPTIONS, (v, k) => getConfig(k)),
icons: Object.assign({}, ...mapPlugins(p => iconsToObj(p.icons, p.id + '/')), iconsToObj(customizedIcons)), // name-to-uri
lang
}, null, 4).replace(/<(\/script)/g, '<"+"$1') /*avoid breaking our script container*/}
document.documentElement.setAttribute('ver', HFS.VERSION.split('-')[0])
Expand All @@ -121,6 +127,10 @@ async function treatIndex(ctx: Koa.Context, filesUri: string, body: string) {
<link rel="shortcut icon" href="/favicon.ico?${timestamp}" />
${getSection('htmlHead')}`}
`
function iconsToObj(icons: CustomizedIcons, pre='') {
return icons && objSameKeys(icons, (v, k) => ICONS_URI + pre + k)
}

if (isBody && isOpen)
return `${all}
${isFrontend && getSection('top')}
Expand Down
4 changes: 3 additions & 1 deletion src/util-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function readFileBusy(path: string): Promise<string> {
})
}

export function watchDir(dir: string, cb: ()=>void) {
export function watchDir(dir: string, cb: ()=>void, atStart=false) {
let watcher: ReturnType<typeof watch>
let paused = false
try {
Expand All @@ -49,6 +49,8 @@ export function watchDir(dir: string, cb: ()=>void) {
console.debug(String(e))
}
}
if (atStart)
controlledCb()
return {
working() { return Boolean(watcher) },
stop() { watcher?.close() },
Expand Down

0 comments on commit 1cd4fe4

Please sign in to comment.