mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d30433739 | ||
|
|
b45e3498b8 |
No files matched your search
@@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- Office previews can use embedded thumbnail if possible.
|
||||
- Text file previews are more efficient direct creation.
|
||||
- New text preview API (`/api/resources/preview/text`) returns a 16KB text preview for text, Markdown, HTML, CSS, code and RTF files. Markdown and HTML render in the listing as sanitized previews. Previews are not cached on disk. SVG files are served directly as images.
|
||||
- Text previews only render inline on tiles at least 60px wide, so narrower tiles (list, compact, normal) show the file-type icon with a `saved_search` badge instead when popup previews are enabled, and hovering still opens the larger popup preview. The text preview endpoint is now bounded by its own concurrency limiter (`server.numImageProcessors`), and rendered preview HTML is cached in the browser so scrolling and hovering do not re-parse it.
|
||||
- Embedded previews for Krita (`.kra`, [#2431](https://github.com/gtsteffaniak/filebrowser/issues/2431)), OpenRaster (`.ora`), Sketch (`.sketch`), MuseScore (`.mscz`), CBZ (first page), XPS (embedded thumbnail, when present), ODF (`.odt`, `.ods`, `.odp`, `.odg`), and Office Open XML (`.docx`, `.xlsx`, `.pptx`) thumbnails.
|
||||
- Virtual scrolling for large lists: like the main listing, advanced search, file tree, etc. Only render the rows on screen, so very large folders stay responsive, resolving some performance issues (#2879) (#1773) (#3320) (#3326).
|
||||
- Search and navigation improvements:
|
||||
@@ -45,6 +46,8 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- Media probes are cached by file identity and concurrent reads are coalesced, so opening a file no longer triggers repeated ffprobe runs.
|
||||
- Client-initiated stream cancels (seek/navigation) no longer log spurious write-error responses.
|
||||
- Gzipped `.woff` font files are no longer generated without being served; only `.js` and `.woff2` are precompressed.
|
||||
- Text preview tiles no longer show the bottom truncation fade; truncated content is clipped at the tile edge instead.
|
||||
- Text preview tiles lay out at a minimum width and are clipped, instead of wrapping aggressively on narrow tiles.
|
||||
- Advanced search no longer loses the previously selected sorting (#3275)
|
||||
- Context menu in iOS don't opening in Advanced Search (#2776) (#3299)
|
||||
- Improved mobile layout of inputs in search filters.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/icons"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/textpreview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/transcoding"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
|
||||
@@ -217,6 +218,9 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
|
||||
if err != nil {
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
// Bound concurrent text preview reads/conversions with the same worker count
|
||||
// the image pipeline uses, but on a separate limiter.
|
||||
textpreview.SetConcurrency(numWorkers)
|
||||
// Generate PWA icons after preview service is initialized
|
||||
if err := icons.GeneratePWAIcons(); err != nil {
|
||||
logger.Warningf("Failed to generate PWA icons: %v", err)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package textpreview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// defaultConcurrency is used when SetConcurrency has not run yet (tests, or an
|
||||
// unset server.numImageProcessors).
|
||||
const defaultConcurrency = 4
|
||||
|
||||
// Limiter bounds how many text previews are read and converted at once.
|
||||
//
|
||||
// It is deliberately separate from the image pipeline's semaphore in
|
||||
// internal/preview: a text preview is a 16KB read plus an optional RTF
|
||||
// conversion, so it should not compete with image decode/resize work and it
|
||||
// should not be limited to the image processor count.
|
||||
type Limiter struct {
|
||||
ch chan struct{}
|
||||
}
|
||||
|
||||
// NewLimiter returns a limiter that admits up to n concurrent callers.
|
||||
// n below 1 falls back to defaultConcurrency.
|
||||
func NewLimiter(n int) *Limiter {
|
||||
if n < 1 {
|
||||
n = defaultConcurrency
|
||||
}
|
||||
return &Limiter{ch: make(chan struct{}, n)}
|
||||
}
|
||||
|
||||
// Acquire takes a slot, waiting until one is free or ctx is done.
|
||||
func (l *Limiter) Acquire(ctx context.Context) error {
|
||||
select {
|
||||
case l.ch <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Release returns a slot taken by Acquire.
|
||||
func (l *Limiter) Release() {
|
||||
<-l.ch
|
||||
}
|
||||
|
||||
var (
|
||||
defaultMu sync.RWMutex
|
||||
defaultLimiter = NewLimiter(defaultConcurrency)
|
||||
)
|
||||
|
||||
// SetConcurrency replaces the package-level limiter with one of size n.
|
||||
// It is called once at startup, next to the preview generator.
|
||||
func SetConcurrency(n int) {
|
||||
defaultMu.Lock()
|
||||
defer defaultMu.Unlock()
|
||||
defaultLimiter = NewLimiter(n)
|
||||
}
|
||||
|
||||
// Acquire takes a slot on the package-level limiter.
|
||||
func Acquire(ctx context.Context) error {
|
||||
defaultMu.RLock()
|
||||
l := defaultLimiter
|
||||
defaultMu.RUnlock()
|
||||
return l.Acquire(ctx)
|
||||
}
|
||||
|
||||
// Release returns a slot taken by Acquire.
|
||||
func Release() {
|
||||
defaultMu.RLock()
|
||||
l := defaultLimiter
|
||||
defaultMu.RUnlock()
|
||||
l.Release()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package textpreview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewLimiterFallsBackToDefault(t *testing.T) {
|
||||
for _, n := range []int{0, -3} {
|
||||
l := NewLimiter(n)
|
||||
if got := cap(l.ch); got != defaultConcurrency {
|
||||
t.Fatalf("NewLimiter(%d) capacity = %d, want %d", n, got, defaultConcurrency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterBoundsConcurrency(t *testing.T) {
|
||||
const limit = 2
|
||||
l := NewLimiter(limit)
|
||||
|
||||
var running, maxRunning atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
release := make(chan struct{})
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := l.Acquire(context.Background()); err != nil {
|
||||
t.Errorf("Acquire: %v", err)
|
||||
return
|
||||
}
|
||||
defer l.Release()
|
||||
cur := running.Add(1)
|
||||
for {
|
||||
prev := maxRunning.Load()
|
||||
if cur <= prev || maxRunning.CompareAndSwap(prev, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
<-release
|
||||
running.Add(-1)
|
||||
}()
|
||||
}
|
||||
|
||||
// Let the admitted goroutines block on release; the rest must be waiting.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := running.Load(); got != limit {
|
||||
t.Fatalf("running = %d, want %d", got, limit)
|
||||
}
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if got := maxRunning.Load(); got != limit {
|
||||
t.Fatalf("max concurrent = %d, want %d", got, limit)
|
||||
}
|
||||
if got := len(l.ch); got != 0 {
|
||||
t.Fatalf("slots still held after release: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterAcquireHonoursCancellation(t *testing.T) {
|
||||
l := NewLimiter(1)
|
||||
if err := l.Acquire(context.Background()); err != nil {
|
||||
t.Fatalf("first Acquire: %v", err)
|
||||
}
|
||||
defer l.Release()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := l.Acquire(ctx); err == nil {
|
||||
t.Fatal("expected an error when the context is already cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// A caller that never releases must not wedge later callers forever; the
|
||||
// package-level limiter also has to survive SetConcurrency swapping it.
|
||||
func TestSetConcurrencySwapsLimiter(t *testing.T) {
|
||||
original := defaultLimiter
|
||||
t.Cleanup(func() {
|
||||
defaultMu.Lock()
|
||||
defaultLimiter = original
|
||||
defaultMu.Unlock()
|
||||
})
|
||||
|
||||
SetConcurrency(3)
|
||||
if err := Acquire(context.Background()); err != nil {
|
||||
t.Fatalf("Acquire after SetConcurrency: %v", err)
|
||||
}
|
||||
Release()
|
||||
|
||||
defaultMu.RLock()
|
||||
got := cap(defaultLimiter.ch)
|
||||
defaultMu.RUnlock()
|
||||
if got != 3 {
|
||||
t.Fatalf("capacity = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.activity-details-table[data-v-be79e288] thead th,.activity-details-table[data-v-be79e288] tbody td{padding:.5em}.activity-details-table[data-v-be79e288] tbody td:first-child{color:var(--textSecondary);white-space:nowrap;vertical-align:top;font-weight:500}.activity-details-table[data-v-be79e288] tbody td:last-child{overflow-wrap:break-word;white-space:pre-wrap}.activity-table-value-info[data-v-d0f8eda4] thead th,.activity-table-value-info[data-v-d0f8eda4] tbody td{padding:.5em}.activity-table-value-info[data-v-d0f8eda4] tbody td{overflow-wrap:break-word;white-space:pre-wrap}.share-picker-field[data-v-97464281]{align-items:stretch;gap:.35rem;max-width:100%;display:flex}.unified-share-picker[data-v-97464281]{overflow-wrap:break-word;box-sizing:border-box;flex:1;justify-content:flex-start;align-items:center;min-width:0;display:flex}.share-clear[data-v-97464281]{min-width:unset;flex-shrink:0;padding:.35rem}.share-clear .material-symbols[data-v-97464281]{font-size:1.1rem}.activity-viewer[data-v-2d793054]{max-width:1200px;margin-left:auto;margin-right:auto;padding:1em}.activity-scope-card[data-v-2d793054]{margin-bottom:1rem}.activity-scope-select[data-v-2d793054]{width:100%;padding:.65rem .85rem;font-size:1rem}.path-filter-mode[data-v-2d793054]{flex-wrap:wrap;gap:1rem;margin-bottom:.75rem;display:flex}.mode-option[data-v-2d793054]{cursor:pointer;align-items:center;gap:.35rem;font-size:.9rem;display:flex}.path-filter-picker[data-v-2d793054]{margin-top:.75rem}.path-filter-picker[data-v-2d793054] .unified-path-picker{max-width:100%}.path-filter-glob[data-v-2d793054]{margin-top:.75rem}.glob-fields[data-v-2d793054]{flex-wrap:wrap;gap:.75rem;display:flex}.glob-field[data-v-2d793054]{flex:1;min-width:10rem}.glob-field-wide[data-v-2d793054]{flex:2;min-width:14rem}.share-hash-field[data-v-2d793054]{margin-top:.75rem}.activity-viewer-results[data-v-2d793054]{margin-top:1.25rem;margin-bottom:2em}.results-table[data-v-2d793054]{min-width:0}.results-table-scroll[data-v-2d793054]{max-width:100%}.results-table-scroll[data-v-2d793054] .settings-table{table-layout:fixed;width:100%}.results-table-scroll[data-v-2d793054] .settings-table tbody td{max-width:0;overflow:hidden}@media (width<=768px){.results-table-scroll[data-v-2d793054]{width:100%;max-width:1000px;overflow-x:auto}.results-table-scroll[data-v-2d793054] .settings-table{table-layout:auto;width:max-content;min-width:100%;max-width:1000px}.results-table-scroll[data-v-2d793054] .settings-table thead th,.results-table-scroll[data-v-2d793054] .settings-table tbody td{white-space:nowrap;max-width:none}}.results-table-scroll[data-v-2d793054] .table-value-cell{min-width:0;max-width:100%;display:block;overflow:hidden}.results-table-scroll[data-v-2d793054] .table-value-badge,.results-table-scroll[data-v-2d793054] .detail-badge{box-sizing:border-box;vertical-align:top;border:1px solid var(--divider);white-space:nowrap;text-overflow:ellipsis;min-width:0;max-width:100%;color:var(--textPrimary,inherit);background:0 0;padding:.12rem .5rem;font-size:.8em;display:inline-block;overflow:hidden}.results-table-scroll[data-v-2d793054] .details-muted{color:var(--textSecondary);font-size:.9em}.results-stats[data-v-2d793054]{color:var(--textSecondary);flex-wrap:wrap;align-items:baseline;gap:.75rem 2rem;margin-bottom:.75rem;font-size:.95rem;display:flex}.results-chart[data-v-2d793054]{margin-top:.25rem}.chart-interval-note[data-v-2d793054]{color:var(--textSecondary);margin:0 0 .75rem;font-size:.9rem}.results-empty[data-v-2d793054]{text-align:center;padding:3rem 1.5rem 4rem}.results-empty-hint[data-v-2d793054]{color:var(--textSecondary);margin:0;font-size:.95rem}.results-empty .lonely-message[data-v-2d793054]{margin-bottom:.5rem}.config-grid[data-v-2d793054]{grid-template-columns:repeat(auto-fill,minmax(12rem,1fr));align-items:end;gap:1rem;display:grid}.config-grid.mobile[data-v-2d793054]{grid-template-columns:1fr}.config-field[data-v-2d793054]{min-width:0}.config-field h3[data-v-2d793054]{text-align:center;margin:0 0 .5rem;font-size:.95rem;font-weight:600}.config-field-wide[data-v-2d793054]{grid-column:1/-1}.custom-range[data-v-2d793054]{flex-wrap:wrap;grid-column:1/-1;gap:.5rem;display:flex}.custom-range .input[data-v-2d793054]{flex:1;min-width:10rem}.config-actions[data-v-2d793054]{flex-wrap:wrap;grid-column:1/-1;align-items:center;gap:.5rem;margin-top:.5rem;display:flex}.button[data-v-2d793054]{align-items:center;gap:.5rem;display:flex}.button .material-symbols[data-v-2d793054]{font-size:1.2rem}.pagination[data-v-2d793054]{justify-content:center;align-items:center;gap:1rem;margin-top:1rem;display:flex}.page-label[data-v-2d793054]{opacity:.85}.details-cell-wrap[data-v-2d793054]{min-width:0;max-width:100%;overflow:hidden}.details-badges[data-v-2d793054]{flex-wrap:nowrap;gap:.35rem;min-width:0;max-width:100%;display:flex;overflow:hidden}.results-table-scroll[data-v-2d793054] .details-badges .detail-badge{vertical-align:top;flex:1 1 0;min-width:0;max-width:100%;display:inline-block}.details-restriLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
.settings-number-input[data-v-6cbd721d],.settings-number-input>div[data-v-6cbd721d]{align-items:center;gap:1em;display:flex}.settings-number-input[data-v-6cbd721d]{flex-wrap:wrap;justify-content:space-between}.range-value[data-v-6cbd721d]{text-align:center;min-width:4ch}.advanced-search-root[data-v-6cbd721d]{flex-direction:column;flex:1;align-self:stretch;width:100%;min-height:0;display:flex}.advanced-search-toolbar[data-v-6cbd721d]{margin:1em}.advanced-search-card-form[data-v-6cbd721d]{box-sizing:border-box;flex-direction:column;width:100%;display:flex}.advanced-search-config-row[data-v-6cbd721d]{box-sizing:border-box;flex-wrap:wrap;align-items:flex-start;gap:1.25rem;width:100%;display:flex}.config-primary[data-v-6cbd721d],.config-secondary[data-v-6cbd721d]{box-sizing:border-box;flex:min(500px,100%);min-width:min(500px,100%);max-width:100%}.config-secondary[data-v-6cbd721d]{flex-direction:column;gap:1rem;display:flex}.advanced-search-options-section[data-v-6cbd721d]{box-sizing:border-box;width:100%}.advanced-search-options-section[data-v-6cbd721d] .settings-group{width:100%;max-width:100%}.as-section-label[data-v-6cbd721d]{color:var(--textSecondary);text-transform:uppercase;letter-spacing:.02em;margin-bottom:.35rem;font-size:.8125rem;font-weight:600}.term-join-divider[data-v-6cbd721d]{box-sizing:border-box;justify-content:center;align-items:center;gap:.2em;width:fit-content;max-width:100%;margin:.25em auto;display:flex}.term-join-line[data-v-6cbd721d]{background-color:var(--divider,#aaa);flex:none;align-self:center;width:1.65em;height:1px}.term-join-text[data-v-6cbd721d]{color:var(--textSecondary);-webkit-user-select:none;user-select:none;flex-shrink:0;font-size:.8125rem;font-weight:600;line-height:1.2}.advanced-search-inline-header[data-v-6cbd721d]{flex-shrink:0;width:100%}.search-term-row[data-v-6cbd721d]{align-items:stretch;gap:0;display:flex}.flex-grow-input[data-v-6cbd721d]{flex:1;min-width:0}.add-term-button[data-v-6cbd721d]{margin:.35rem 0 .75rem}.advanced-search-switch[data-v-6cbd721d]{margin-top:.25rem}.source-dropdown-select[data-v-6cbd721d]{width:100%}.scope-picker[data-v-6cbd721d]{margin-top:.35rem}.advanced-search-actions-row[data-v-6cbd721d]{flex-wrap:wrap;align-items:center;gap:.75rem;width:100%;margin-top:auto;display:flex}.search-submit[data-v-6cbd721d]{margin-left:auto}@media (width<=559px){.advanced-search-actions-row .search-submit[data-v-6cbd721d]{flex:100%;width:100%;margin-left:0}}.advanced-search-results-plane[data-v-6cbd721d]{flex-direction:column;flex:1;width:100%;min-height:min(65vh,800px);display:flex}.results-loading[data-v-6cbd721d]{justify-content:center;align-items:center;gap:.75em;padding:2rem;display:flex}.padded-message[data-v-6cbd721d]{padding:1.5rem}.error-message[data-v-6cbd721d]{color:var(--error,#c00)}.empty-state-message[data-v-6cbd721d]{text-align:center;color:var(--textSecondary)}.spin[data-v-6cbd721d]{animation:1s linear infinite rotate-6cbd721d}@keyframes rotate-6cbd721d{to{transform:rotate(360deg)}}.advanced-search-listing-results[data-v-6cbd721d]{margin:0 1em 1em}.advanced-search-inner[data-v-6cbd721d]{margin-top:.5em}.advanced-search-options-pane[data-v-6cbd721d]{box-sizing:border-box;width:100%}.search-filter-dropdowns[data-v-6cbd721d]{flex-wrap:wrap;justify-content:center;align-items:flex-start;gap:.5em;margin-bottom:.5em;display:flex}.search-filter-dropdowns[data-v-6cbd721d] .expand-dropdown{flex:0 auto;width:auto;min-width:15em;max-width:15em}.search-filter-dropdowns[data-v-6cbd721d] .expand-dropdown-anchor.menu-panel{min-width:0}.advanced-search-options-pane .constraints[data-v-6cbd721d]{box-sizing:border-box;width:calc(100% - 2em);margin-left:1em;margin-right:1em}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.viewer-background[data-v-47d3581c]{box-sizing:border-box;background-color:#f0f2f5;width:100%;height:100%;padding:2em;overflow:hidden auto}.docx-page[data-v-47d3581c]{box-sizing:border-box;color:#000;overflow-wrap:anywhere;background:#fff;width:min(8.5in,100%);min-height:11in;margin:0 auto;padding:clamp(1em,8vw,1in);box-shadow:0 0 10px #00000026}.docx-page[data-v-47d3581c] img,.docx-page[data-v-47d3581c] svg,.docx-page[data-v-47d3581c] video{max-width:100%;height:auto}.docx-page[data-v-47d3581c] table{border-collapse:collapse;max-width:100%;margin:1em 0;display:block;overflow-x:auto}.docx-page[data-v-47d3581c] td,.docx-page[data-v-47d3581c] th{vertical-align:top;text-align:left;border:1px solid #000;padding:.4em .7em}.docx-page[data-v-47d3581c] th{background-color:#f3f4f6;font-weight:600}.docx-page[data-v-47d3581c] td>p,.docx-page[data-v-47d3581c] th>p{margin:0}.docx-page[data-v-47d3581c] pre{max-width:100%;overflow-x:auto}.status-text[data-v-47d3581c]{text-align:center;color:#333;padding:3em;font-family:sans-serif;font-size:1.2em}.status-text.error[data-v-47d3581c]{color:var(--red)}@media (width<=8.5in){.viewer-background[data-v-47d3581c]{padding:0}.docx-page[data-v-47d3581c]{min-height:100%;box-shadow:none}}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.duplicate-finder[data-v-83e23830]{max-width:1200px;margin-left:auto;margin-right:auto;padding:1em}.duplicate-finder-results[data-v-83e23830]{margin-bottom:2em}.button[data-v-83e23830]{align-items:center;gap:.5rem;margin-top:1rem;display:flex}.button .material-symbols[data-v-83e23830]{font-size:1.2rem}.error-message[data-v-83e23830]{color:var(--dark-red);border:1px solid color-mix(in srgb, var(--red), transparent 60%);background:#fee;border-radius:4px;margin-bottom:1rem;padding:1rem}.hint[data-v-83e23830]{color:var(--textSecondary);margin-top:.25rem;margin-bottom:1rem;font-size:.85rem}.stats[data-v-83e23830]{background:var(--surfaceSecondary);border-radius:4px;gap:2rem;margin-bottom:1.5rem;padding:1rem;display:flex}.duplicate-groups[data-v-83e23830]{flex-direction:column;gap:1.5rem;display:flex}.duplicate-group[data-v-83e23830]{border:1px solid var(--divider);border-radius:var(--borderRadius);overflow:hidden}.group-header[data-v-83e23830]{background:var(--surfaceSecondary);border-radius:.5em;align-items:center;gap:1rem;padding:1rem;display:flex}.group-title[data-v-83e23830]{font-size:1rem;font-weight:600}.group-size[data-v-83e23830]{color:var(--textSecondary);font-size:.9rem}.wasted-space[data-v-83e23830]{color:var(--icon-red);margin-left:auto;font-size:.9rem;font-weight:600}.group-files[data-v-83e23830]{flex-direction:column;padding:0;display:flex}.file-item-wrapper[data-v-83e23830]{border:1px solid #0000001a;border-top:0;border-radius:0;align-items:center;gap:.5rem;padding:.5em;display:flex}.file-item-wrapper[data-v-83e23830]:first-child{border-top:1px solid #0000001a}.file-item-wrapper.deleted[data-v-83e23830]{opacity:.5;background:var(--surfaceSecondary)}.file-item-wrapper.failed[data-v-83e23830]{border-left:3px solid var(--icon-red)}.file-item-content[data-v-83e23830]{flex-direction:column;flex:1;gap:.25rem;display:flex}.group-files .listing-item[data-v-83e23830]{cursor:pointer;width:100%;margin:.25em}.group-files .listing-item.activebutton[data-v-83e23830]{background:var(--primaryColor)!important;color:#fff!important}.path-text[data-v-83e23830]{white-space:nowrap;text-overflow:ellipsis;overflow:hidden;color:var(--textSecondary)!important;font-size:.75rem!important}.empty-state[data-v-83e23830]{text-align:center;color:var(--textSecondary);padding:4rem 2rem}.empty-state .material-symbols[data-v-83e23830]{opacity:.3;margin-bottom:1rem;font-size:4rem}.empty-state p[data-v-83e23830]{font-size:1.1rem}.spin[data-v-83e23830]{animation:1s linear infinite spin-83e23830}@keyframes spin-83e23830{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@media (width<=768px){.duplicate-finder[data-v-83e23830]{padding:1rem}.stats[data-v-83e23830]{flex-direction:column;gap:.5rem}.group-header[data-v-83e23830]{flex-wrap:wrap}.wasted-space[data-v-83e23830]{width:100%;margin-left:0}}
|
||||
@@ -0,0 +1 @@
|
||||
.editor-toolbar[data-v-83c0efe7]{background:var(--background);border-bottom:1px solid var(--alt-background);scrollbar-width:none;flex-shrink:0;align-items:center;gap:.15em;padding:.35em 0;display:flex;overflow:auto hidden}.editor-toolbar-btn[data-v-83c0efe7]{border-radius:var(--borderRadius);width:2em;height:2em;color:var(--textPrimary);cursor:pointer;background:0 0;border:none;flex-shrink:0;justify-content:center;align-items:center;transition:opacity .15s;display:flex;position:relative}.md-toolbar-group[data-v-83c0efe7]{flex-shrink:0;display:flex}.editor-toolbar-sticky[data-v-83c0efe7]{z-index:1;background:var(--background);border-right:1px solid var(--alt-background);flex-shrink:0;align-items:center;margin:-.35em 0;padding:.25em;display:flex;position:sticky;left:0}.editor-toolbar-sticky--right[data-v-83c0efe7]{border-left:1px solid var(--alt-background);border-right:none;padding-right:calc(.25em + 1px);right:-1px}.toolbar-color-indicator[data-v-83c0efe7]{background-color:var(--alt-background);width:1.4em;height:.5em;box-shadow:inset 0 0 0 1px var(--alt-background);cursor:pointer;border-radius:.2em;position:absolute;bottom:.15em;left:50%;transform:translate(-50%)}.editor-toolbar-btn .material-symbols.toolbar-color-glyph[data-v-83c0efe7]{font-size:1em;transform:translateY(-.2em)}.toolbar-color-indicator--empty[data-v-83c0efe7]{opacity:0;background-color:#0000}.color-input[data-v-83c0efe7]{opacity:0;cursor:pointer;border:none;width:100%;height:100%;margin:0;padding:0;position:absolute;inset:0}.editor-toolbar-btn[data-v-83c0efe7]:disabled{opacity:.35}.editor-toolbar-btn .material-symbols[data-v-83c0efe7]{font-size:1.2em}.toolbar-icon-enter-active[data-v-83c0efe7],.toolbar-icon-leave-active[data-v-83c0efe7]{transition:opacity .12s,transform .12s}.toolbar-icon-enter-from[data-v-83c0efe7],.toolbar-icon-leave-to[data-v-83c0efe7]{opacity:0;transform:scale(.6)}.editor-toolbar-menu[data-v-83c0efe7]{z-index:9999;margin:0;padding:.25em .25em .65em;list-style:none;position:fixed}.editor-toolbar-menu--icon-menu[data-v-83c0efe7]{gap:.15em;padding-bottom:.5em;display:flex}.editor-toolbar-menu-btn[data-v-83c0efe7]{justify-content:flex-start;gap:.5em;width:100%;height:auto;padding:.5em .75em}.editor-toolbar-menu-btn .material-symbols[data-v-83c0efe7]{font-size:1.1em}.expand-enter-active[data-v-83c0efe7],.expand-leave-active[data-v-83c0efe7]{overflow:hidden}.split-divider[data-v-351bb28e]{cursor:col-resize;background-color:#0000;flex:0 0 5px;position:relative}.split-divider[data-v-351bb28e]:before{content:"";background-color:var(--alt-background);width:1px;position:absolute;top:0;bottom:0;left:2px}.split-divider[data-v-351bb28e]:hover:before,.split-divider.resizing[data-v-351bb28e]:before{background-color:var(--primaryColor)}.split-preview-pane[data-v-351bb28e]{flex:none;min-width:0;height:100%;position:relative;overflow:hidden}.split-preview-scrollbar[data-v-351bb28e]{scrollbar-width:none;width:100%;height:100%;overflow-y:auto}.split-preview-scrollbar[data-v-351bb28e]::-webkit-scrollbar{width:0;display:none}#editor-root[data-v-c1ccc708]{height:100%}#editor-root.split-active[data-v-c1ccc708]{width:100%;height:100%;display:flex}#editor-container[data-v-c1ccc708]{flex-direction:column;width:100%;height:100%;display:flex;position:relative;overflow:hidden}#editor-root.split-active #editor-container[data-v-c1ccc708]{flex:none;min-width:0;position:relative}#editor-container #editor[data-v-c1ccc708]{flex:1;min-height:0}#editor-container.viewer-mode[data-v-c1ccc708]{position:absolute;inset:0}.ace_editor{--ace-dark-bg:#151515;--ace-popup-bg:#1a1a1a;--ace-control-bg:#232323;font-size:14px;line-height:1.4;z-index:1!important}.ace_content{-webkit-user-select:text;user-select:text}.ace_editor .ace_selection{background-color:color-mix(in srgb, var(--primaryColor) 25%, transparent)!important}.ace_editor .ace_selection.ace_start{box-shadow:0 0 3px 0 color-mix(in srgb, var(--primaryColor) 40%, transparent)!important}.ace_editor .ace_gutter-active-line{background-color:color-mix(in srgb, var(--primaryColor) 20%, transparent)!important;color:var(--primaryColor)!important;font-weight:700!important}.ace_editor .ace_indent-guide{border-right:1px solid color-mix(in srgb, var(--primaryColor) 50%, transparent)!important;opacity:1!important;z-index:5!important}.ace_editor .ace_indent-guide-active{border-right:1px solid color-mix(in srgb, var(--primaryColor) 75%, transparent)!important}.ace_editor.ace_dark{background-color:var(--ace-dark-bg)!important}.ace_editor.ace_dark .ace_marker-layer .ace_active-line{background:color-mix(in srgb, var(--surfaceSecondary) 35%, transparent)}#editor-root.split-active .ace_scrollbar{scrollbar-width:none}#editor-root.split-active .ace_scrollbar::-webkit-scrollbar{display:none}.ace_editor .ace_search{color:var(--textPrimary);border-color:var(--divider)}.ace_editor.ace_dark .ace_search{background-color:var(--ace-popup-bg)}.ace_editor .ace_search_field{color:var(--textPrimary);border:1px solid var(--divider)}.ace_editorLine truncated
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.epub-container[data-v-2f4b46b5]{background-color:var(--background);z-index:1000;width:100%;height:100%;position:relative}.loading-indicator[data-v-2f4b46b5]{height:100%;color:var(--textSecondary);justify-content:center;align-items:center;font-size:1.2em;display:flex}#viewer[data-v-2f4b46b5]{visibility:hidden;width:100%;height:100%}#viewer.ready[data-v-2f4b46b5]{visibility:visible}.navigation[data-v-2f4b46b5]{z-index:1001;background-color:var(--surfacePrimary);border-radius:var(--borderRadius);align-items:center;gap:1em;padding:.5em;display:flex;position:absolute;bottom:1em;left:50%;transform:translate(-50%);box-shadow:0 2px 10px #0000001a}.nav-button[data-v-2f4b46b5]{border-radius:var(--borderRadius);color:var(--textPrimary);cursor:pointer;background-color:#0000;padding:.25em 1em;font-size:1.5em;transition:background-color .2s}.nav-button[data-v-2f4b46b5]:hover{background-color:var(--hoverOverlay);color:var(--primaryColor)}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.image-ex-container{overscroll-behavior:none;touch-action:none;justify-content:center;width:100%;max-width:100%;height:100%;min-height:0;max-height:100%;display:flex;position:relative;overflow:hidden}.image-ex-img{object-fit:contain;max-width:100%;max-height:100%;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.image-loading-overlay{z-index:10;justify-content:center;align-items:center;display:flex;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.file-watcher[data-v-a9d38c6d]{max-width:1200px;margin-left:auto;margin-right:auto;padding:1em}.file-watcher-config[data-v-a9d38c6d]{margin-bottom:2em}.searchContext[data-v-a9d38c6d]{margin-bottom:0!important}.config-row[data-v-a9d38c6d]{flex-wrap:nowrap;align-items:center;gap:.75rem;min-width:0;display:flex;padding:1em!important}.config-row.mobile[data-v-a9d38c6d]{flex-direction:column;align-items:stretch}.config-row-second[data-v-a9d38c6d]{flex:none;align-items:center;gap:.75rem;display:flex}.config-row-second.mobile[data-v-a9d38c6d]{grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:.5rem;width:100%;display:grid}.config-item[data-v-a9d38c6d]{align-items:center;display:flex}.config-item.lines-input[data-v-a9d38c6d]{flex-direction:row;align-items:center;gap:.5rem}.lines-label[data-v-a9d38c6d]{color:var(--textSecondary);white-space:nowrap;flex-shrink:0;margin:0;font-size:.875rem}.file-picker-button[data-v-a9d38c6d]{white-space:nowrap;text-overflow:ellipsis;align-items:center;gap:.5rem;width:100%;display:flex;overflow:hidden}.file-picker[data-v-a9d38c6d]{flex:auto;min-width:0}.config-item.interval-select[data-v-a9d38c6d]{min-width:0}.config-row.mobile .file-picker[data-v-a9d38c6d]{width:100%}.file-watcher-output[data-v-a9d38c6d]{width:100%;padding:0!important}.status-indicator[data-v-a9d38c6d]{color:var(--textSecondary);align-items:center;gap:.5rem;font-size:.9rem;display:flex}.terminal-header[data-v-a9d38c6d]{background:var(--surfacePrimary);border-bottom:1px solid var(--divider);border-bottom-right-radius:0;border-bottom-left-radius:0;flex-direction:column;gap:.5rem;margin-bottom:0;padding:.75rem 1rem;font-size:.9rem;display:flex}.header-row[data-v-a9d38c6d]{justify-content:space-between;align-items:center;display:flex;position:relative}.header-row-first[data-v-a9d38c6d]{min-height:1.5rem}.header-row-second[data-v-a9d38c6d]{min-height:1.5rem;padding-top:.25rem}.header-row-second-mobile[data-v-a9d38c6d]{justify-content:center;min-height:1.5rem;padding-top:.25rem}.header-row-third-mobile[data-v-a9d38c6d]{min-height:1.5rem;padding-top:.25rem}.header-center-mobile[data-v-a9d38c6d]{justify-content:center;align-items:center;gap:.5rem;width:100%;display:flex}.header-left[data-v-a9d38c6d],.header-center[data-v-a9d38c6d],.header-right[data-v-a9d38c6d]{align-items:center;gap:.5rem;display:flex}.header-center[data-v-a9d38c6d]{white-space:nowrap;position:absolute;left:50%;transform:translate(-50%)}.header-right[data-v-a9d38c6d]{justify-content:flex-end;margin-left:auto}.header-filename[data-v-a9d38c6d]{color:var(--textPrimary);font-weight:700}.header-filesize[data-v-a9d38c6d]{color:var(--textSecondary)}.header-label[data-v-a9d38c6d]{color:var(--textSecondary);font-weight:500}.header-value[data-v-a9d38c6d]{color:var(--textPrimary);font-weight:600}.header-value.latency-good[data-v-a9d38c6d]{color:var(--icon-green)}.header-value.latency-ok[data-v-a9d38c6d]{color:var(--icon-deep-orange)}.header-value.latency-slow[data-v-a9d38c6d]{color:var(--icon-red)}.header-value.latency-inactive[data-v-a9d38c6d]{color:var(--textSecondary)}.status-dot[data-v-a9d38c6d]{background:var(--icon-green);border-radius:50%;width:8px;height:8px;animation:2s infinite pulse-a9d38c6d}@keyframes pulse-a9d38c6d{0%,to{opacity:1}50%{opacity:.5}}.terminal-output[data-v-a9d38c6d]{background:var(--iconBackground);min-height:100px;color:var(--textPrimary);border-top-left-radius:0;border-top-right-radius:0;padding:1rem;font-family:Courier New,Consolas,Monaco,monospace;font-size:14px;overflow:auto}.terminal-line[data-v-a9d38c6d]{white-space:nowrap;align-items:flex-start;gap:.5rem;margin-bottom:2px;line-height:1.5;display:flex}.terminal-text[data-v-a9d38c6d]{white-space:nowrap}.empty-state[data-v-a9d38c6d]{text-align:center;color:var(--textSecondary);padding:4rem 2rem}.empty-state .material-symbols[data-v-a9d38c6d]{opacity:.3;margin-bottom:1rem;font-size:4rem}.empty-state p[data-v-a9d38c6d]{font-size:1.1rem}@media (width<=768px){.file-watcher[data-v-a9d38c6d]{padding:1rem}.config-row[data-v-a9d38c6d]{flex-direction:column;align-items:stretch;gap:.5rem}.config-item.file-picker[data-v-a9d38c6d]{width:100%!important}.config-item.file-picker .file-picker-button[data-v-a9d38c6d]{width:100%}.config-row-second[data-v-a9d38c6d]{grid-template-columns:minmax(0,1fr) auto auto;gap:.5rem;width:100%;display:grid}.config-item.interval-select[data-v-a9d38c6d],.config-item.lines-input[data-v-a9d38c6d],.config-item.play-button[data-v-a9d38c6d]{flex:1;align-items:center;width:auto!important}.terminal-output[data-v-a9d38c6d]{min-height:100px;font-size:12px}}@media (width<=1100px){.config-row[data-v-a9d38c6d]:not(.mobile){flex-direction:column;align-items:stretch;gap:.5rem}.config-row:not(.mobile) .config-row-second[data-v-a9d38c6d]{grid-template-columns:minmax(0,1fr) auto auto;gap:.5rem;width:100%;display:grid}}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
#markedown-viewer{overflow-wrap:break-word;margin:1em}#markedown-viewer.html-viewer-mode{box-sizing:border-box;flex-direction:column;width:calc(100% - 1em);margin:.5em;display:flex;overflow:hidden}#markedown-viewer .markdown-content-container{background-color:color-mix(in srgb, var(--surfacePrimary) 75%, transparent);border-radius:var(--borderRadius);box-shadow:var(--surfaceElevationShadow);margin:0 auto;padding:1.2em}#markedown-viewer .markdown-content,#markedown-viewer .html-content{width:100%}#markedown-viewer .html-content{box-sizing:border-box;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;background:#fff;border:none;min-height:24em}@media (prefers-color-scheme:dark){#markedown-viewer .html-content{--lightningcss-light: ;--lightningcss-dark:initial}}#markedown-viewer.html-viewer-mode .html-content{flex:auto;width:100%;height:100%;min-height:0}#markedown-viewer .html-content img{max-width:100%;height:auto}#markedown-viewer .spacer{pointer-events:none;width:100%}#markedown-viewer .markdown-content-container .code-block-wrapper{background-color:var(--background);border-radius:.5em;max-width:100%;margin:1em 0;font-family:SFMono-Regular,Monaco,Inconsolata,Liberation Mono,Courier New,monospace;font-size:.85em;line-height:1.45;display:flex;position:relative;overflow:hidden}#markedown-viewer .markdown-content code:not(pre code){background-color:var(--background);border-radius:.5em;padding:.25em .4em;font-family:SFMono-Regular,Monaco,Inconsolata,Liberation Mono,Courier New,monospace;font-size:.85em}#markedown-viewer .markdown-content kbd{background-color:var(--background);border:1px solid var(--divider);box-shadow:inset 0 -2px 0 var(--divider);border-radius:.35em;padding:.1em .5em;font-family:SFMono-Regular,Monaco,Inconsolata,Liberation Mono,Courier New,monospace;font-size:.85em}#markedown-viewer .markdown-content-container .copy-code-button{color:var(--primaryColor);background:var(--background);border-radius:.45em;justify-content:center;align-items:center;padding:.2em .4em;font-size:.8em;transition:background .2s,color .2s;display:flex;position:absolute;top:.4em;right:.3em}#markedown-viewer .markdown-content-container .line-numbers{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none;background-color:var(--background);border-right:1px solid var(--divider);text-align:right;color:var(--textSecondary);flex-shrink:0;min-width:2em;padding:.625em .5em .625em .75em}#markedown-viewer .markdown-content-container .line-number{white-space:nowrap;cursor:pointer;height:1.45em;line-height:1.45;transition:background-color .2s,color .2s;display:block}#markedown-viewer .markdown-content-container .line-number:hover{color:var(--primaryColor);background-color:#0000}#markedown-viewer .markdown-content-container .line-number.active{background-color:color-mix(in srgb, var(--primaryColor) 30%, transparent);color:var(--primaryColor);border-radius:.25em}#markedown-viewer .markdown-content-container .code-line{white-space:pre;min-height:1.45em;line-height:1.45;transition:background-color .2s;display:block}#markedown-viewer .markdown-content-container .code-line.highlighted{background-color:color-mix(in srgb, var(--primaryColor) 20%, transparent)}#markedown-viewer .markdown-content-container .code-content{flex:1;max-width:100%;overflow-x:auto}#markedown-viewer .markdown-content-container .code-content pre{font-family:inherit;font-size:inherit;border-radius:0;width:100%;margin:0;padding:0;line-height:1.45}#markedown-viewer .markdown-content-container .code-content code{font-family:inherit;font-size:inherit;line-height:inherit;white-space:pre;padding:.65em .5em .5em;display:block}#markedown-viewer .markdown-content-container .code-content pre code,#markedown-viewer .markdown-content-container .code-content pre code br{line-height:1.45}#markedown-viewer .markdown-content a{color:var(--primaryColor);text-decoration:underline}#markedown-viewer .markdown-content-container .code-content a{color:var(--primaryColor);font-weight:500;text-decoration:underline}#markedown-viewer .markdown-content h1,#markedown-viewer .markdown-content h2,#markedown-viewer .markdown-content h3,#markedown-viewer .markdown-content h4,#markedown-viewer .markdown-content h5,#markedown-viewer .markdown-content h6{text-wrap:balance;margin:1.4em 0 .6em;font-weight:600;line-height:1.25}#markedown-viewer .markdown-content h1{border-bottom:1px solid var(--divider);margin-top:0;padding-bottom:.3em;font-size:1.8em}#markedown-viewer .markdown-content h2{border-bottom:1px solid var(--divider);padding-bottom:.3em;font-size:1.45em}#markedown-viewer .markdown-content h3{font-size:1.2em}#markedown-viewer .markdown-content h4{font-size:1em}#markedown-viewer .markdown-content p{text-wrap:pretty;margin-top:0;margin-bottom:.85em;line-height:1.45}#markedown-viewer .markdown-content hr{border:none;border-top:1px solid var(--divider);margin:1.5em 0}#markedown-viewer .markdown-content ul{margin-top:0;margin-bottom:0;padding-bottom:.85em;padding-lefLine truncated
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
a.button[data-v-5a3a53bf]{justify-content:center;align-items:center;width:500px;max-width:100%;margin:auto;display:flex}.controls[data-v-5a3a53bf]{margin:1.5em}.search-box[data-v-5a3a53bf]{margin-bottom:1em;position:relative}.search-box .search-icon[data-v-5a3a53bf]{position:absolute;top:.25em;left:.5em}.clear-button[data-v-5a3a53bf]{position:absolute;top:50%;right:.5em;transform:translateY(-50%);min-width:auto!important;padding:.25em!important}.results-info[data-v-5a3a53bf]{color:var(--textSecondary);margin-bottom:1em;font-size:.9em}.icon-grid[data-v-5a3a53bf]{grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:.75em;margin-bottom:2em;display:grid}.icon-card[data-v-5a3a53bf]{background:var(--surfaceSecondary);border:1px solid var(--divider);border-radius:var(--borderRadius);flex-direction:column;align-items:center;padding:1em .6em;display:flex;position:relative}.icon-card.custom-icon[data-v-5a3a53bf]{border-color:var(--primaryColor);border-style:dashed}.icon-display[data-v-5a3a53bf]{color:var(--primaryColor);justify-content:center;align-items:center;height:1em;margin-bottom:.4em;font-size:2em;display:flex}.icon-name[data-v-5a3a53bf]{color:var(--textSecondary);text-align:center;overflow-wrap:break-word;font-size:.75em;line-height:1.2}.custom-badge[data-v-5a3a53bf]{background:var(--primaryColor);color:#fff;text-transform:uppercase;border-radius:.5em;padding:.25em .6em;font-size:.7em;font-weight:600;position:absolute;top:.5em;right:.5em}.empty-state[data-v-5a3a53bf]{text-align:center;color:var(--textSecondary);padding:4em 2em}.empty-state .material-symbols[data-v-5a3a53bf]{opacity:.5;margin-bottom:.5em;font-size:4em}.empty-state p[data-v-5a3a53bf]{margin-bottom:.5em;font-size:1.2em}.empty-hint[data-v-5a3a53bf]{opacity:.7;font-size:.9em}@media (width<=768px){#tool-material-icon-picker[data-v-5a3a53bf]{padding:1em}.icon-grid[data-v-5a3a53bf]{grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:.75em}.icon-card[data-v-5a3a53bf]{padding:1em .5em}.icon-display[data-v-5a3a53bf]{font-size:2em}}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.size-viewer[data-v-42452d2e]{max-width:1200px;margin-left:auto;margin-right:auto;padding:1em}.toggle-container[data-v-42452d2e]{padding:1em}.size-viewer-results[data-v-42452d2e]{margin-bottom:2em}.button[data-v-42452d2e]{align-items:center;gap:.5rem;margin-top:1rem;display:flex}.button .material-symbols[data-v-42452d2e]{font-size:1.2rem}.error-message[data-v-42452d2e]{background:color-mix(in srgb, var(--red), transparent 90%);color:var(--icon-red);border:1px solid color-mix(in srgb, var(--red), transparent 60%);border-radius:4px;margin-bottom:1rem;padding:1rem}.stats[data-v-42452d2e]{gap:2rem;margin-bottom:1rem;display:flex}.treemap[data-v-42452d2e]{background:var(--surfacePrimary);border-radius:4px;width:100%;height:600px;margin-bottom:2rem;position:relative;overflow:hidden}.treemap.has-expanded[data-v-42452d2e]{overflow:visible}.treemap-overlay[data-v-42452d2e]{background:var(--overlay);z-index:50;cursor:pointer;position:absolute;inset:0}.treemap-hit-area[data-v-42452d2e]{z-index:200;cursor:pointer;pointer-events:auto;background:0 0;border:none;outline:none;position:absolute}.treemap.has-expanded .treemap-hit-area[data-v-42452d2e]:not(.active){pointer-events:none;z-index:1}.treemap.has-expanded .treemap-hit-area.active[data-v-42452d2e]{z-index:150;pointer-events:none}.treemap-item[data-v-42452d2e]{cursor:pointer;box-sizing:border-box;pointer-events:none;border:1px solid #0000001a;flex-direction:column;justify-content:center;align-items:center;transition:left .4s cubic-bezier(.4,0,.2,1),top .4s cubic-bezier(.4,0,.2,1),width .4s cubic-bezier(.4,0,.2,1),height .4s cubic-bezier(.4,0,.2,1),opacity .4s cubic-bezier(.4,0,.2,1),filter .4s cubic-bezier(.4,0,.2,1);display:flex;position:absolute;overflow:visible}.treemap-item.small-item[data-v-42452d2e]{padding:0}.treemap-item.dimmed[data-v-42452d2e]{opacity:.3;filter:brightness(.5)}.treemap-item.expanded[data-v-42452d2e]{z-index:100;pointer-events:auto;border:2px solid #ffffffe6;box-shadow:0 8px 32px #000000b3;width:50%!important;height:50%!important;top:25%!important;left:25%!important}.item-content[data-v-42452d2e]{text-align:center;color:#fff;text-shadow:1px 1px 2px #00000080;flex-direction:column;justify-content:center;align-items:center;width:100%;padding:.5rem;font-weight:500;transition:opacity .2s;display:flex;overflow:hidden}.treemap-item.expanded .item-content[data-v-42452d2e]{opacity:0;pointer-events:none;transition:opacity .2s}.item-name[data-v-42452d2e]{text-overflow:ellipsis;white-space:nowrap;max-width:100%;margin-bottom:.25rem;font-size:.85rem;overflow:hidden}.item-size[data-v-42452d2e]{opacity:.95;white-space:nowrap;font-size:.75rem}.item-percentage[data-v-42452d2e]{opacity:.85;white-space:nowrap;margin-top:.2rem;font-size:.7rem}.item-expanded[data-v-42452d2e]{opacity:0;pointer-events:none;z-index:1;flex-direction:column;justify-content:center;gap:.4rem;padding:.75rem;transition:opacity .2s,transform .2s;display:flex;position:absolute;inset:0;transform:scale(.9)}.treemap-item.expanded .item-expanded[data-v-42452d2e]{opacity:1;pointer-events:auto;z-index:200;transform:scale(1)}.expanded-field[data-v-42452d2e]{text-align:left;flex-direction:column;gap:.15rem;display:flex}.field-label[data-v-42452d2e]{opacity:.85;text-transform:uppercase;letter-spacing:.05em;color:#fff;text-shadow:1px 1px 2px #00000080;font-size:.65rem;font-weight:600}.field-value[data-v-42452d2e]{color:#fff;text-shadow:1px 1px 2px #00000080;overflow-wrap:break-word;text-overflow:ellipsis;line-clamp:2;-webkit-box-orient:vertical;max-height:3em;font-size:.8rem;font-weight:500;display:-webkit-box;overflow:hidden}.expanded-field:first-child .field-value[data-v-42452d2e]{line-clamp:1;max-height:1.5em}.expanded-field:nth-child(2) .field-value[data-v-42452d2e]{line-clamp:3;max-height:4em}.type-video[data-v-42452d2e]{background:var(--blue)}.type-image[data-v-42452d2e]{background:var(--icon-coral)}.type-audio[data-v-42452d2e]{background:var(--icon-plum)}.type-archive[data-v-42452d2e]{background:var(--icon-tan)}.type-document[data-v-42452d2e]{background:indigo}.type-binary[data-v-42452d2e]{background:var(--divider)}.type-directory[data-v-42452d2e]{background:var(--primaryColor)}.type-other[data-v-42452d2e]{background:var(--light-gray)}.legend[data-v-42452d2e]{flex-wrap:wrap;gap:1rem;margin-top:.5rem;display:flex}.legend-item[data-v-42452d2e]{align-items:center;gap:.5rem;display:flex}.legend-color[data-v-42452d2e]{border-radius:3px;width:20px;height:20px;box-shadow:0 1px 3px #0003}.empty-state[data-v-42452d2e]{text-align:center;color:var(--textSecondary);padding:4rem 2rem}.empty-state .material-symbols[data-v-42452d2e]{opacity:.3;margin-bottom:1rem;font-size:4rem}.empty-state p[data-v-42452d2e]{font-size:1.1rem}.spin[data-v-42452d2e]{animation:1s linear infinite spin-42452d2e}@keyframes spin-42452d2e{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@media (width<=768px){.size-viewer[data-v-42452d2e]{padding:1rem}.treemap[data-v-42452d2e]{height:400px}.item-path[data-v-42452d2e]{font-size:.7Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
.threejs-viewer[data-v-a7499d14]{width:100%;height:100%;position:relative;overflow:hidden}.threejs-viewer canvas[data-v-a7499d14]{display:block;position:absolute;top:0;left:0;width:100%!important;height:100%!important}.loading-overlay[data-v-a7499d14],.error-overlay[data-v-a7499d14]{background:var(--background);z-index:10;justify-content:center;align-items:center;display:flex;position:absolute;inset:0}.error-content[data-v-a7499d14]{text-align:center;max-width:500px;padding:2rem}.error-content i[data-v-a7499d14]{color:var(--red);margin-bottom:1rem;font-size:64px}.error-content h3[data-v-a7499d14]{color:var(--textPrimary);margin-bottom:.5rem}.error-content p[data-v-a7499d14]{color:var(--textSecondary)}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.wopi-editor[data-v-5e12c31f]{width:100%;height:100vh;position:relative}.wopi-form[data-v-5e12c31f]{display:none}.wopi-frame[data-v-5e12c31f]{border:0;width:100%;height:100%;display:block}.wopi-loading[data-v-5e12c31f]{text-align:center;position:absolute;top:1em;left:0;right:0}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
html{line-height:1.15}body{margin:0}pre,code,kbd,samp{font-family:monospace;font-size:1em}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:100%;line-height:1.15}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;white-space:normal;max-width:100%;padding:0;display:table}progress{vertical-align:baseline}[type=search]{appearance:textfield;outline-offset:-2px}[hidden]{display:none}:root{--blue:#2196f3;--dark-blue:#1e88e5;--red:#f44336;--dark-red:#be2626;--icon-red:#d23b4a;--icon-orange:#ff8c00;--icon-deep-orange:#ff6f00;--icon-yellow:#fdd34b;--icon-green:#4caf50;--icon-blue:#1d99f3;--icon-violet:#9b59b6;--icon-brown:brown;--icon-coral:lightcoral;--icon-plum:plum;--icon-tan:tan;--primaryColor:var(--blue);--light-gray:lightgray;--icon-size:3em;--safe-area-top:env(safe-area-inset-top,0px);--safe-area-left:env(safe-area-inset-left,0px);--safe-area-right:env(safe-area-inset-right,0px);--safe-area-bottom:env(safe-area-inset-bottom,0px);--header-height:calc(4em + var(--safe-area-top));--borderRadius:.85rem;--borderWidth:2px;--panel-bg:color-mix(in srgb, var(--alt-background) 65%, transparent);--panel-blur:blur(12px) invert(.1);--surfaceElevationShadow:0 1px 2px #00000012, 0 2px 4px #0000000f, 0 4px 8px #0000000d;--overlay:#00000080;--hoverOverlay:#0000001a}.dark-mode{--hoverOverlay:#ffffff1f;color:var(--textPrimary)}.border-radius{border-radius:var(--borderRadius)}.button{padding:var(--button-padding-y,.5em) var(--button-padding-x,1em);border-radius:var(--borderRadius);cursor:pointer;background:var(--button-background,var(--primaryColor));color:var(--button-color,white);box-shadow:var(--button-shadow,0 0 5px #0000000d);min-width:var(--button-min-width,2em);min-height:var(--button-min-height,2em);outline:0;justify-content:center;align-items:center;transition:transform .1s,opacity .1s,background-color .1s,box-shadow .2s;display:flex}button{background-color:var(--surfaceSecondary);min-width:var(--button-min-width,2em);min-height:var(--button-min-height,2em);border:none;justify-content:center;align-items:center;padding:.5em;transition:background-color .3s;display:flex}button:disabled{opacity:.5;cursor:not-allowed}.button--block{border-style:unset;width:100%;margin:0 0 .5em;display:block}.button--red{background:var(--red)}.button--blue{background:var(--blue)}.button--flat{color:var(--primaryColor);background:var(--surfaceSecondary);text-transform:uppercase;padding-left:1em;padding-right:1em;box-shadow:0 0}.button--flat.button--red{color:var(--dark-red)}.button--flat.button--grey{color:#6f6f6f}.button--settings-control{border-radius:50px;width:4em;min-width:4em;max-width:4em;height:34px;min-height:34px;padding:0}.button[disabled]{opacity:.5;cursor:not-allowed}:root{--fab-size:50px;--fab-size-small:36px;--fab-icon-size:24px;--fab-edge-offset:16px;--fab-edge-offset-media:20px;--fab-elevation-rest:0 1px 3px 1px #00000026, 0 1px 2px #0000004d;--fab-elevation-hover:0 2px 6px 2px #00000026, 0 1px 2px #0000004d;--fab-elevation-pressed:0 4px 8px 3px #00000026, 0 1px 3px #0000004d;--fab-shadow:var(--fab-elevation-rest);--fab-transition:.2s cubic-bezier(.2, 0, 0, 1);--fab-fade-transition:.4s ease;--fab-safe-inset:calc(var(--fab-size) + 2rem)}.has-fab-bottom-right{margin-bottom:var(--fab-safe-inset)}.input{box-sizing:border-box;border-radius:var(--borderRadius);border:var(--borderWidth) solid var(--surfaceSecondary);background:var(--background);width:100%;min-height:2.5em;color:var(--textPrimary);margin:0;padding:.5em 1em;transition:border-color .2s}.input:hover{border-color:color-mix(in srgb, var(--surfaceSecondary) 70%, var(--textPrimary))}.input:focus{border-color:var(--primaryColor);outline:none}.input:disabled{cursor:not-allowed;color:var(--textSecondary);opacity:.75}.expand-dropdown.input,.expand-dropdown.input:hover,.expand-dropdown.input:focus{box-shadow:none;background:0 0;border:none;padding:0}input:-webkit-autofill{box-shadow:0 0 0 1000px var(--background) inset;-webkit-text-fill-color:var(--textPrimary);border-color:unset}input:-webkit-autofill:hover{box-shadow:0 0 0 1000px var(--background) inset;-webkit-text-fill-color:var(--textPrimary);border-color:unset}input:-webkit-autofill:focus{box-shadow:0 0 0 1000px var(--background) inset;-webkit-text-fill-color:var(--textPrimary);border-color:unset}input:-webkit-autofill:active{box-shadow:0 0 0 1000px var(--background) inset;-webkit-text-fill-color:var(--textPrimary);border-color:unset}input[type=range]{accent-color:var(--primaryColor)}input.sizeInput:disabled{cursor:not-allowed}.sizeInputWrapper{box-sizing:border-box;border-radius:var(--borderRadius);background-color:var(--background);border:var(--borderWidth) solid var(--surfaceSecondary);color:var(--textPrimary);justify-content:center;align-items:center;margin:.5em;padding:.25em .5em;display:flex;box-shadow:0 1pxLine truncated
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.audio-side-panel[data-v-788790b4]{background:color-mix(in srgb, var(--alt-background) 20%, var(--background) 20%);border-radius:var(--borderRadius);max-height:65vh;box-shadow:var(--surfaceElevationShadow);flex-direction:column;display:flex;overflow:hidden}.panel-tabs[data-v-788790b4]{border-bottom:1px solid var(--divider);padding:.5em}.tab-container[data-v-788790b4]{gap:.5em;display:flex;position:relative}.tab-btn[data-v-788790b4]{color:var(--textSecondary);border-radius:var(--borderRadius);cursor:pointer;z-index:1;-webkit-user-select:none;user-select:none;white-space:nowrap;background:0 0;border:none;flex:1;justify-content:center;align-items:center;gap:.3em;width:100%;min-width:0;padding:.5em;font-size:.9rem;transition:color .2s,transform .2s;display:flex;position:relative}.tab-btn>i[data-v-788790b4],.tab-btn>span+span[data-v-788790b4]{flex-shrink:0}.tab-btn>span[data-v-788790b4]:first-of-type{text-overflow:ellipsis;min-width:0;overflow:hidden}.tab-btn.active[data-v-788790b4]{color:#fff}.tab-indicator[data-v-788790b4]{background:var(--primaryColor);border-radius:var(--borderRadius);z-index:0;pointer-events:none;transition:left .35s cubic-bezier(.25,.8,.25,1),width .35s cubic-bezier(.25,.8,.25,1);position:absolute;top:0;bottom:0;left:0}.panel-content[data-v-788790b4]{flex-direction:column;flex:1;display:flex;overflow:hidden}.tab-queue[data-v-788790b4],.tab-lyrics[data-v-788790b4],.tab-visualizer[data-v-788790b4]{height:100%}.tab-queue[data-v-788790b4]{flex-direction:column;display:flex}.tab-lyrics[data-v-788790b4]{flex-direction:column;display:flex;position:relative}.lyrics-scrollable[data-v-788790b4]{flex:1;min-height:0;overflow-y:auto}.tab-queue[data-v-788790b4] .card-content{flex:1;margin:0;padding:.5em;overflow-y:auto}.tab-queue[data-v-788790b4] .card-actions{flex-shrink:0;justify-content:flex-end;padding:.25em;display:flex}.tab-queue[data-v-788790b4] .card-actions .button--flat{background:0 0}.lyrics-list[data-v-788790b4]{text-align:center;color:var(--textPrimary);padding-top:1em;padding-bottom:1em}.lyrics-meta-header[data-v-788790b4]{opacity:.55;letter-spacing:.02em;text-transform:uppercase;cursor:default;-webkit-user-select:none;user-select:none;margin:0;padding:0 0 .6em;font-size:.8rem;font-weight:600}.lyric-line[data-v-788790b4]{opacity:.6;cursor:pointer;transform-origin:50%;padding:.5em 0;font-size:1.15rem;transition:opacity .25s,color .25s,font-size .25s,transform .25s;transform:scale(1)}.lyric-line[data-v-788790b4]:hover{opacity:1}.lyric-line.active[data-v-788790b4]{opacity:1;color:var(--primaryColor);font-size:1.33rem;font-weight:700;animation:.3s lyric-line-in-788790b4}@keyframes lyric-line-in-788790b4{0%{transform:scale(.98)}to{transform:scale(1)}}.lyric-word[data-v-788790b4]{opacity:.4;transition:opacity .35s,transform .35s cubic-bezier(.25,.8,.25,1);display:inline-block;transform:scale(1)}.lyric-word.sung[data-v-788790b4]{opacity:1}.lyric-word.current[data-v-788790b4]{opacity:1;transform:scale(1.05)}.no-lyrics[data-v-788790b4]{height:100%;color:var(--textSecondary);flex-direction:column;justify-content:center;align-items:center;gap:.5em;display:flex}.no-lyrics i[data-v-788790b4]{opacity:.5;font-size:3rem}.lyric-line.no-seek[data-v-788790b4]{cursor:default}.tab-btn[data-v-788790b4]:hover:not(.active){color:var(--primaryColor);transform:scale(1.02)}.tab-visualizer[data-v-788790b4]{box-sizing:border-box;justify-content:center;align-items:center;height:100%;padding:.5em;display:flex;position:relative}.visualizer-canvas[data-v-788790b4]{border-radius:var(--borderRadius);background:#0000001f;width:100%;height:100%;display:block}@keyframes plyr-progress{to{background-position:25px 0;background-position:var(--plyr-progress-loading-size,25px) 0}}@keyframes plyr-popup{0%{opacity:.5;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@keyframes plyr-fade-in{0%{opacity:0}to{opacity:1}}.plyr{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:inherit;font-family:var(--plyr-font-family,inherit);font-variant-numeric:tabular-nums;font-weight:400;font-weight:var(--plyr-font-weight-regular,400);line-height:1.7;line-height:var(--plyr-line-height,1.7);min-width:200px;max-width:100%;text-shadow:none;z-index:0;direction:ltr;flex-direction:column;align-items:center;transition:box-shadow .3s;display:flex;position:relative}.plyr audio,.plyr iframe,.plyr video{width:100%;height:100%;display:block}.plyr button{font:inherit;line-height:inherit;width:auto}.plyr:focus{outline:0}.plyr--full-ui{box-sizing:border-box}.plyr--full-ui *,.plyr--full-ui :after,.plyr--full-ui :before{box-sizing:inherit}.plyr--full-ui a,.plyr--full-ui button,.plyr--full-ui input,.plyr--full-ui label{touch-action:manipulation}.plyr__badge{background:#4a5464;background:var(--plyr-badge-background,#4a5464);border-radius:2px;border-radius:var(--plyr-badge-border-radius,2px);color:var(--plyr-badge-text-color,#fff);font-size:9px;font-size:var(--plyr-font-size-badge,9px);padding:3px 4px;line-height:1}.plyr--fuLine truncated
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
.transition-loading[data-v-33a8df92]{background:var(--background);z-index:10000;flex-direction:column;justify-content:center;align-items:center;transition:opacity .1s;display:flex;position:fixed;inset:0}.transition-loading .spinner[data-v-33a8df92]{text-align:center;width:70px}.transition-loading .spinner>div[data-v-33a8df92]{background-color:var(--textPrimary);border-radius:100%;width:18px;height:18px;animation:1.4s ease-in-out infinite both sk-bouncedelay-33a8df92;display:inline-block}.transition-loading .spinner .bounce1[data-v-33a8df92]{animation-delay:-.32s}.transition-loading .spinner .bounce2[data-v-33a8df92]{animation-delay:-.16s}@keyframes sk-bouncedelay-33a8df92{0%,80%,to{transform:scale(0)}40%{transform:scale(1)}}.preview.transitioning[data-v-33a8df92]{opacity:.3;pointer-events:none}.pdf-wrapper[data-v-33a8df92]{width:100%;height:100%;position:relative}.pdf-wrapper .pdf[data-v-33a8df92]{border:0;width:100%;height:100%}.pdf-wrapper .floating-btn[data-v-33a8df92]{color:#fff;background:#00000080}.pdf-wrapper .floating-btn[data-v-33a8df92]:hover{background:#000000b3}.preview-buttons[data-v-33a8df92]{flex-direction:row;gap:1em;display:flex}.av-preview-wrap[data-v-33a8df92]{width:100%;height:100%;min-height:12rem}.av-preview-loading[data-v-33a8df92]{justify-content:center;align-items:center;width:100%;min-height:12rem;display:flex}.transcode-offer-message[data-v-33a8df92]{text-align:center;margin:1rem 0}.transcode-session-list[data-v-33a8df92]{text-align:center;margin:0 0 1rem;padding:0;list-style:none}.transcode-session-list li[data-v-33a8df92]{margin:.25rem 0}.transcode-session-source[data-v-33a8df92]{opacity:.7}.overlay-icon{text-shadow:0 0 3px #000c;z-index:2;color:#fff;opacity:.7;position:absolute;top:.2em;left:.2em;width:100%!important;height:100%!important;font-size:1.2em!important}.overlay-icon--bottom{pointer-events:auto;cursor:pointer;background:0 0;border:none;margin:0;padding:0;line-height:1;inset:auto 0 0 auto;width:auto!important;height:auto!important}.overlay-icon--bottom.clickable:hover{opacity:1;transform:none;box-shadow:none!important}.file-icons [aria-label^=\.],.file-icons [aria-label$=\.bak]{opacity:.33}.svg-icons{max-width:100px;display:flex}.icon{fill:currentColor;background:var(--iconBackground);will-change:auto;border-radius:.2em;padding:.1em;font-size:1.5rem;transform:translateZ(0)}.icon.active{background:var(--background)}.primary-icons{color:var(--primaryColor)}.primary-icons.active{text-shadow:0 0 1px #000}.blue-icons{color:var(--icon-blue)}.deep-blue-icons{color:#1d5fbf}.lightblue-icons{color:#87cefa}.purple-icons{color:var(--icon-violet)}.plum-icons{color:var(--icon-plum)}.yellow-icons{color:var(--icon-yellow)}.orange-icons{color:var(--icon-orange)}.red-icons{color:var(--red)}.deep-orange-icons{color:var(--icon-deep-orange)}.brown-icons{color:var(--icon-brown)}.coral-icons{color:var(--icon-coral)}.tan-icons{color:var(--icon-tan)}.green-icons{color:var(--icon-green)}.white-icons{color:#fff}.gray-icons{color:var(--divider)}.lightgray-icons{color:var(--light-gray)}#listingView.gallery .listing-item i.white-icons,.active.white-icons{color:var(--activeWhiteIcon)}.image-preview{width:var(--icon-size);height:var(--icon-size);aspect-ratio:1;background:var(--iconBackground);border-radius:.5em;justify-content:center;align-items:center;display:flex;position:relative;overflow:hidden}.gallery .image-preview{border-radius:0}.image-preview i{box-sizing:border-box;font-size:var(--icon-font-size,3em);padding:.1em;transform:translateZ(0)}.image-preview img{object-fit:cover;object-position:center;width:100%;height:100%;display:block}.image-preview .placeholder-icon{color:#fff;opacity:.7;text-shadow:0 0 3px #000c}.image-preview .threejs-icon-container{background:#000;justify-content:center;align-items:center;width:100%;height:100%;display:flex}.image-preview .threejs-icon-container canvas{display:block;width:100%!important;height:100%!important}.download-icon{cursor:pointer;color:var(--surfaceSecondary)}.icon-download{font-size:.5em}.listing-item{-webkit-touch-callout:none}.listing-item.pinned-item{border-color:color-mix(in srgb, var(--primaryColor) 35%, transparent)}.listing-item>div{min-height:1em}.hiddenFile{opacity:.5}.activebutton{height:10em}.iconActive{font-size:6em!important}.activetitle{width:9em!important;margin-right:1em!important}.half-selected{border-width:var(--borderWidth)!important;border-color:var(--primaryColor)!important;border-style:solid!important}.vue-simple-progress{background:var(--primaryColor);margin:.5em}.vue-simple-progress,.vue-simple-progress-bar{border-radius:.5em}.vue-simple-progress-text{color:#000}.tooltip-info-icon{cursor:pointer;vertical-align:middle;opacity:.7;font-size:1rem}.tooltip-info-icon:hover,.tooltip-info-icon:hover .material-symbols-outlined{font-variation-settings:"FILL" 1;opacity:1}@keyframes progress-pulse{0%,to{opacity:1}50%{opacity:.6}}.settings-group-title[data-v-7bce02d3]{justify-content:space-between;align-items:center;margin-top:.75em;pLine truncated
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,304 @@
|
||||
server:
|
||||
searchResultsLimit: 1000 # maximum requested search results across all sources (default: 1000); quick search defaults to 100, advanced search to 500; size viewer remains limited to 200 validate:gte=1
|
||||
minSearchLength: 3 # minimum length of search query to begin searching (default: 3)
|
||||
disableUpdateCheck: false # disables backend update check service
|
||||
numImageProcessors: 4 # number of concurrent image processing jobs used to create previews, default is 4.
|
||||
disablePreviews: false # disable all previews thumbnails, simple icons will be used
|
||||
disablePreviewResize: false # disable resizing of previews for faster loading over slow connections
|
||||
disableTypeDetectionByHeader: false # disable type detection by header, useful if filesystem is slow.
|
||||
logging:
|
||||
- levels: "" # separated list of log levels to enable. (eg. "info|warning|error|debug")
|
||||
apiLevels: "" # separated list of log levels to enable for the API. (eg. "info|warning|error")
|
||||
output: "stdout" # output location. (eg. "stdout" or "path/to/file.log")
|
||||
noColors: false # disable colors in the output
|
||||
json: false # output in json format
|
||||
utc: false # use UTC time in the output instead of local time.
|
||||
apiFilter: "" # regex filter that excludes matching full api paths from being logged. (eg. '/user\?id\=self') Defaults to '^/health|^/favicon.ico|^/static|^/public/static'
|
||||
sources: # validate:required,dive
|
||||
- path: "/relative/or/absolute/path" # file system path. (Can be relative) validate:required
|
||||
name: "Source Name" # display name
|
||||
config:
|
||||
denyByDefault: false # deny access unless an "allow" access rule was specifically created.
|
||||
private: false # designate as source as private -- currently just means no sharing permitted.
|
||||
readOnly: false # read-only source, changes from the UI, webdav, and API will be disabled.
|
||||
disabled: false # disable the source, this is useful so you don't need to remove it from the config file
|
||||
rules: # list of item rules to apply to specific paths
|
||||
- neverWatchPath: "" # index the folder in the first pass to get included in search, but never re-indexed.
|
||||
includeRootItem: "" # include only these items at root folder level
|
||||
fileStartsWith: "" # (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
|
||||
folderStartsWith: "" # (global) exclude folders that start with these prefixes. Eg. "archive-" or "backup-"
|
||||
fileEndsWith: "" # (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
|
||||
folderEndsWith: "" # (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
|
||||
folderPath: "" # (global) exclude folders that match this path. Eg. "/path/to/folder" or "/path/to/folder/subfolder"
|
||||
filePath: "" # (global) exclude files that match this path. Eg. "/path/to/file.txt" or "/path/to/file.txt/subfile.txt"
|
||||
fileName: "" # (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
|
||||
folderName: "" # (global) exclude folders that match these names. Eg. "folder" or "subfolder"
|
||||
viewable: false # Enable viewing in UI but exclude from indexing
|
||||
ignoreHidden: false # Excludes only hidden files and folders
|
||||
ignoreZeroSizeFolders: false # Excludes only folders with 0 size
|
||||
ignoreSymlinks: false # Excludes symbolic links
|
||||
defaultUserScope: "/" # defaults to root of index "/" should match folders under path
|
||||
defaultEnabled: true # should be added as a default source for new users?
|
||||
createUserDir: false # create a user directory for each user under defaultUserScope + username
|
||||
useLogicalSize: false # calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
defaultPermissions: # DefaultPermissions is the template for new user scopes on this source (also synced globally via Access settings).
|
||||
view: false
|
||||
download: false
|
||||
modify: false
|
||||
delete: false
|
||||
create: false
|
||||
configured: false # true when explicitly set (allows intentional deny-all)
|
||||
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
|
||||
cacheDirCleanup: false # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
maxArchiveSize: 20 # maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
filesystem: # filesystem settings
|
||||
createFilePermission: "644" # Unix permissions like 644, 755, 2755 (default: 644) validate:required,file_permission
|
||||
createDirectoryPermission: "755" # Unix permissions like 755, 2755, 1777 (default: 755) validate:required,file_permission
|
||||
indexSqlConfig: # Index database SQL configuration
|
||||
batchSize: 1000 # number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
|
||||
cacheSizeMB: 32 # size of the SQLite cache in MB
|
||||
walMode: false # enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
|
||||
disableReuse: false # enable to always create a new indexing database on startup.
|
||||
fts5: false # Enable FTS5 to provide faster search queries, but 40% slower indexing and 15% larger database size
|
||||
startupIntegrityCheck: "quickCheck" # the method used to check the integrity of the index database on startup (default: quickCheck) validate:omitempty,oneof=quickCheck probe off
|
||||
database: # SQLite database configuration
|
||||
path: "filebrowser.sqlite" # path to SQLite database file
|
||||
migrateFrom: "" # path to legacy database file for migration (optional)
|
||||
activity: # activity audit logging configuration
|
||||
disabled: false # disable semantic activity audit logging (default: false)
|
||||
retentionDays: 30 # purge activity rows older than this many days (default 30)
|
||||
flushIntervalSeconds: 10 # buffer flush interval in seconds (default 10)
|
||||
maxBufferSize: 10000 # max in-memory buffer before immediate flush (default 10000)
|
||||
quotas: # quota counter batch flush configuration
|
||||
flushIntervalSeconds: 10 # default 10
|
||||
flushMaxBuffers: 500 # default 500
|
||||
auth:
|
||||
tokenExpirationHours: 2 # time in hours each web UI session token is valid for. Default is 2 hours.
|
||||
methods:
|
||||
proxy: # validate:omitempty
|
||||
enabled: false # whether to enable this authentication method.
|
||||
adminGroup: "" # if set, users in this group will be granted admin privileges
|
||||
userGroups: # if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
|
||||
groupsClaim: "" # the JSON field name to read groups from. Default is "groups"
|
||||
userIdentifier: "" # the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
|
||||
disableVerifyTLS: false # disable TLS verification (insecure, for testing only)
|
||||
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
header: "" # required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
|
||||
noauth: false # if set to true, overrides all other auth methods and disables authentication
|
||||
password: # validate:omitempty
|
||||
enabled: true
|
||||
adminUsername: "admin" # secret: admin username auto-assigned. If not set, the default is "admin".
|
||||
adminPassword: "admin" # secret: password for admin auto-assigned admin account. If set, reset on startup.
|
||||
minLength: 5 # minimum pasword length required, default is 5. validate:omitempty
|
||||
signup: false # allow signups on login page if enabled -- not secure. validate:omitempty
|
||||
recaptcha: # recaptcha config. If configured will show up the checkbox verification in the login page validate:omitempty
|
||||
host: "" # google recaptcha host, for example: https://www.google.com/recaptcha/api.js validate:required
|
||||
key: "" # v2 site key validate:required
|
||||
secret: "" # v2 secret key validate:required
|
||||
enforcedOtp: false # if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
|
||||
oidc: # validate:omitempty
|
||||
enabled: false # whether to enable this authentication method.
|
||||
adminGroup: "" # if set, users in this group will be granted admin privileges
|
||||
userGroups: # if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
|
||||
groupsClaim: "groups" # the JSON field name to read groups from. Default is "groups"
|
||||
userIdentifier: "preferred_username" # the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
|
||||
disableVerifyTLS: false # disable TLS verification (insecure, for testing only)
|
||||
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
clientId: "" # secret: client id of the OIDC application
|
||||
clientSecret: "" # secret: client secret of the OIDC application
|
||||
issuerUrl: "" # authorization URL of the OIDC provider
|
||||
scopes: "openid email profile" # scopes to request from the OIDC provider
|
||||
ldap: # validate:omitempty
|
||||
enabled: false # whether to enable this authentication method.
|
||||
adminGroup: "" # if set, users in this group will be granted admin privileges
|
||||
userGroups: # if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
|
||||
groupsClaim: "" # the JSON field name to read groups from. Default is "groups"
|
||||
userIdentifier: "" # the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
|
||||
disableVerifyTLS: false # disable TLS verification (insecure, for testing only)
|
||||
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
server: "" # scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
|
||||
baseDN: "" # LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
|
||||
userDN: "" # Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
|
||||
userPassword: "" # Bind password for service account
|
||||
userFilter: "" # Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
|
||||
jwt: # validate:omitempty
|
||||
enabled: false # whether to enable this authentication method.
|
||||
adminGroup: "" # if set, users in this group will be granted admin privileges
|
||||
userGroups: # if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
|
||||
groupsClaim: "" # the JSON field name to read groups from. Default is "groups"
|
||||
userIdentifier: "" # the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
|
||||
disableVerifyTLS: false # disable TLS verification (insecure, for testing only)
|
||||
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
header: "" # HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
|
||||
secret: "" # secret: Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
|
||||
algorithm: "" # JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
|
||||
passkey: # validate:omitempty
|
||||
enabled: false # whether to enable passkey MFA
|
||||
rpDisplayName: "" # the Relying Party display name (defaults to frontend name)
|
||||
rpId: "" # the Relying Party ID; if empty, auto-derived from base URL host
|
||||
rpOrigins: # allowed origins; if empty, auto-derived from base URL
|
||||
loginButtonText: "" # custom text for the passkey login button
|
||||
key: "" # secret: HMAC key for JWT tokens. If unset in config/env, one is generated and stored in the application database.
|
||||
totpSecret: "" # secret: secret used to encrypt TOTP secrets
|
||||
frontend:
|
||||
name: "FileBrowser Quantum" # display name, PWA installation name is capped at 30 characters
|
||||
disableDefaultLinks: false # disable default links in the sidebar
|
||||
disableUsedPercentage: false # disable used percentage for the sources in the sidebar
|
||||
externalLinks:
|
||||
- text: "(untracked)" # the text to display on the link validate:required
|
||||
title: "untracked" # the title to display on hover
|
||||
url: "https://github.com/gtsteffaniak/filebrowser/releases/" # the url to link to validate:required
|
||||
- text: "Help" # the text to display on the link validate:required
|
||||
title: "" # the title to display on hover
|
||||
url: "help prompt" # the url to link to validate:required
|
||||
disableNavButtons: false # disable the nav buttons in the sidebar
|
||||
styling:
|
||||
disableEventThemes: false # disable the event based themes,
|
||||
customCSS: "" # if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
|
||||
lightBackground: "#f5f5f5" # specify a valid CSS color property value to use as the background color in light mode
|
||||
darkBackground: "#141D24" # Specify a valid CSS color property value to use as the background color in dark mode
|
||||
customThemes: # A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
|
||||
alternative:
|
||||
description: "Reduce rounded corners" # The description of the theme to display in the UI.
|
||||
css: "reduce-rounded-corners.css" # The css file path and filename to use for the theme.
|
||||
default:
|
||||
description: "The default theme" # The description of the theme to display in the UI.
|
||||
css: "" # The css file path and filename to use for the theme.
|
||||
favicon: "" # path to a favicon to use for the frontend
|
||||
description: "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server" # description that shows up in html head meta description
|
||||
loginIcon: "" # path to an image file for the login page icon
|
||||
loginButtonText: "" # text to display on the login button
|
||||
oidcLoginButtonText: "" # text to display on the OIDC login button
|
||||
disablePWAInstall: false # disable the PWA install button in the sidebar
|
||||
userDefaults: # optional signup/CLI defaults; per-user values are managed in the UI
|
||||
sidebar:
|
||||
disableQuickToggles: false # disable the quick toggles in the sidebar
|
||||
hideFileActions: false # hide the file actions in the sidebar
|
||||
disableHideOnPreview: false # keep sidebar open when previewing files (was preview.disableHideSidebar)
|
||||
sticky: true # keep sidebar open when navigating
|
||||
hideFiles: false # hide files in the sidebar tree navigation, when true, will show only directories
|
||||
showTools: true # show sidebar links with category "tool"; default is true
|
||||
listing:
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
promptRightCloseButton: false # show the prompts close button on the right
|
||||
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
|
||||
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
quickDownload: false # show icon to download in one click
|
||||
showSelectMultiple: false # show select multiple files on desktop
|
||||
singleClick: false # open directory on single click, also enables middle click to open in new tab
|
||||
hideFileExt: "" # space separated list of file extensions to hide in UI
|
||||
showCopyPath: false # show copy path button in the context menu
|
||||
showTypeColumn: false # show the Type column in list and compact views
|
||||
showCreationDateColumn: false # show the Creation date column in list and compact views
|
||||
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
|
||||
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
|
||||
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
|
||||
newFileTemplate: # list of custom filenames that will be used as template for new files
|
||||
- ""
|
||||
preview:
|
||||
image: true # show thumbnails for image files
|
||||
video: true # show thumbnails for video files
|
||||
audio: true # show thumbnails for audio files
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
|
||||
office: true # show thumbnails for office files
|
||||
popup: true # show larger popup preview when hovering over thumbnail
|
||||
disablePreviewExt: "" # comma separated list of file extensions to disable preview for
|
||||
highQuality: true # high quality preview thumbnails
|
||||
folder: true # show thumbnails for folders that have previewable contents
|
||||
models: true # show live thumbnails for 3D models files
|
||||
fileViewer:
|
||||
autoplayMedia: true # autoplay media files in preview
|
||||
disableViewingExt: "" # comma separated list of file extensions to disable viewing for
|
||||
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
|
||||
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
search:
|
||||
disableOptions: false # disable the search options in the search bar
|
||||
ui:
|
||||
darkMode: true # should dark mode be enabled
|
||||
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config
|
||||
locale: "en" # language to use: eg. de, en, or fr
|
||||
fileLoading:
|
||||
maxConcurrentUpload: 10
|
||||
uploadChunkSizeMb: 10
|
||||
clearAll: false
|
||||
downloadChunkSizeMb: 0
|
||||
account:
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
admin: false # allow admin access
|
||||
share: false # allow sharing files
|
||||
realtime: false # allow realtime updates
|
||||
lockPassword: false # disable the user from changing their password
|
||||
requirePasswordChange: false # force password change at next password login
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
loginMethod: "password" # login method to use: eg. password, proxy, oidc
|
||||
disableUpdateNotifications: false # disable update notifications banner for admin users
|
||||
showAdvancedProfile: false # show advanced profile settings in the user profile page
|
||||
maxConcurrentTranscodes: 1 # per-user concurrent transcode sessions (default: 1)
|
||||
shareDefaults: # optional defaults for new shares; managed in the UI
|
||||
shareTheme: ""
|
||||
disableAnonymous: false
|
||||
disableThumbnails: false
|
||||
keepAfterExpiration: false
|
||||
themeColor: ""
|
||||
title: ""
|
||||
description: ""
|
||||
favicon: ""
|
||||
quickDownload: false
|
||||
hideNavButtons: false
|
||||
disableSidebar: false
|
||||
disableShareCard: false
|
||||
enforceDarkLightMode: ""
|
||||
viewMode: ""
|
||||
enableOnlyOffice: false
|
||||
shareType: ""
|
||||
allowDelete: false
|
||||
allowCreate: false
|
||||
allowModify: false
|
||||
disableFileViewer: false
|
||||
disableDownload: false
|
||||
allowReplacements: false
|
||||
sidebarLinks:
|
||||
showHidden: false
|
||||
disableLoginOption: false
|
||||
maxBandwidth: 0
|
||||
allowedUsernames:
|
||||
perUserDownloadLimit: false
|
||||
extractEmbeddedSubtitles: false
|
||||
downloadsLimit: 0
|
||||
quotaLimitBytes: 0
|
||||
hideFileExt: ""
|
||||
banner: ""
|
||||
integrations:
|
||||
office: # validate:omitempty
|
||||
url: "" # The URL to the office editor (OnlyOffice Document Server or Collabora Online), needs to be accessible to the user. validate:required
|
||||
internalUrl: "" # An optional internal address that the filebrowser server can use to communicate with the office editor, could be useful to bypass proxy.
|
||||
secret: "" # secret: OnlyOffice only, the JWT secret shared with the Document Server. Unused with collabora: editing sessions are signed with a key derived from auth.key, and Collabora trusts FileBrowser through its storage.wopi.host allow-list, where FileBrowser's host must be added. validate:required_unless=Product collabora
|
||||
product: "" # office editor behind url: onlyoffice (default) or collabora validate:omitempty,oneof=onlyoffice collabora
|
||||
tokenExpirationHours: 10 # collabora only: lifetime of an editing session, in hours (default: 10)
|
||||
viewOnly: false # view only mode for the office editor
|
||||
media: # validate:omitempty
|
||||
ffmpegPath: "" # path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
|
||||
skipHwCheck: false # SkipHwCheck skips the ffmpeg hardware codec probe entirely (software-only).
|
||||
# Config-only, not a runtime setting — advanced use, e.g. keeping the
|
||||
# multi-second probe out of CI startup.
|
||||
accelDevice: "auto" # AccelDevice selects the acceleration device: "auto" (default), "software"/"cpu",
|
||||
# "igpu", "dgpu", a Linux DRM render node (e.g. /dev/dri/renderD128), or a GPU
|
||||
# name substring. Managed via the admin media settings API.
|
||||
alwaysUseFfmpeg: false # AlwaysUseFfmpeg forces ffmpeg for all probe, metadata, subtitle, and
|
||||
# thumbnail work, skipping the go-media fast path. Managed via the admin
|
||||
# media settings API.
|
||||
http:
|
||||
socket: "" # socket to listen on - eg. /var/run/filebrowser.sock
|
||||
tlsKey: "" # path to TLS key
|
||||
tlsCert: "" # path to TLS cert
|
||||
port: 80 # port to listen on
|
||||
listen: "" # address to listen on (default: 0.0.0.0)
|
||||
baseURL: "/" # base URL for the server, the subpath that the server is running on.
|
||||
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
|
||||
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
disableWebDAV: false # disable webdav support (default: false)
|
||||
trustProxyHeaders: false # honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
|
||||
disableRateLimit: false # turns off built-in auth route rate limiting and failed-login lockout (default false).
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
* LoadingSpinner.vue + initial index.html shell.
|
||||
* Embedded in index.html via the backend; also @import'd from src/css/styles.css for the Vue app.
|
||||
*
|
||||
* Spinner glyph: filled SVG path (data URI mask) so ring thickness tapers along the arc — not alpha-only.
|
||||
* Regenerate: polygon from outer/inner circular arcs (~120° sweep) in viewBox 0 0 100 100; mirror X if spin/orientation needs flipping.
|
||||
*/
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Core box */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader {
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Tapered ring: solid fill + SVG mask (true stroke-width taper, not alpha) */
|
||||
/* Path is mirrored so the thick head leads when rotating clockwise (W3C +angle = CW). */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader__comet {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
direction: ltr;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
/* Mirrored annulus-sector polygon, 120° sweep, viewBox 0 0 100 100 */
|
||||
--fb-loader-taper-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20100%20100%22%3E%3Cpath%20fill%3D%22black%22%20d%3D%22M57.47%2C7.65%20L54.99%2C7.29%20L52.50%2C7.07%20L50.00%2C7.00%20L47.50%2C7.07%20L45.01%2C7.29%20L42.53%2C7.65%20L40.08%2C8.16%20L37.67%2C8.81%20L35.29%2C9.59%20L32.97%2C10.52%20L30.70%2C11.57%20L28.50%2C12.76%20L26.37%2C14.07%20L24.32%2C15.51%20L22.36%2C17.06%20L20.49%2C18.72%20L18.72%2C20.49%20L17.06%2C22.36%20L15.51%2C24.32%20L14.07%2C26.37%20L12.76%2C28.50%20L11.57%2C30.70%20L10.52%2C32.97%20L9.59%2C35.29%20L8.81%2C37.67%20L8.16%2C40.08%20L7.65%2C42.53%20L7.29%2C45.01%20L7.07%2C47.50%20L7.00%2C50.00%20L7.07%2C52.50%20L7.29%2C54.99%20L7.65%2C57.47%20L8.16%2C59.92%20L8.81%2C62.33%20L9.59%2C64.71%20L10.16%2C64.50%20L9.56%2C62.11%20L9.12%2C59.69%20L8.81%2C57.26%20L8.65%2C54.83%20L8.63%2C52.41%20L8.75%2C50.00%20L9.01%2C47.61%20L9.41%2C45.26%20L9.94%2C42.94%20L10.61%2C40.66%20L11.40%2C38.44%20L12.32%2C36.28%20L13.36%2C34.19%20L14.51%2C32.18%20L15.77%2C30.24%20L17.14%2C28.39%20L18.60%2C26.63%20L20.16%2C24.96%20L21.81%2C23.40%20L23.53%2C21.95%20L25.33%2C20.60%20L27.20%2C19.37%20L29.12%2C18.26%20L31.10%2C17.26%20L33.12%2C16.39%20L35.18%2C15.64%20L37.27%2C15.02%20L39.38%2C14.52%20L41.50%2C14.15%20L43.64%2C13.91%20L45.77%2C13.79%20L47.89%2C13.79%20L50.00%2C13.92%20L52.09%2C14.18%20L54.14%2C14.55%20L56.16%2C15.04Z%22%2F%3E%3C%2Fsvg%3E");
|
||||
|
||||
mask-image: var(--fb-loader-taper-mask);
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
transform-origin: 50% 50%;
|
||||
transform-box: fill-box;
|
||||
animation: fb-spinner-rotation var(--animation-duration, 1s) linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fb-spinner-rotation {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Size variants */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader--xsmall {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.loader--small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.loader--medium {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.loader--large {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Initial full-page shell (index.html) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
body > .loader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: 0.1s ease opacity;
|
||||
pointer-events: none;
|
||||
border: none;
|
||||
animation: none;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body > .loader.loader--xsmall,
|
||||
body > .loader.loader--small,
|
||||
body > .loader.loader--medium,
|
||||
body > .loader.loader--large {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body > .loader .loader__comet {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
body > .loader.loader--xsmall .loader__comet {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
body > .loader.loader--small .loader__comet {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
body > .loader.loader--medium .loader__comet {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
body > .loader.loader--large .loader__comet {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
body > .loader.done {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Pulse mode */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader--pulse {
|
||||
border: none;
|
||||
background: var(--spinner-color, var(--primaryColor));
|
||||
animation: fb-pulse-scale var(--animation-duration, 0.75s) ease infinite;
|
||||
}
|
||||
|
||||
@keyframes fb-pulse-scale {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Bounce mode */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader--bounce {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3em;
|
||||
border: none;
|
||||
background: transparent;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.loader--bounce::before,
|
||||
.loader--bounce::after {
|
||||
content: "";
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: fb-bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.loader--bounce::before {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.loader--bounce::after {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.loader--bounce .bounce-dot {
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: fb-bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes fb-bounce-scale {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.loader--bounce.loader--xsmall::before,
|
||||
.loader--bounce.loader--xsmall::after,
|
||||
.loader--bounce.loader--xsmall .bounce-dot {
|
||||
width: 0.3em;
|
||||
height: 0.3em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--small::before,
|
||||
.loader--bounce.loader--small::after,
|
||||
.loader--bounce.loader--small .bounce-dot {
|
||||
width: 0.45em;
|
||||
height: 0.45em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--medium::before,
|
||||
.loader--bounce.loader--medium::after,
|
||||
.loader--bounce.loader--medium .bounce-dot {
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--large::before,
|
||||
.loader--bounce.loader--large::after,
|
||||
.loader--bounce.loader--large .bounce-dot {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Placeholder mode */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.loader--placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
border: none;
|
||||
animation: none;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.placeholder-item {
|
||||
background-color: rgb(128 128 128 / 10%);
|
||||
animation: fb-placeholder-ripple 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite,
|
||||
fb-placeholder-bg-fade 2s ease-in-out infinite;
|
||||
display: block;
|
||||
border-radius: 0.25em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.placeholder-item-1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.placeholder-item-2 {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
|
||||
.placeholder-item-3 {
|
||||
animation-delay: 0.72s;
|
||||
}
|
||||
|
||||
@keyframes fb-placeholder-ripple {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.07);
|
||||
box-shadow: inset 0 -3em 3em rgb(172 172 172 / 21.1%),
|
||||
0 0 0 2px var(--alt-background), 0 4px 12px rgb(0 0 0 / 15%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.97);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fb-placeholder-bg-fade {
|
||||
0% {
|
||||
background-color: rgb(128 128 128 / 10%);
|
||||
}
|
||||
50% {
|
||||
background-color: rgb(128 128 128 / 40%);
|
||||
}
|
||||
100% {
|
||||
background-color: rgb(128 128 128 / 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--xsmall .placeholder-item {
|
||||
width: 30px;
|
||||
height: 7.5px;
|
||||
min-width: 30px;
|
||||
min-height: 7.5px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--small .placeholder-item {
|
||||
width: 150px;
|
||||
height: 12.5px;
|
||||
min-width: 150px;
|
||||
min-height: 12.5px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--medium .placeholder-item {
|
||||
width: 200px;
|
||||
height: 25px;
|
||||
min-width: 200px;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--large .placeholder-item {
|
||||
width: 400px;
|
||||
height: 50px;
|
||||
min-width: 400px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Standard portrait mobile responsive fix */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
@media (width <= 768px) {
|
||||
.loader--placeholder.loader--large .placeholder-item {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/* `--background` is intentionally set here as a placeholder for stylelint */
|
||||
:root {
|
||||
--background: #f5f5f5;
|
||||
--alt-background: #ddd;
|
||||
--surfacePrimary: #ebebeb;
|
||||
--surfaceSecondary: lightgray;
|
||||
--textPrimary: #546e7a;
|
||||
--textSecondary: gray;
|
||||
--divider: rgb(176 176 176 / 50%);
|
||||
--iconBackground: #ddd;
|
||||
--activeWhiteIcon: gray;
|
||||
}
|
||||
|
||||
.dark-mode {
|
||||
--background: #141D24;
|
||||
--alt-background: #283136;
|
||||
--surfacePrimary: #20292F;
|
||||
--surfaceSecondary: #3A4147;
|
||||
--divider: rgb(255 255 255 / 12%);
|
||||
--textPrimary: rgb(255 255 255 / 87%);
|
||||
--textSecondary: rgb(255 255 255 / 60%);
|
||||
--iconBackground: #171718;
|
||||
--activeWhiteIcon: white;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTphNDVjYTUyZi1lNmUzLTQ2OGMtOTVmNS1hNWFjZGNkYWMyNzMAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaC8syWM38pOOa8ixT2VTrZ8AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpmNjUxODAxZS1lY2M0LTRkY2YtODMxYS1jNGRiNTFmMGYyZGVscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNo/rluAl+axcbNl2Z6ES4+rQAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggsS2I9QGZZgKjd+iXY8tdX0wQZiaXSj0p6vOf0HcM3HekZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaGw/ftegvRExRCmWqP7NqVgAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCAGUpNZWl6dyl7Lqc+nxic9U4Llw2dok9BrPLYs6lUCM2RuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiAZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmE0NWNhNTJmLWU2ZTMtNDY4Yy05NWY1LWE1YWNkY2RhYzI3My9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjliMmE1YzY2LTNhYjUtNGM1Ny04OGYzLTBlYWYyNTM4Y2Q3OHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCxLYj1AZlmAqN36Jdjy11fTBBmJpdKPSnq85/Qdwzcd6JjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFgg58nesqQPwea1rdmtJwi2yHM5YvZFC8IFIzlPAQxO/lWiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggkq1NcJFXxZpzavjtsGnWBT1U1jAnT0H2X34P6eLc/jp0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated
|
||||
<defs>
|
||||
<pattern id="halloween-pattern" width="200" height="200" patternUnits="userSpaceOnUse">
|
||||
<rect width="200" height="200" fill="#ececec"/>
|
||||
<g opacity="0.09" fill="#1a1a1a">
|
||||
<!-- Ghost 1 (top-left) - straight sides ghost -->
|
||||
<path d="M 60 60 A 15 15 0 1 1 90 60 L 90 90 C 80 100, 70 100, 60 90 L 60 60 Z" />
|
||||
<circle cx="70" cy="55" r="2" fill="#ececec"/>
|
||||
<circle cx="80" cy="55" r="2" fill="#ececec"/>
|
||||
|
||||
<!-- Ghost 2 (bottom-right, offset for diagonal pattern) - straight sides ghost -->
|
||||
<path d="M 160 160 A 15 15 0 1 1 190 160 L 190 190 C 180 200, 170 200, 160 190 L 160 160 Z" />
|
||||
<circle cx="170" cy="155" r="2" fill="#ececec"/>
|
||||
<circle cx="180" cy="155" r="2" fill="#ececec"/>
|
||||
</g>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="200" height="200" fill="url(#halloween-pattern)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
|
||||
<defs>
|
||||
<pattern id="halloween-pattern" width="200" height="200" patternUnits="userSpaceOnUse">
|
||||
<rect width="200" height="200" fill="#1a1a1a"/>
|
||||
<g opacity="0.08" fill="#cccccc">
|
||||
<!-- Ghost 1 (top-left) - straight sides ghost -->
|
||||
<path d="M 60 60 A 15 15 0 1 1 90 60 L 90 90 C 80 100, 70 100, 60 90 L 60 60 Z" />
|
||||
<circle cx="70" cy="55" r="2" fill="#1a1a1a"/>
|
||||
<circle cx="80" cy="55" r="2" fill="#1a1a1a"/>
|
||||
|
||||
<!-- Ghost 2 (bottom-right, offset for diagonal pattern) - straight sides ghost -->
|
||||
<path d="M 160 160 A 15 15 0 1 1 190 160 L 190 190 C 180 200, 170 200, 160 190 L 160 160 Z" />
|
||||
<circle cx="170" cy="155" r="2" fill="#1a1a1a"/>
|
||||
<circle cx="180" cy="155" r="2" fill="#1a1a1a"/>
|
||||
</g>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="200" height="200" fill="url(#halloween-pattern)"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 949 B |
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Background circle -->
|
||||
<circle cx="16" cy="16" r="16" fill="#141D24"/>
|
||||
|
||||
<!-- Back folder (orange, barely higher and left) -->
|
||||
<path d="M5 8h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z"
|
||||
fill="#FF9800"/>
|
||||
|
||||
<!-- Front folder (blue, almost same position) -->
|
||||
<path d="M6 9h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V11a2 2 0 0 1 2-2z"
|
||||
fill="#2196F3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<symbol id="plyr-airplay" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="M16 1H2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3v-2H3V3h12v8h-2v2h3a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1"
|
||||
/>
|
||||
<path d="M4 17h10l-5-6z" />
|
||||
</symbol>
|
||||
<symbol id="plyr-captions-off" viewBox="0 0 18 18">
|
||||
<path
|
||||
fill-opacity=".5"
|
||||
fill-rule="evenodd"
|
||||
d="M1 1c-.6 0-1 .4-1 1v11c0 .6.4 1 1 1h4.6l2.7 2.7c.2.2.4.3.7.3s.5-.1.7-.3l2.7-2.7H17c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zm4.52 10.15c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41C8.47 4.96 7.46 3.76 5.5 3.76c-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69m7.57 0c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41c-.28-1.15-1.29-2.35-3.25-2.35-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-captions-on" viewBox="0 0 18 18">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M1 1c-.6 0-1 .4-1 1v11c0 .6.4 1 1 1h4.6l2.7 2.7c.2.2.4.3.7.3s.5-.1.7-.3l2.7-2.7H17c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zm4.52 10.15c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41C8.47 4.96 7.46 3.76 5.5 3.76c-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69m7.57 0c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41c-.28-1.15-1.29-2.35-3.25-2.35-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-download" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="M9 13c.3 0 .5-.1.7-.3L15.4 7 14 5.6l-4 4V1H8v8.6l-4-4L2.6 7l5.7 5.7c.2.2.4.3.7.3m-7 2h14v2H2z"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-enter-fullscreen" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-exit-fullscreen" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-fast-forward" viewBox="0 0 18 18">
|
||||
<path d="M7.875 7.171 0 1v16l7.875-6.171V17L18 9 7.875 1z" />
|
||||
</symbol>
|
||||
<symbol id="plyr-logo-vimeo" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="M17 5.3c-.1 1.6-1.2 3.7-3.3 6.4-2.2 2.8-4 4.2-5.5 4.2-.9 0-1.7-.9-2.4-2.6C5 10.9 4.4 6 3 6c-.1 0-.5.3-1.2.8l-.8-1c.8-.7 3.5-3.4 4.7-3.5S7.7 3 8 4.8c.3 2 .8 6.1 1.8 6.1.9 0 2.5-3.4 2.6-4 .1-.9-.3-1.9-2.3-1.1.8-2.6 2.3-3.8 4.5-3.8q2.55.15 2.4 3.3"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-logo-youtube" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="M16.8 5.8c-.2-1.3-.8-2.2-2.2-2.4C12.4 3 9 3 9 3s-3.4 0-5.6.4C2 3.6 1.3 4.5 1.2 5.8 1 7.1 1 9 1 9s0 1.9.2 3.2.8 2.2 2.2 2.4C5.6 15 9 15 9 15s3.4 0 5.6-.4c1.4-.3 2-1.1 2.2-2.4S17 9 17 9s0-1.9-.2-3.2M7 12V6l5 3z"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-muted" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="m12.4 12.5 2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601z"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-pause" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1m6 0c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1z"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-pip" viewBox="0 0 18 18">
|
||||
<path d="M13.293 3.293 7.022 9.564l1.414 1.414 6.271-6.271L17 7V1h-6z" />
|
||||
<path
|
||||
d="M13 15H3V5h5V3H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6h-2z"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-play" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="M15.562 8.1 3.87.225c-.818-.562-1.87 0-1.87.9v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-restart" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="m9.7 1.2.7 6.4 2.1-2.1c1.9 1.9 1.9 5.1 0 7-.9 1-2.2 1.5-3.5 1.5s-2.6-.5-3.5-1.5c-1.9-1.9-1.9-5.1 0-7 .6-.6 1.4-1.1 2.3-1.3l-.6-1.9C6 2.6 4.9 3.2 4 4.1 1.3 6.8 1.3 11.2 4 14c1.3 1.3 3.1 2 4.9 2 1.9 0 3.6-.7 4.9-2 2.7-2.7 2.7-7.1 0-9.9L16 1.9z"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-rewind" viewBox="0 0 18 18">
|
||||
<path d="M10.125 1 0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z" />
|
||||
</symbol>
|
||||
<symbol id="plyr-settings" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="M16.135 7.784a2 2 0 0 1-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 0 1-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 0 1-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 0 1-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 0 1 1.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 0 1 2.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 0 1 2.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 0 1 1.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997M9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="plyr-volume" viewBox="0 0 18 18">
|
||||
<path
|
||||
d="M15.6 3.3c-.4-.4-1-.4-1.4 0s-.4 1 0 1.4C15.4 5.9 16 7.4 16 9s-.6 3.1-1.8 4.3c-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7"
|
||||
/>
|
||||
<path
|
||||
d="M11.282 5.282a.91.91 0 0 0 0 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.91.91 0 0 0 0 1.316c.145.145.636.262 1.018.156a.7.7 0 0 0 .298-.156C13.773 11.733 14.13 10.16 14.13 9q.001-.255-.011-.51c-.053-.992-.319-2.005-1.522-3.208a.91.91 0 0 0-1.316 0m-7.495.726H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601z"
|
||||
/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ if .htmlVars.defaultDarkMode }}dark-mode{{ end }}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>"{{ .htmlVars.title }}"</title>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="{{ .htmlVars.favicon }}">
|
||||
<link rel="stylesheet" href="{{ .htmlVars.staticURL }}/css/variables.css">
|
||||
|
||||
{{- if not .htmlVars.devMode }}
|
||||
<!-- Preload critical fonts for production first paint (skipped in dev: CSS loads async via Vite) -->
|
||||
<link rel="preload" href="{{ .htmlVars.fontURL }}/roboto-normal-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.fontURL }}/roboto-medium-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.fontURL }}/roboto-bold-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.fontURL }}/material-symbols-core-filled.woff2" as="font" type="font/woff2" crossorigin>
|
||||
{{- end }}
|
||||
|
||||
<!-- Add to home screen for Android and modern mobile browsers -->
|
||||
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
|
||||
<meta name="theme-color" content="{{ .htmlVars.color }}">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
|
||||
<!-- Add to home screen for Safari on iOS/iPadOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="assets">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ .htmlVars.appIcon }}">
|
||||
|
||||
<!-- Add to home screen for Windows -->
|
||||
<meta name="msapplication-TileImage" content="{{ .htmlVars.winIcon }}">
|
||||
<meta name="msapplication-TileColor" content="{{ .htmlVars.color }}">
|
||||
|
||||
<meta name="description" content="{{ .htmlVars.description }}">
|
||||
|
||||
<!-- OpenGraph metadata -->
|
||||
<meta property="og:title" content="{{ .htmlVars.title }}">
|
||||
<meta property="og:description" content="{{ .htmlVars.description }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ .htmlVars.url }}">
|
||||
<meta property="og:image" content="{{ .htmlVars.image }}">
|
||||
<meta property="og:image:alt" content="{{ .htmlVars.title }}">
|
||||
|
||||
<!-- Twitter Card metadata -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ .htmlVars.title }}">
|
||||
<meta name="twitter:description" content="{{ .htmlVars.description }}">
|
||||
<meta name="twitter:image" content="{{ .htmlVars.image }}">
|
||||
|
||||
<link rel="canonical" href="https://github.com/gtsteffaniak/filebrowser">
|
||||
<link rel="canonical" href="https://github.com/quantumx-apps">
|
||||
<link rel="canonical" href="https://filebrowserquantum.com">
|
||||
|
||||
<!-- Injected variables come from backend server (JSON block is not subject to script-src CSP) -->
|
||||
<script id="app-config" type="application/json">{{ .globalVars }}</script>
|
||||
<script nonce="{{ .cspNonce }}">
|
||||
window.__pwaDeferredPrompt = null;
|
||||
window.addEventListener("beforeinstallprompt", function (event) {
|
||||
event.preventDefault();
|
||||
window.__pwaDeferredPrompt = event;
|
||||
window.dispatchEvent(new Event("pwa-install-available"));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--background: {{ .htmlVars.lightBackground }};
|
||||
}
|
||||
.dark-mode {
|
||||
--background: {{ .htmlVars.darkBackground }};
|
||||
}
|
||||
/* paint safe areas + iOS overscroll with app background (notched PWAs) */
|
||||
html {
|
||||
background-color: var(--background);
|
||||
min-height: calc(100% + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
{{ .htmlVars.loadingSpinnersCSS }}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<span class="loader loader--medium" aria-busy="true"><span class="loader__comet" aria-hidden="true"></span></span>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<style>
|
||||
{{ .htmlVars.customCSS }}
|
||||
</style>
|
||||
<style id="user-selected-theme">
|
||||
{{ .htmlVars.userSelectedTheme }}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ if .htmlVars.defaultDarkMode }}dark-mode{{ end }}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>"{{ .htmlVars.title }}"</title>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="{{ .htmlVars.favicon }}">
|
||||
<link rel="stylesheet" href="{{ .htmlVars.staticURL }}/css/variables.css">
|
||||
|
||||
{{- if not .htmlVars.devMode }}
|
||||
<!-- Preload critical fonts for production first paint (skipped in dev: CSS loads async via Vite) -->
|
||||
<link rel="preload" href="{{ .htmlVars.fontURL }}/roboto-normal-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.fontURL }}/roboto-medium-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.fontURL }}/roboto-bold-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="{{ .htmlVars.fontURL }}/material-symbols-core-filled.woff2" as="font" type="font/woff2" crossorigin>
|
||||
{{- end }}
|
||||
|
||||
<!-- Add to home screen for Android and modern mobile browsers -->
|
||||
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
|
||||
<meta name="theme-color" content="{{ .htmlVars.color }}">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
|
||||
<!-- Add to home screen for Safari on iOS/iPadOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="assets">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ .htmlVars.appIcon }}">
|
||||
|
||||
<!-- Add to home screen for Windows -->
|
||||
<meta name="msapplication-TileImage" content="{{ .htmlVars.winIcon }}">
|
||||
<meta name="msapplication-TileColor" content="{{ .htmlVars.color }}">
|
||||
|
||||
<meta name="description" content="{{ .htmlVars.description }}">
|
||||
|
||||
<!-- OpenGraph metadata -->
|
||||
<meta property="og:title" content="{{ .htmlVars.title }}">
|
||||
<meta property="og:description" content="{{ .htmlVars.description }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ .htmlVars.url }}">
|
||||
<meta property="og:image" content="{{ .htmlVars.image }}">
|
||||
<meta property="og:image:alt" content="{{ .htmlVars.title }}">
|
||||
|
||||
<!-- Twitter Card metadata -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ .htmlVars.title }}">
|
||||
<meta name="twitter:description" content="{{ .htmlVars.description }}">
|
||||
<meta name="twitter:image" content="{{ .htmlVars.image }}">
|
||||
|
||||
<link rel="canonical" href="https://github.com/gtsteffaniak/filebrowser">
|
||||
<link rel="canonical" href="https://github.com/quantumx-apps">
|
||||
<link rel="canonical" href="https://filebrowserquantum.com">
|
||||
|
||||
<!-- Injected variables come from backend server (JSON block is not subject to script-src CSP) -->
|
||||
<script id="app-config" type="application/json">{{ .globalVars }}</script>
|
||||
<script nonce="{{ .cspNonce }}">
|
||||
window.__pwaDeferredPrompt = null;
|
||||
window.addEventListener("beforeinstallprompt", function (event) {
|
||||
event.preventDefault();
|
||||
window.__pwaDeferredPrompt = event;
|
||||
window.dispatchEvent(new Event("pwa-install-available"));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--background: {{ .htmlVars.lightBackground }};
|
||||
}
|
||||
.dark-mode {
|
||||
--background: {{ .htmlVars.darkBackground }};
|
||||
}
|
||||
/* paint safe areas + iOS overscroll with app background (notched PWAs) */
|
||||
html {
|
||||
background-color: var(--background);
|
||||
min-height: calc(100% + env(safe-area-inset-top, 0px));
|
||||
}
|
||||
{{ .htmlVars.loadingSpinnersCSS }}
|
||||
</style>
|
||||
<script type="module" crossorigin src="{{ .htmlVars.staticURL }}/assets/index-DkcnrSMM.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="{{ .htmlVars.staticURL }}/assets/rolldown-runtime-BCiCTKAo.js">
|
||||
<link rel="modulepreload" crossorigin href="{{ .htmlVars.staticURL }}/assets/store-evUUVZbn.js">
|
||||
<link rel="stylesheet" crossorigin href="{{ .htmlVars.staticURL }}/assets/store-bYVyZJUF.css">
|
||||
<link rel="stylesheet" crossorigin href="{{ .htmlVars.staticURL }}/assets/index-DhFF1S3M.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<span class="loader loader--medium" aria-busy="true"><span class="loader__comet" aria-hidden="true"></span></span>
|
||||
|
||||
|
||||
<style>
|
||||
{{ .htmlVars.customCSS }}
|
||||
</style>
|
||||
<style id="user-selected-theme">
|
||||
{{ .htmlVars.userSelectedTheme }}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -105,6 +105,13 @@ func serveTextPreview(w http.ResponseWriter, r *http.Request, info *iteminfo.Ext
|
||||
if kind == "" {
|
||||
return http.StatusUnsupportedMediaType, fmt.Errorf("unsupported file type for text preview")
|
||||
}
|
||||
// Bound concurrent reads and RTF conversions with the text preview's own
|
||||
// limiter, so a listing of many text files cannot open an unbounded number
|
||||
// of file handles at once. Separate from the image pipeline's semaphore.
|
||||
if err := textpreview.Acquire(r.Context()); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer textpreview.Release()
|
||||
result, err := textpreview.Read(info.RealPath, kind)
|
||||
if err != nil {
|
||||
switch {
|
||||
|
||||
Binary file not shown.
@@ -1207,6 +1207,28 @@ export function getPreviewURL(source, path, modified) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a text preview API URL. Use fetchTextPreview(url, modified) to load it.
|
||||
* Separate from getPreviewURL: the text endpoint returns JSON, not an image.
|
||||
*/
|
||||
export function getTextPreviewURL(source, path) {
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
path: path,
|
||||
source: source,
|
||||
inline: 'true'
|
||||
}
|
||||
const apiPath = getApiPath('resources/preview/text', params)
|
||||
return window.origin + apiPath
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error getting text preview URL')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/resources/archive - Create an archive
|
||||
export async function createArchive(opts) {
|
||||
const { fromSource, toSource, paths, destination, format, compression, deleteAfter } = opts;
|
||||
@@ -1359,17 +1381,28 @@ export function getPreviewURLPublic(path, size, viewToken = state.req?.viewToken
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a text preview URL for public shares
|
||||
/**
|
||||
* Uploads a file, one chunk of it, or creates a folder.
|
||||
* @param {string | undefined} hash
|
||||
* @param {string} path
|
||||
* @param {string | Blob} [content]
|
||||
* @param {boolean} [overwrite]
|
||||
* @param {((percent: number) => void) | undefined} [onupload]
|
||||
* @param {Record<string, string | number>} [headers]
|
||||
* @param {boolean} [isDir]
|
||||
* @returns {Promise<any> & { xhr?: XMLHttpRequest | null, abort?: () => void }}
|
||||
* @param {string} [viewToken]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getTextPreviewURLPublic(path, viewToken = state.req?.viewToken) {
|
||||
try {
|
||||
const params = {
|
||||
path: path,
|
||||
hash: state.shareInfo.hash,
|
||||
inline: 'true',
|
||||
...(viewToken && { viewToken }),
|
||||
}
|
||||
const apiPath = getPublicApiPath('resources/preview/text', params)
|
||||
return window.origin + apiPath
|
||||
} catch (/** @type {any} */ err) {
|
||||
notify.showError(err.message || 'Error getting text preview URL')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function postPublic(
|
||||
hash,
|
||||
path,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
|
||||
const { mockState, mockGetters, setPopupPreview } = vi.hoisted(() => ({
|
||||
mockState: {
|
||||
sessionId: "test-session",
|
||||
user: {},
|
||||
shareInfo: null,
|
||||
req: { source: "main", path: "/", modified: "" },
|
||||
popupPreviewSourceInfo: null,
|
||||
},
|
||||
mockGetters: {
|
||||
sourcePermissions: () => ({ view: true }),
|
||||
previewPerms: () => ({ office: true, popup: true, image: true, video: true, audio: true, models: true, folder: true }),
|
||||
isShare: () => false,
|
||||
viewMode: () => "gallery",
|
||||
isDarkMode: () => false,
|
||||
},
|
||||
setPopupPreview: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
state: mockState,
|
||||
getters: mockGetters,
|
||||
mutations: { setPopupPreview, setPreviewSource: vi.fn() },
|
||||
}));
|
||||
|
||||
// The real TextPreview is async and pulls in highlight.js; a stub is enough to detect mounting.
|
||||
vi.mock("@/utils/asyncComponent.js", () => ({
|
||||
createAsyncComponent: () => ({
|
||||
name: "TextPreviewStub",
|
||||
render: () => h("div", { class: "text-preview" }),
|
||||
}),
|
||||
}));
|
||||
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
type ResizeCb = (entries: ResizeObserverEntry[]) => void;
|
||||
let resizeCallback: ResizeCb | null = null;
|
||||
|
||||
interface IconProps {
|
||||
filename?: string;
|
||||
mimetype?: string;
|
||||
hasPreview?: boolean;
|
||||
path?: string;
|
||||
source?: string;
|
||||
textPreviewUrl?: string;
|
||||
}
|
||||
|
||||
function mountIcon(props: IconProps = {}) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp({
|
||||
render: () =>
|
||||
h(Icon, {
|
||||
filename: "notes.md",
|
||||
mimetype: "text/markdown",
|
||||
hasPreview: true,
|
||||
path: "/notes.md",
|
||||
source: "main",
|
||||
textPreviewUrl: "/api/resources/preview/text?path=%2Fnotes.md",
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
app.mount(el);
|
||||
return { el, app };
|
||||
}
|
||||
|
||||
describe("Icon text preview width gate", () => {
|
||||
beforeEach(() => {
|
||||
setPopupPreview.mockClear();
|
||||
resizeCallback = null;
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(cb: ResizeCb) {
|
||||
resizeCallback = cb;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("shows the type icon plus a preview-available badge on a small tile", async () => {
|
||||
const { el, app } = mountIcon();
|
||||
await nextTick();
|
||||
|
||||
expect(el.querySelector(".text-preview")).toBeNull();
|
||||
const hint = el.querySelector(".overlay-icon--preview-hint");
|
||||
expect(hint).not.toBeNull();
|
||||
expect(hint?.textContent).toContain("saved_search");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("hides the badge when popup previews are disabled", async () => {
|
||||
const original = mockGetters.previewPerms;
|
||||
mockGetters.previewPerms = () => ({
|
||||
office: true,
|
||||
popup: false,
|
||||
image: true,
|
||||
video: true,
|
||||
audio: true,
|
||||
models: true,
|
||||
folder: true,
|
||||
});
|
||||
try {
|
||||
const { el, app } = mountIcon();
|
||||
await nextTick();
|
||||
expect(el.querySelector(".overlay-icon--preview-hint")).toBeNull();
|
||||
app.unmount();
|
||||
} finally {
|
||||
mockGetters.previewPerms = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the hover popup preview working on a small tile", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { el, app } = mountIcon();
|
||||
await nextTick();
|
||||
|
||||
el.querySelector(".image-preview")?.dispatchEvent(new MouseEvent("mouseenter"));
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(setPopupPreview).toHaveBeenCalledTimes(1);
|
||||
expect(setPopupPreview.mock.calls[0]?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
path: "/notes.md",
|
||||
textUrl: "/api/resources/preview/text?path=%2Fnotes.md",
|
||||
});
|
||||
vi.useRealTimers();
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("mounts TextPreview once the tile is wide enough", async () => {
|
||||
const { el, app } = mountIcon();
|
||||
await nextTick();
|
||||
expect(el.querySelector(".text-preview")).toBeNull();
|
||||
|
||||
// Simulate the ResizeObserver reporting a gallery-sized tile.
|
||||
resizeCallback?.([{ contentRect: { width: 400 } } as ResizeObserverEntry]);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(el.querySelector(".text-preview")).not.toBeNull();
|
||||
expect(el.querySelector(".overlay-icon--preview-hint")).toBeNull();
|
||||
app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- Unified preview container for all types -->
|
||||
<span v-if="hasPreviewImage || (shouldUse3DPreview && !threeJsError) || shouldUseTextPreview" class="image-preview" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
<span v-if="hasPreviewImage || (shouldUse3DPreview && !threeJsError) || shouldUseTextPreview" ref="tileEl" class="image-preview" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
<!-- Overlay icons (folder/animation) positioned top-left -->
|
||||
<i v-if="hasPreviewImage && hasMotion && isFile" class="material-symbols-outlined overlay-icon">animation</i>
|
||||
<i v-else-if="hasPreviewImage && !isFile" class="material-symbols overlay-icon">folder</i>
|
||||
@@ -21,18 +21,26 @@
|
||||
@error="handle3DError" />
|
||||
<TextPreview v-else-if="shouldUseTextPreview"
|
||||
:url="textPreviewUrl"
|
||||
:language="textPreviewLanguage"
|
||||
:modified="modified"
|
||||
@error="handleTextPreviewError" />
|
||||
</span>
|
||||
<!-- Regular material icon (no preview) -->
|
||||
<span v-else class="image-preview">
|
||||
<span v-else ref="tileEl" class="image-preview" @mouseenter="hasTextPreview && handleMouseEnter()" @mouseleave="hasTextPreview && handleMouseLeave()">
|
||||
<i :class="[classes, { active: active, clickable: clickable }]"> {{ materialSymbol }} </i>
|
||||
<i v-if="isShared" class="material-symbols overlay-icon">group</i>
|
||||
<!-- Text file with a popup preview, but the tile is too small to render it inline -->
|
||||
<i
|
||||
v-if="textPreviewHintVisible"
|
||||
class="material-symbols overlay-icon overlay-icon--preview-hint"
|
||||
:class="{ 'overlay-icon--preview-hint-shared': isShared }"
|
||||
aria-hidden="true"
|
||||
>saved_search</i>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, ref, unref, watch, type Ref } from "vue";
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, unref, watch, type Ref } from "vue";
|
||||
import { fetchPreviewImage } from "@/api/resources";
|
||||
import { createAsyncComponent } from "@/utils/asyncComponent.js";
|
||||
import { getTypeInfo } from "@/utils/mimetype";
|
||||
@@ -45,8 +53,7 @@ import {
|
||||
storeThumbnailBlob,
|
||||
} from "@/utils/cache/imageCache.ts";
|
||||
import { queuedFetchPreview } from "@/utils/previewRequests.ts";
|
||||
import { textPreviewUrlFor } from "@/utils/textPreview.ts";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { isTextPreviewableName, languageFromPath, MIN_TEXT_PREVIEW_WIDTH_PX } from "@/utils/textPreview.ts";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -64,6 +71,8 @@ const props = withDefaults(
|
||||
isDir?: boolean;
|
||||
viewToken?: string;
|
||||
inView?: boolean;
|
||||
/** Text preview API URL from getTextPreviewURL / getTextPreviewURLPublic. */
|
||||
textPreviewUrl?: string;
|
||||
}>(),
|
||||
{
|
||||
hasPreview: false,
|
||||
@@ -78,6 +87,7 @@ const props = withDefaults(
|
||||
isDir: false,
|
||||
viewToken: "",
|
||||
inView: true,
|
||||
textPreviewUrl: "",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -214,29 +224,57 @@ const shouldUse3DPreview = computed(() => {
|
||||
return typeInfo.value.simpleType === "3d-model";
|
||||
});
|
||||
|
||||
/** Every extension the text preview endpoint supports, from the backend's kind map. */
|
||||
const textPreviewExtSet = computed(() => {
|
||||
const groups = (globalVars.textPreviewExtensions ?? {}) as Record<string, string[]>;
|
||||
return new Set(Object.values(groups).flat());
|
||||
});
|
||||
/** True when the filename has an extension the text preview endpoint supports. */
|
||||
const isTextPreviewable = computed(
|
||||
() => props.mimetype !== "directory" && isTextPreviewableName(props.filename),
|
||||
);
|
||||
|
||||
const isTextPreviewable = computed(() => {
|
||||
if (props.mimetype === "directory" || !props.filename) return false;
|
||||
const dot = props.filename.lastIndexOf(".");
|
||||
if (dot <= 0) return false;
|
||||
return textPreviewExtSet.value.has(props.filename.slice(dot + 1).toLowerCase());
|
||||
});
|
||||
/** Extension used for syntax highlighting and the CSV/TSV delimiter. */
|
||||
const textPreviewLanguage = computed(() => languageFromPath(props.filename));
|
||||
|
||||
const textPreviewUrl = computed(() => (props.thumbnailUrl ? textPreviewUrlFor(props.thumbnailUrl) : ""));
|
||||
// Measured tile width; only observed for text-previewable files so image tiles pay nothing.
|
||||
const tileWidth = ref(0);
|
||||
const tileEl = ref<HTMLElement | null>(null);
|
||||
let tileResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const shouldUseTextPreview = computed(() => {
|
||||
function observeTileWidth(el: HTMLElement | null): void {
|
||||
tileResizeObserver?.disconnect();
|
||||
tileResizeObserver = null;
|
||||
if (!el || !isTextPreviewable.value || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
// ResizeObserver fires once on observe, so no manual initial measurement is needed.
|
||||
tileResizeObserver = new ResizeObserver((entries) => {
|
||||
tileWidth.value = entries[0]?.contentRect.width ?? 0;
|
||||
});
|
||||
tileResizeObserver.observe(el);
|
||||
}
|
||||
|
||||
// The tile element switches between the icon span and the preview span, so re-observe it.
|
||||
watch(tileEl, observeTileWidth, { flush: "post" });
|
||||
|
||||
/** True when the tile is wide enough to render readable text instead of an icon. */
|
||||
const tileWideEnough = computed(() => tileWidth.value >= MIN_TEXT_PREVIEW_WIDTH_PX);
|
||||
|
||||
/**
|
||||
* True when this item has a text preview to show (inline or via the hover popup).
|
||||
* Kept separate from shouldUseTextPreview so the popup still works on small tiles.
|
||||
*/
|
||||
const hasTextPreview = computed(() => {
|
||||
if (!canViewSource.value || !isTextPreviewable.value || !props.hasPreview || !everInView.value) return false;
|
||||
if (textPreviewError.value || props.thumbnailUrl === "") return false;
|
||||
if (textPreviewError.value || props.textPreviewUrl === "") return false;
|
||||
if (state.shareInfo?.disableThumbnails || previewExtDisabled.value) return false;
|
||||
// Same permission as other document-style previews (txt/md/html were already gated by office).
|
||||
return getters.previewPerms().office;
|
||||
});
|
||||
|
||||
const shouldUseTextPreview = computed(() => hasTextPreview.value && tileWideEnough.value);
|
||||
|
||||
/** Show the popup-preview badge on tiles too small for the inline text preview. */
|
||||
const textPreviewHintVisible = computed(
|
||||
() => hasTextPreview.value && !shouldUseTextPreview.value && !hasPreviewImage.value && getters.previewPerms().popup,
|
||||
);
|
||||
|
||||
function handleTextPreviewError(): void {
|
||||
textPreviewError.value = true;
|
||||
}
|
||||
@@ -391,13 +429,14 @@ function showPopupPreview(): void {
|
||||
const source = (getters.isShare() ? state.shareInfo?.hash : props.source || state.req?.source) ?? "";
|
||||
const modified = props.modified || state.req?.modified;
|
||||
|
||||
// Text-like file: popup shows the same text preview tile at a larger fixed size
|
||||
if (shouldUseTextPreview.value) {
|
||||
// Text-like file: popup shows the same text preview tile at a larger fixed size.
|
||||
// Uses hasTextPreview, not shouldUseTextPreview, so small tiles still get a popup.
|
||||
if (hasTextPreview.value) {
|
||||
mutations.setPopupPreview({
|
||||
type: "text",
|
||||
source,
|
||||
path,
|
||||
textUrl: textPreviewUrl.value,
|
||||
textUrl: props.textPreviewUrl,
|
||||
modified,
|
||||
});
|
||||
return;
|
||||
@@ -526,7 +565,14 @@ watch(
|
||||
{ immediate: true }, // Run on component mount
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Only text previews need the measured width, so image tiles never create an observer.
|
||||
observeTileWidth(tileEl.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
tileResizeObserver?.disconnect();
|
||||
tileResizeObserver = null;
|
||||
cancelActivePreviews();
|
||||
if (props.path && state.popupPreviewSourceInfo?.path === props.path) {
|
||||
mutations.setPopupPreview(null);
|
||||
@@ -563,6 +609,19 @@ onBeforeUnmount(() => {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* "Preview available in popup" badge on tiles too small to render the text itself.
|
||||
Top-left, matching the other overlay icons; offset when the shared badge shares that corner. */
|
||||
.overlay-icon--preview-hint {
|
||||
inset: 0.2em auto auto 0.2em;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay-icon--preview-hint-shared {
|
||||
inset: 0.2em auto auto 2.2em;
|
||||
}
|
||||
|
||||
.overlay-icon--bottom.clickable:hover {
|
||||
transform: none;
|
||||
box-shadow: none !important;
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
:size="size"
|
||||
:isShared="isShared"
|
||||
:viewToken="viewToken"
|
||||
:textPreviewUrl="textPreviewUrl"
|
||||
/>
|
||||
<button
|
||||
v-if="quickDownloadEnabled && galleryView"
|
||||
@@ -373,6 +374,28 @@ export default {
|
||||
}
|
||||
return resourcesApi.getPreviewURL(this.source || state.req?.source, previewPath, this.modified);
|
||||
},
|
||||
textPreviewUrl() {
|
||||
// The text preview endpoint is JSON, not an image, so it needs its own URL.
|
||||
// Reuse the same path resolution as thumbnailUrl so duplicates/FileTree work.
|
||||
if (!globalVars.enableThumbs) {
|
||||
return "";
|
||||
}
|
||||
let previewPath;
|
||||
if (this.path) {
|
||||
previewPath = this.path;
|
||||
} else if (state.req?.path && this.name) {
|
||||
previewPath = url.joinPath(state.req.path, this.name);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
if (this.forceFilesApi) {
|
||||
return resourcesApi.getTextPreviewURL(this.source || state.req?.source, previewPath);
|
||||
}
|
||||
if (getters.isShare()) {
|
||||
return resourcesApi.getTextPreviewURLPublic(previewPath);
|
||||
}
|
||||
return resourcesApi.getTextPreviewURL(this.source || state.req?.source, previewPath);
|
||||
},
|
||||
isClickable() {
|
||||
return this.clickable;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
getters: { isDarkMode: () => false },
|
||||
state: { sessionId: "test-session" },
|
||||
}));
|
||||
|
||||
import TextPreview from "./TextPreview.vue";
|
||||
import { clearTextPreviewCache } from "@/utils/textPreview.ts";
|
||||
|
||||
function payload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
content: "",
|
||||
kind: "plain",
|
||||
format: "text",
|
||||
truncated: false,
|
||||
size: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubFetch(data: unknown) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify(data), { status: 200 })),
|
||||
);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
// Wait for the fetch promise chain and queued limiter to settle.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
function mount(props: { url: string; modified?: string | null; language?: string | null }) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const app = createApp({ render: () => h(TextPreview, props) });
|
||||
app.mount(el);
|
||||
return { el, app };
|
||||
}
|
||||
|
||||
describe("TextPreview rendering", () => {
|
||||
afterEach(() => {
|
||||
clearTextPreviewCache();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("does not render a truncation fade for a truncated payload", async () => {
|
||||
stubFetch(payload({ content: "<p>hello world</p>", kind: "html", format: "html", truncated: true, size: 99_999 }));
|
||||
const { el, app } = mount({ url: "/api/resources/preview/text?path=%2Fa.html", modified: "1" });
|
||||
await flush();
|
||||
|
||||
expect(el.querySelector(".text-preview-html")?.textContent).toContain("hello world");
|
||||
expect(el.querySelector(".text-preview-fade")).toBeNull();
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("uses the language prop to pick the CSV delimiter", async () => {
|
||||
stubFetch(payload({ content: "a\tb\n1\t2", kind: "csv" }));
|
||||
const { el, app } = mount({
|
||||
url: "/api/resources/preview/text?path=%2Fdata.tsv",
|
||||
modified: "1",
|
||||
language: "tsv",
|
||||
});
|
||||
await flush();
|
||||
|
||||
const cells = el.querySelectorAll(".text-preview-table thead th");
|
||||
expect(cells).toHaveLength(2);
|
||||
expect(cells[0]?.textContent).toBe("a");
|
||||
expect(cells[1]?.textContent).toBe("b");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("reuses the rendered HTML cache for the same url and modified time", async () => {
|
||||
stubFetch(payload({ content: "<p>cached</p>", kind: "html", format: "html" }));
|
||||
const props = { url: "/api/resources/preview/text?path=%2Fb.html", modified: "7" };
|
||||
|
||||
const first = mount(props);
|
||||
await flush();
|
||||
expect(first.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
|
||||
first.app.unmount();
|
||||
|
||||
// A second mount (as happens when the popup reopens) must not re-fetch.
|
||||
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||
fetchMock.mockClear();
|
||||
const second = mount(props);
|
||||
await flush();
|
||||
expect(second.el.querySelector(".text-preview-html")?.textContent).toContain("cached");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
second.app.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<div ref="root" class="text-preview" :class="{ 'text-preview--dark': isDark }" aria-hidden="true">
|
||||
<span v-if="status === 'loading'" class="material-symbols text-preview-placeholder">{{ PLACEHOLDER_ICON }}</span>
|
||||
<!-- Small tiles: markdown/html cannot fit, so show the readable text only -->
|
||||
<pre v-else-if="status === 'ready' && compactText !== null" class="text-preview-body text-preview-plain text-preview-compact">{{ compactText }}</pre>
|
||||
<div v-else-if="status === 'ready' && csvTable" class="text-preview-body text-preview-table-wrap" :class="{ 'text-preview-compact': compact }">
|
||||
<div v-else-if="status === 'ready' && csvTable" class="text-preview-body text-preview-table-wrap">
|
||||
<table class="text-preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -17,39 +15,41 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else-if="status === 'ready' && renderedHtml" class="text-preview-body text-preview-html" :class="{ 'text-preview-compact': compact }" v-html="renderedHtml"></div>
|
||||
<pre v-else-if="status === 'ready' && highlighted !== null" class="text-preview-body text-preview-plain hljs" :class="{ 'text-preview-compact': compact }" v-html="highlighted"></pre>
|
||||
<pre v-else-if="status === 'ready' && payload" class="text-preview-body text-preview-plain" :class="{ 'text-preview-compact': compact }">{{ payload.content }}</pre>
|
||||
<div v-if="status === 'ready' && payload?.truncated" class="text-preview-fade"></div>
|
||||
<div v-else-if="status === 'ready' && renderedHtml" class="text-preview-body text-preview-html" v-html="renderedHtml"></div>
|
||||
<pre v-else-if="status === 'ready' && highlighted !== null" class="text-preview-body text-preview-plain hljs" v-html="highlighted"></pre>
|
||||
<pre v-else-if="status === 'ready' && payload" class="text-preview-body text-preview-plain">{{ payload.content }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
||||
import { getters } from "@/store";
|
||||
import { applyHighlightTheme, loadHljs } from "@/utils/highlight.ts";
|
||||
import { parseDelimited } from "@/utils/delimited.ts";
|
||||
import { fetchTextPreview, languageFromPreviewUrl } from "@/utils/textPreview.ts";
|
||||
import {
|
||||
plainTextFromHtml,
|
||||
fetchTextPreview,
|
||||
getRenderedTextPreview,
|
||||
languageFromPreviewUrl,
|
||||
setRenderedTextPreview,
|
||||
textPreviewCacheKey,
|
||||
} from "@/utils/textPreview.ts";
|
||||
import {
|
||||
renderMarkdownTile,
|
||||
sanitizeTileHtml,
|
||||
type TextPreviewPayload,
|
||||
} from "@/utils/textPreviewRender.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Text preview API URL (see textPreviewUrlFor). */
|
||||
/** Text preview API URL (see getTextPreviewURL / getTextPreviewURLPublic). */
|
||||
url: string;
|
||||
/** Modified time, used with the URL as the cache key. */
|
||||
modified?: string | null;
|
||||
/** Lowercase extension without the dot; derived from the URL when omitted. */
|
||||
language?: string | null;
|
||||
}>();
|
||||
|
||||
|
||||
const emit = defineEmits<(e: "error") => void>();
|
||||
|
||||
|
||||
// Tiles narrower than this cannot fit rendered markdown or HTML, so they show plain text.
|
||||
const COMPACT_WIDTH_PX = 240;
|
||||
// Only plain source files are syntax highlighted. Markdown and HTML are rendered instead.
|
||||
const HIGHLIGHT_KINDS = new Set(["code", "css"]);
|
||||
// Limits keep the DOM small; the tile only shows its top-left area anyway.
|
||||
@@ -64,37 +64,30 @@ const status = ref<Status>("loading");
|
||||
const payload = shallowRef<TextPreviewPayload | null>(null);
|
||||
let requestId = 0;
|
||||
|
||||
/** Cache key shared with the tile and the popup, so rendering happens once per file. */
|
||||
const cacheKey = computed(() => textPreviewCacheKey(props.url, props.modified ?? null));
|
||||
|
||||
/** Language for highlighting/CSV, preferring the caller-provided extension. */
|
||||
const language = computed(() => props.language ?? languageFromPreviewUrl(props.url));
|
||||
|
||||
// Only markdown, html, and rtf render as HTML. Everything else (plain, css, code) is shown as escaped text.
|
||||
// Rendered HTML is cached so scroll-back and hover do not re-parse and re-sanitize.
|
||||
const renderedHtml = computed(() => {
|
||||
const data = payload.value;
|
||||
if (!data) {
|
||||
return "";
|
||||
}
|
||||
if (data.kind === "markdown") {
|
||||
return renderMarkdownTile(data.content);
|
||||
if (data.kind !== "markdown" && data.kind !== "html" && data.format !== "html") {
|
||||
return "";
|
||||
}
|
||||
if (data.kind === "html" || data.format === "html") {
|
||||
return sanitizeTileHtml(data.content);
|
||||
const key = cacheKey.value;
|
||||
const cached = getRenderedTextPreview(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const compact = ref(false);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// Plain text shown on compact tiles. Markdown is shown as its source, HTML as its visible text.
|
||||
const compactText = computed<string | null>(() => {
|
||||
const data = payload.value;
|
||||
// CSV keeps its table, HTML keeps its rendering, and code/css keep their highlighting on compact tiles.
|
||||
if (!compact.value || !data || data.kind === "csv" || data.kind === "html" || HIGHLIGHT_KINDS.has(data.kind)) {
|
||||
return null;
|
||||
}
|
||||
// Converted RTF is HTML too, so it still falls back to text on compact tiles.
|
||||
if (data.format === "html") {
|
||||
return plainTextFromHtml(sanitizeTileHtml(data.content));
|
||||
}
|
||||
return data.content;
|
||||
const html = data.kind === "markdown" ? renderMarkdownTile(data.content) : sanitizeTileHtml(data.content);
|
||||
setRenderedTextPreview(key, html);
|
||||
return html;
|
||||
});
|
||||
|
||||
// CSV/TSV as a table: first row is the header. A truncated file may end mid-row, so that row is dropped.
|
||||
@@ -103,7 +96,7 @@ const csvTable = computed<{ header: string[]; body: string[][] } | null>(() => {
|
||||
if (data?.kind !== "csv") {
|
||||
return null;
|
||||
}
|
||||
const delimiter = languageFromPreviewUrl(props.url) === "tsv" ? "\t" : ",";
|
||||
const delimiter = language.value === "tsv" ? "\t" : ",";
|
||||
let rows = parseDelimited(data.content, delimiter);
|
||||
if (data.truncated && rows.length > 1) {
|
||||
rows = rows.slice(0, -1);
|
||||
@@ -122,45 +115,46 @@ let highlightId = 0;
|
||||
|
||||
async function updateHighlight(): Promise<void> {
|
||||
const token = ++highlightId;
|
||||
highlighted.value = null;
|
||||
const data = payload.value;
|
||||
if (!data || !HIGHLIGHT_KINDS.has(data.kind)) {
|
||||
highlighted.value = null;
|
||||
return;
|
||||
}
|
||||
const language = languageFromPreviewUrl(props.url);
|
||||
if (!language) {
|
||||
const lang = language.value;
|
||||
if (!lang) {
|
||||
highlighted.value = null;
|
||||
return;
|
||||
}
|
||||
// Highlighted output is theme-independent, so it can be cached and reused.
|
||||
const cacheKeyWithLang = `${cacheKey.value}|hl`;
|
||||
const cached = getRenderedTextPreview(cacheKeyWithLang);
|
||||
if (cached !== undefined) {
|
||||
highlighted.value = cached;
|
||||
return;
|
||||
}
|
||||
highlighted.value = null;
|
||||
try {
|
||||
const hljs = await loadHljs();
|
||||
if (!hljs.getLanguage(language)) {
|
||||
if (!hljs.getLanguage(lang)) {
|
||||
return; // unknown language: keep the plain text
|
||||
}
|
||||
await applyHighlightTheme(isDark.value);
|
||||
const html = hljs.highlight(data.content, { language, ignoreIllegals: true }).value;
|
||||
const html = hljs.highlight(data.content, { language: lang, ignoreIllegals: true }).value;
|
||||
if (token === highlightId) {
|
||||
// highlight.js escapes its input; sanitizing again is cheap defense in depth.
|
||||
highlighted.value = sanitizeTileHtml(html);
|
||||
const safe = sanitizeTileHtml(html);
|
||||
setRenderedTextPreview(cacheKeyWithLang, safe);
|
||||
highlighted.value = safe;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to highlight text preview:", err);
|
||||
}
|
||||
}
|
||||
|
||||
watch([payload, compact, isDark], () => {
|
||||
watch([payload, language, isDark], () => {
|
||||
void updateHighlight();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (root.value && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width ?? 0;
|
||||
compact.value = width > 0 && width < COMPACT_WIDTH_PX;
|
||||
});
|
||||
resizeObserver.observe(root.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.url, () => props.modified],
|
||||
async ([url, modified]) => {
|
||||
@@ -185,8 +179,6 @@ onBeforeUnmount(() => {
|
||||
// Drop any in-flight result so it never updates an unmounted tile.
|
||||
requestId++;
|
||||
highlightId++;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -237,6 +229,8 @@ onBeforeUnmount(() => {
|
||||
padding: 0.6em;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
/* Lay out at a readable width and let the tile clip, instead of wrapping aggressively */
|
||||
min-width: var(--text-preview-min-width, 320px);
|
||||
font-size: 3.4cqw;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
@@ -274,12 +268,6 @@ onBeforeUnmount(() => {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Compact tiles: keep text at a readable minimum size and let the tile clip the rest */
|
||||
.text-preview-body.text-preview-compact {
|
||||
font-size: max(6px, 3.4cqw);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.text-preview-html :deep(h1),
|
||||
.text-preview-html :deep(h2),
|
||||
.text-preview-html :deep(h3),
|
||||
@@ -352,14 +340,4 @@ onBeforeUnmount(() => {
|
||||
color: var(--primaryColor);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.text-preview-fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 30%;
|
||||
background: linear-gradient(to bottom, transparent, var(--surfacePrimary));
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -147,6 +147,7 @@
|
||||
"replay_10",
|
||||
"restart",
|
||||
"save",
|
||||
"saved_search",
|
||||
"search",
|
||||
"search_off",
|
||||
"select_all",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearTextPreviewCache, fetchTextPreview, languageFromPreviewUrl, textPreviewUrlFor } from "./textPreview.ts";
|
||||
import {
|
||||
clearTextPreviewCache,
|
||||
fetchTextPreview,
|
||||
getRenderedTextPreview,
|
||||
isTextPreviewableName,
|
||||
languageFromPath,
|
||||
languageFromPreviewUrl,
|
||||
MIN_TEXT_PREVIEW_WIDTH_PX,
|
||||
setRenderedTextPreview,
|
||||
textPreviewCacheKey,
|
||||
} from "./textPreview.ts";
|
||||
|
||||
describe("languageFromPreviewUrl", () => {
|
||||
it("reads the lowercase extension from the path parameter", () => {
|
||||
@@ -12,17 +22,48 @@ describe("languageFromPreviewUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("textPreviewUrlFor", () => {
|
||||
it("maps the authenticated preview URL to the text endpoint and keeps params", () => {
|
||||
const thumb = "http://localhost/api/resources/preview?path=%2Fa.md&key=123&source=main&inline=true";
|
||||
expect(textPreviewUrlFor(thumb)).toBe(
|
||||
"http://localhost/api/resources/preview/text?path=%2Fa.md&key=123&source=main&inline=true",
|
||||
);
|
||||
describe("languageFromPath", () => {
|
||||
it("derives the extension without parsing a URL", () => {
|
||||
expect(languageFromPath("/src/Main.GO")).toBe("go");
|
||||
expect(languageFromPath("data.TSV")).toBe("tsv");
|
||||
expect(languageFromPath("Makefile")).toBeNull();
|
||||
expect(languageFromPath(".env")).toBeNull();
|
||||
expect(languageFromPath(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTextPreviewableName", () => {
|
||||
it("is true for extensions in the backend kind map", () => {
|
||||
// The vitest setup stubs globalVars.textPreviewExtensions.
|
||||
expect(isTextPreviewableName("notes.md")).toBe(true);
|
||||
expect(isTextPreviewableName("image.png")).toBe(false);
|
||||
expect(isTextPreviewableName("noext")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MIN_TEXT_PREVIEW_WIDTH_PX", () => {
|
||||
it("is the tile width below which no inline preview is rendered", () => {
|
||||
expect(MIN_TEXT_PREVIEW_WIDTH_PX).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendered HTML cache", () => {
|
||||
afterEach(() => {
|
||||
clearTextPreviewCache();
|
||||
});
|
||||
|
||||
it("maps the public share preview URL and keeps the hash", () => {
|
||||
const thumb = "http://localhost/public/api/resources/preview?path=%2Fa.txt&hash=abc";
|
||||
expect(textPreviewUrlFor(thumb)).toBe("http://localhost/public/api/resources/preview/text?path=%2Fa.txt&hash=abc");
|
||||
it("round-trips a value and marks it most recently used", () => {
|
||||
const key = textPreviewCacheKey("/api/resources/preview/text?path=a", "2026-01-01");
|
||||
setRenderedTextPreview(key, "<p>hi</p>");
|
||||
expect(getRenderedTextPreview(key)).toBe("<p>hi</p>");
|
||||
});
|
||||
|
||||
it("keys by URL and modified time", () => {
|
||||
const a = textPreviewCacheKey("/u", "1");
|
||||
const b = textPreviewCacheKey("/u", "2");
|
||||
expect(a).not.toBe(b);
|
||||
setRenderedTextPreview(a, "<p>a</p>");
|
||||
expect(getRenderedTextPreview(b)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { state } from "@/store/index.ts";
|
||||
import { globalVars } from "@/utils/constants.js";
|
||||
import type { TextPreviewPayload } from "@/utils/textPreviewRender.ts";
|
||||
|
||||
// Text previews are small (about 16KB), so the frontend keeps a bounded in-memory cache.
|
||||
@@ -6,13 +7,8 @@ import type { TextPreviewPayload } from "@/utils/textPreviewRender.ts";
|
||||
const MAX_CACHE_ENTRIES = 200;
|
||||
const cache = new Map<string, Promise<TextPreviewPayload>>();
|
||||
|
||||
/**
|
||||
* Derives the text preview URL from an image preview thumbnail URL. Works for both
|
||||
* /api/resources/preview and /public/api/resources/preview, so auth and share params carry over.
|
||||
*/
|
||||
export function textPreviewUrlFor(thumbnailUrl: string): string {
|
||||
return thumbnailUrl.replace(/\/resources\/preview\?/, "/resources/preview/text?");
|
||||
}
|
||||
/** Tiles narrower than this show an indicator instead of rendered markdown/HTML/CSV. */
|
||||
export const MIN_TEXT_PREVIEW_WIDTH_PX = 60;
|
||||
|
||||
/**
|
||||
* Lowercase file extension (no dot) of the file a text preview URL points at, or null.
|
||||
@@ -20,22 +16,85 @@ export function textPreviewUrlFor(thumbnailUrl: string): string {
|
||||
*/
|
||||
export function languageFromPreviewUrl(url: string): string | null {
|
||||
const path = new URL(url, window.location.href).searchParams.get("path") ?? "";
|
||||
return languageFromPath(path);
|
||||
}
|
||||
|
||||
/** Lowercase file extension (no dot) of a path, or null. A leading dot marks a dotfile. */
|
||||
export function languageFromPath(path: string | null | undefined): string | null {
|
||||
if (!path) return null;
|
||||
const name = path.slice(path.lastIndexOf("/") + 1);
|
||||
// A leading dot marks a dotfile (".env"), which has no extension.
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
return name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
async function requestTextPreview(url: string): Promise<TextPreviewPayload> {
|
||||
const res = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
headers: { sessionId: state.sessionId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Text preview request failed: ${res.status}`);
|
||||
/**
|
||||
* Every extension the text preview endpoint supports, from the backend's kind map.
|
||||
* Built once per page rather than once per listing item.
|
||||
*/
|
||||
let textPreviewExtSet: Set<string> | null = null;
|
||||
export function getTextPreviewExtensions(): Set<string> {
|
||||
if (textPreviewExtSet === null) {
|
||||
const groups = (globalVars.textPreviewExtensions ?? {}) as Record<string, string[]>;
|
||||
textPreviewExtSet = new Set(Object.values(groups).flat());
|
||||
}
|
||||
return (await res.json()) as TextPreviewPayload;
|
||||
return textPreviewExtSet;
|
||||
}
|
||||
|
||||
/** True when the filename has an extension the text preview endpoint supports. */
|
||||
export function isTextPreviewableName(filename: string | null | undefined): boolean {
|
||||
const ext = languageFromPath(filename);
|
||||
return ext !== null && getTextPreviewExtensions().has(ext);
|
||||
}
|
||||
|
||||
// Text fetches are queued so a folder full of text files does not fire dozens of
|
||||
// requests at once. Mirrors previewRequests.ts, but returns parsed JSON.
|
||||
const MAX_CONCURRENT_LOADS = 8;
|
||||
let activeLoads = 0;
|
||||
const queue: Array<() => void> = [];
|
||||
|
||||
function runQueued<T>(task: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const start = () => {
|
||||
activeLoads++;
|
||||
task()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeLoads--;
|
||||
const next = queue.shift();
|
||||
if (next) next();
|
||||
});
|
||||
};
|
||||
if (activeLoads < MAX_CONCURRENT_LOADS) {
|
||||
start();
|
||||
} else {
|
||||
queue.push(start);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
|
||||
async function requestTextPreview(url: string, signal?: AbortSignal): Promise<TextPreviewPayload> {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
return runQueued(async () => {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
headers: { sessionId: state.sessionId },
|
||||
...(signal && { signal }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Text preview request failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as TextPreviewPayload;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +126,41 @@ export function fetchTextPreview(url: string, modified: string | null): Promise<
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Test helper: clears the in-memory text preview cache. */
|
||||
// Final sanitized/rendered HTML is the expensive part of a text preview. Virtualization
|
||||
// unmounts off-window tiles and the popup unmounts on mouse-out, so without this every
|
||||
// scroll-back and hover would re-run markdown parsing + DOMPurify.
|
||||
const MAX_RENDERED_ENTRIES = 200;
|
||||
const renderedCache = new Map<string, string>();
|
||||
|
||||
/** Cache key for a rendered text preview, matching fetchTextPreview's key. */
|
||||
export function textPreviewCacheKey(url: string, modified: string | null): string {
|
||||
return `${url}|${modified ?? ""}`;
|
||||
}
|
||||
|
||||
/** Returns the cached rendered HTML for a key, marking it as most recently used. */
|
||||
export function getRenderedTextPreview(key: string): string | undefined {
|
||||
const value = renderedCache.get(key);
|
||||
if (value !== undefined) {
|
||||
renderedCache.delete(key);
|
||||
renderedCache.set(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Stores rendered HTML for a key, evicting the least recently used entry past the limit. */
|
||||
export function setRenderedTextPreview(key: string, html: string): void {
|
||||
renderedCache.delete(key);
|
||||
renderedCache.set(key, html);
|
||||
if (renderedCache.size > MAX_RENDERED_ENTRIES) {
|
||||
const oldest = renderedCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
renderedCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper: clears the in-memory text preview caches. */
|
||||
export function clearTextPreviewCache(): void {
|
||||
cache.clear();
|
||||
renderedCache.clear();
|
||||
}
|
||||
@@ -86,6 +86,14 @@ vi.mock('@/utils/constants', () => {
|
||||
return {
|
||||
globalVars: {
|
||||
baseURL: "/files/",
|
||||
textPreviewExtensions: {
|
||||
markdown: ["md"],
|
||||
code: ["go", "js", "ts"],
|
||||
csv: ["csv", "tsv"],
|
||||
html: ["html"],
|
||||
plain: ["txt"],
|
||||
rtf: ["rtf"],
|
||||
},
|
||||
},
|
||||
baseURL: "/files/",
|
||||
};
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
import { expect, test } from "../test-setup.ts";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test, ensureListingViewMode } from "../test-setup.ts";
|
||||
|
||||
const FILES_DIR = "/files/playwright-files/files/";
|
||||
const REMOTE_IMAGE_ALT = "Remote screenshot from filebrowserquantum.com";
|
||||
|
||||
/** Drive the StatusBar size slider so the gallery tiles are wide enough for a text preview. */
|
||||
async function setGallerySize(page: Page, size: number): Promise<void> {
|
||||
await page.locator("#gallery-size").evaluate((el, value) => {
|
||||
const input = el as HTMLInputElement;
|
||||
input.value = String(value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}, size);
|
||||
}
|
||||
|
||||
/** Text previews only render inline once the tile is wide enough; gallery does that. */
|
||||
async function showTextPreviewGallery(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path);
|
||||
await setGallerySize(page, 9);
|
||||
await ensureListingViewMode(page, "gallery");
|
||||
}
|
||||
|
||||
// Thumbnails of HTML files must not show images or videos. The full viewer still shows them.
|
||||
|
||||
test("html thumbnail in the listing shows text but no images or videos", async ({ page }) => {
|
||||
await page.goto(FILES_DIR);
|
||||
await showTextPreviewGallery(page, FILES_DIR);
|
||||
const tile = page.locator('a[aria-label="index.html"] .text-preview');
|
||||
await tile.waitFor({ state: "visible" });
|
||||
await expect(tile).toContainText("Relative path resolution");
|
||||
await expect(tile.locator("img, image, picture, video")).toHaveCount(0);
|
||||
await expect(tile.locator("[srcset], [background]")).toHaveCount(0);
|
||||
// The truncation fade was removed; truncated content is clipped instead.
|
||||
await expect(tile.locator(".text-preview-fade")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("html thumbnail for a page with a remote image omits the image", async ({ page }) => {
|
||||
await page.goto(`${FILES_DIR}nested/`);
|
||||
await showTextPreviewGallery(page, `${FILES_DIR}nested/`);
|
||||
const tile = page.locator('a[aria-label="child.html"] .text-preview');
|
||||
await tile.waitFor({ state: "visible" });
|
||||
await expect(tile).toContainText("Child page");
|
||||
@@ -23,6 +43,27 @@ test("html thumbnail for a page with a remote image omits the image", async ({ p
|
||||
await expect(tile.locator(`img[alt="${REMOTE_IMAGE_ALT}"]`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("small tiles show a preview-available indicator instead of the text preview", async ({ page }) => {
|
||||
await page.goto(FILES_DIR);
|
||||
await ensureListingViewMode(page, "list");
|
||||
|
||||
const item = page.locator('a[aria-label="index.html"]');
|
||||
// The tile is too small to render text, so the icon plus a badge is shown instead.
|
||||
await expect(item.locator(".text-preview")).toHaveCount(0);
|
||||
await expect(item.locator(".overlay-icon--preview-hint")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("hovering a small tile still opens the text preview popup", async ({ page }) => {
|
||||
await page.goto(FILES_DIR);
|
||||
await ensureListingViewMode(page, "list");
|
||||
|
||||
const item = page.locator('a[aria-label="index.html"]');
|
||||
await item.hover();
|
||||
const popup = page.locator(".popup-preview .text-preview");
|
||||
await popup.waitFor({ state: "visible" });
|
||||
await expect(popup).toContainText("Relative path resolution");
|
||||
});
|
||||
|
||||
test("full html viewer shows relative images and videos", async ({ page }) => {
|
||||
await page.goto(`${FILES_DIR}index.html`);
|
||||
const frame = page.frameLocator("iframe.html-content");
|
||||
|
||||
Reference in new issue
Block a user