mirror of
https://github.com/OctopusSuite/octopus-file-browser.git
synced 2026-10-14 16:37:54 +00:00
Build reusable octopus file browser
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
:root {
|
||||
color: #181c21;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
body { margin: 0; min-width: 280px; }
|
||||
|
||||
main {
|
||||
margin: 0 auto;
|
||||
max-width: 1220px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
main { padding: 8px; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import './demo.css';
|
||||
import './octopus-file-browser.js';
|
||||
import entries from 'virtual:octopus-repo-tree';
|
||||
|
||||
const browser = document.querySelector('octopus-file-browser');
|
||||
browser.entries = entries;
|
||||
@@ -0,0 +1,54 @@
|
||||
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
export function sortEntries(entries = []) {
|
||||
return [...entries].sort((a, b) =>
|
||||
Number(b.type === 'directory') - Number(a.type === 'directory') ||
|
||||
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '—';
|
||||
if (!bytes) return '0 B';
|
||||
const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1);
|
||||
const value = bytes / 1024 ** unit;
|
||||
return `${value.toFixed(unit && value < 10 ? 1 : 0)} ${UNITS[unit]}`;
|
||||
}
|
||||
|
||||
export function formatRelativeDate(value, now = Date.now()) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
const delta = date.getTime() - now;
|
||||
const abs = Math.abs(delta);
|
||||
const units = abs < 60 * 60e3
|
||||
? ['minute', 60e3]
|
||||
: abs < 24 * 60 * 60e3
|
||||
? ['hour', 60 * 60e3]
|
||||
: abs < 7 * 24 * 60 * 60e3
|
||||
? ['day', 24 * 60 * 60e3]
|
||||
: abs < 30 * 24 * 60 * 60e3
|
||||
? ['week', 7 * 24 * 60 * 60e3]
|
||||
: abs < 365 * 24 * 60 * 60e3
|
||||
? ['month', 30 * 24 * 60 * 60e3]
|
||||
: ['year', 365 * 24 * 60 * 60e3];
|
||||
return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
.format(Math.round(delta / units[1]), units[0]);
|
||||
}
|
||||
|
||||
export function isRecent(value, now = Date.now()) {
|
||||
const time = new Date(value).getTime();
|
||||
const age = now - time;
|
||||
return Number.isFinite(time) && age >= 0 && age < 30 * 24 * 60 * 60e3;
|
||||
}
|
||||
|
||||
export function fileKindLabel(name = '') {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === 'license' || lower.startsWith('license.')) return 'License';
|
||||
if (lower === 'third_party_notices') return 'Notices';
|
||||
if (lower.startsWith('.git')) return 'Git';
|
||||
const extension = lower.includes('.') ? lower.split('.').pop() : '';
|
||||
return ({
|
||||
css: 'CSS', html: 'HTML', js: 'JavaScript', json: 'JSON', md: 'Markdown',
|
||||
png: 'Image', svg: 'SVG', yml: 'YAML', yaml: 'YAML', ts: 'TypeScript',
|
||||
})[extension] || (extension ? extension.toUpperCase() : 'File');
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import css from 'material-icon-theme/icons/css.svg';
|
||||
import file from 'material-icon-theme/icons/file.svg';
|
||||
import folder from 'material-icon-theme/icons/folder.svg';
|
||||
import git from 'material-icon-theme/icons/git.svg';
|
||||
import html from 'material-icon-theme/icons/html.svg';
|
||||
import image from 'material-icon-theme/icons/image.svg';
|
||||
import javascript from 'material-icon-theme/icons/javascript.svg';
|
||||
import json from 'material-icon-theme/icons/json.svg';
|
||||
import license from 'material-icon-theme/icons/license.svg';
|
||||
import markdown from 'material-icon-theme/icons/markdown.svg';
|
||||
import npm from 'material-icon-theme/icons/npm.svg';
|
||||
import svg from 'material-icon-theme/icons/svg.svg';
|
||||
import typescript from 'material-icon-theme/icons/typescript.svg';
|
||||
import vite from 'material-icon-theme/icons/vite.svg';
|
||||
import yaml from 'material-icon-theme/icons/yaml.svg';
|
||||
|
||||
const EXTENSIONS = { css, htm: html, html, jpeg: image, jpg: image, js: javascript,
|
||||
json, md: markdown, mjs: javascript, png: image, svg, ts: typescript, yaml, yml: yaml };
|
||||
|
||||
export function resolveIcon(entry) {
|
||||
if (entry.icon) return entry.icon;
|
||||
if (entry.type === 'directory') return folder;
|
||||
const name = entry.name.toLowerCase();
|
||||
if (name === 'license' || name.startsWith('license.')) return license;
|
||||
if (name === 'package.json' || name === 'package-lock.json') return npm;
|
||||
if (name.startsWith('vite.config.')) return vite;
|
||||
if (name.startsWith('.git')) return git;
|
||||
return EXTENSIONS[name.split('.').pop()] || file;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
:host {
|
||||
--ofb-border: #d0d7de;
|
||||
--ofb-text: #181c21;
|
||||
--ofb-muted: #57606a;
|
||||
--ofb-link: #1f2328;
|
||||
--ofb-folder: #54aeff;
|
||||
--ofb-hover: #f6f8fa;
|
||||
--ofb-focus: #0969da;
|
||||
--ofb-recent: #d15700;
|
||||
color: var(--ofb-text);
|
||||
display: block;
|
||||
font: 14px/20px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto,
|
||||
"Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.list {
|
||||
border: 1px solid var(--ofb-border);
|
||||
border-radius: 5px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.entry {
|
||||
appearance: none;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--ofb-border);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
font: inherit;
|
||||
grid-template-columns: minmax(0, 1fr) 120px;
|
||||
margin: 0;
|
||||
min-height: 37px;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
li:first-child .entry { border-top: 0; }
|
||||
.entry:hover { background: var(--ofb-hover); }
|
||||
.entry:active { background: #eef1f4; }
|
||||
.entry:focus-visible { box-shadow: inset 0 0 0 2px var(--ofb-focus); outline: 0; }
|
||||
|
||||
.cell {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.name { gap: 8px; }
|
||||
.icon { flex: 0 0 auto; height: 18px; object-fit: contain; width: 18px; }
|
||||
.label, .modified { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.label { color: var(--ofb-link); min-width: 0; }
|
||||
.size {
|
||||
color: var(--ofb-muted);
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modified { color: var(--ofb-muted); }
|
||||
.modified.recent { color: var(--ofb-recent); }
|
||||
.modified { justify-content: flex-end; }
|
||||
|
||||
.empty {
|
||||
color: var(--ofb-muted);
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.entry {
|
||||
grid-template-columns: minmax(0, 1fr) 104px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.cell { padding: 10px 8px; }
|
||||
.name { gap: 7px; }
|
||||
.icon { height: 17px; width: 17px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.entry { transition: background-color 80ms ease; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import styles from './octopus-file-browser.css?inline';
|
||||
import { fileKindLabel, formatFileSize, formatRelativeDate, isRecent, sortEntries } from './file-browser-utils.js';
|
||||
import { resolveIcon } from './icon-resolver.js';
|
||||
|
||||
const HTMLElementBase = globalThis.HTMLElement || class {};
|
||||
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
export class OctopusFileBrowser extends HTMLElementBase {
|
||||
#entries = [];
|
||||
#path = [];
|
||||
|
||||
static get observedAttributes() { return ['empty-label']; }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() { this.#render(); }
|
||||
attributeChangedCallback() { if (this.isConnected) this.#render(); }
|
||||
|
||||
set entries(value) {
|
||||
this.#entries = Array.isArray(value) ? value : [];
|
||||
this.#path = [];
|
||||
this.#render();
|
||||
}
|
||||
|
||||
get entries() { return this.#entries; }
|
||||
get path() { return [...this.#path]; }
|
||||
|
||||
navigate(path = []) {
|
||||
let entries = this.#entries;
|
||||
const valid = [];
|
||||
for (const part of path) {
|
||||
const directory = entries.find((entry) => entry.type === 'directory' && entry.name === part);
|
||||
if (!directory) break;
|
||||
valid.push(part);
|
||||
entries = directory.children || [];
|
||||
}
|
||||
this.#path = valid;
|
||||
this.#render();
|
||||
this.#emit('octopus:navigate', { path: this.path, entries: this.#currentEntries() });
|
||||
}
|
||||
|
||||
#currentEntries() {
|
||||
return this.#path.reduce((entries, part) =>
|
||||
entries.find((entry) => entry.type === 'directory' && entry.name === part)?.children || [],
|
||||
this.#entries);
|
||||
}
|
||||
|
||||
#emit(name, detail) {
|
||||
this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
|
||||
}
|
||||
|
||||
#open(entry) {
|
||||
if (entry.parent) return this.navigate(this.#path.slice(0, -1));
|
||||
if (entry.type === 'directory') return this.navigate([...this.#path, entry.name]);
|
||||
this.#emit('octopus:open', { entry, path: [...this.#path, entry.name] });
|
||||
}
|
||||
|
||||
#renderEntry(entry) {
|
||||
const item = element('li');
|
||||
const row = element('button', 'entry');
|
||||
row.type = 'button';
|
||||
row.title = entry.parent ? 'Parent folder' : entry.name;
|
||||
row.setAttribute('aria-label', entry.parent
|
||||
? 'Parent folder'
|
||||
: `${entry.name}, ${entry.type === 'directory' ? 'folder' : fileKindLabel(entry.name)}`);
|
||||
row.addEventListener('click', () => this.#open(entry));
|
||||
|
||||
const name = element('span', 'cell name');
|
||||
const icon = element('img', 'icon');
|
||||
icon.alt = '';
|
||||
icon.src = resolveIcon(entry);
|
||||
name.append(icon, element('span', 'label', entry.name));
|
||||
if (entry.type !== 'directory') name.append(element('span', 'size', formatFileSize(entry.size)));
|
||||
const modified = element('span', 'cell modified', entry.modified ? formatRelativeDate(entry.modified) : '');
|
||||
if (entry.modified) {
|
||||
modified.title = new Date(entry.modified).toLocaleString();
|
||||
if (isRecent(entry.modified)) modified.classList.add('recent');
|
||||
}
|
||||
row.append(name, modified);
|
||||
item.append(row);
|
||||
return item;
|
||||
}
|
||||
|
||||
#render() {
|
||||
if (!this.shadowRoot || typeof document === 'undefined') return;
|
||||
const style = element('style');
|
||||
style.textContent = styles;
|
||||
const list = element('ul', 'list');
|
||||
list.setAttribute('aria-label', 'Files');
|
||||
const entries = sortEntries(this.#currentEntries());
|
||||
if (this.#path.length) list.append(this.#renderEntry({ name: '..', type: 'directory', parent: true }));
|
||||
if (entries.length) entries.forEach((entry) => list.append(this.#renderEntry(entry)));
|
||||
else list.append(element('li', 'empty', this.getAttribute('empty-label') || 'This folder is empty.'));
|
||||
this.shadowRoot.replaceChildren(style, list);
|
||||
}
|
||||
}
|
||||
|
||||
if (globalThis.customElements && !customElements.get('octopus-file-browser'))
|
||||
customElements.define('octopus-file-browser', OctopusFileBrowser);
|
||||
|
||||
export function createOctopusFileBrowser(target, options = {}) {
|
||||
const browser = document.createElement('octopus-file-browser');
|
||||
if (options.emptyLabel) browser.setAttribute('empty-label', options.emptyLabel);
|
||||
browser.entries = options.entries || [];
|
||||
target.append(browser);
|
||||
return browser;
|
||||
}
|
||||
|
||||
export { fileKindLabel, formatFileSize, formatRelativeDate, isRecent, sortEntries };
|
||||
Reference in New Issue
Block a user