mirror of
https://github.com/Lissy93/dashy.git
synced 2026-01-20 23:01:55 +01:00
🌟 Adds Quantum file browser widget (#1966)
This commit is contained in:
parent
fdb0b9840e
commit
ceb6023208
3 changed files with 479 additions and 0 deletions
|
|
@ -70,6 +70,7 @@ Dashy has support for displaying dynamic content in the form of widgets. There a
|
|||
- [Sabnzbd](#sabnzbd)
|
||||
- [Gluetun VPN Info](#gluetun-vpn-info)
|
||||
- [Drone CI Build](#drone-ci-builds)
|
||||
- [Filebrowser](#filebrowser)
|
||||
- [Linkding](#linkding)
|
||||
- [Uptime Kuma](#uptime-kuma)
|
||||
- [Uptime Kuma Status Page](#uptime-kuma-status-page)
|
||||
|
|
@ -2606,6 +2607,77 @@ Display the last builds from a [Drone CI](https://www.drone.ci) instance. A self
|
|||
|
||||
---
|
||||
|
||||
### Filebrowser
|
||||
|
||||
Displays storage statistics and file listings from a [Filebrowser Quantum](https://github.com/gtsteffaniak/filebrowser) instance. Shows directory size, file/folder counts, favorite files, and recently modified files with quick-access links.
|
||||
|
||||
#### Options
|
||||
|
||||
**Field** | **Type** | **Required** | **Description**
|
||||
--- | --- | --- | ---
|
||||
**`hostname`** | `string` | Required | The URL of your Filebrowser instance
|
||||
**`apiKey`** | `string` | Required | A long-lived API key (create in Settings → API Keys)
|
||||
**`source`** | `string` | _Optional_ | The source/scope name to browse. Defaults to the first available source
|
||||
**`path`** | `string` | _Optional_ | The directory path to display. Defaults to `/`
|
||||
**`favorites`** | `array` | _Optional_ | List of filenames to show as quick-access favorites
|
||||
**`showRecent`** | `number` | _Optional_ | Number of recently modified files to display. Defaults to `5`, set to `0` to disable
|
||||
**`limit`** | `number` | _Optional_ | Maximum number of files to display per section. Defaults to `10`
|
||||
**`hideStats`** | `boolean` | _Optional_ | If `true`, hides the storage statistics section
|
||||
**`hideFavorites`** | `boolean` | _Optional_ | If `true`, hides the favorites section
|
||||
**`hideRecent`** | `boolean` | _Optional_ | If `true`, hides the recent files section
|
||||
**`showDetailedStats`** | `boolean` | _Optional_ | If `true`, shows additional statistics including last modified date, largest file, hidden file count, total items, and file type breakdown. Defaults to `false`
|
||||
|
||||
#### Example
|
||||
|
||||
**Basic usage:**
|
||||
|
||||
```yaml
|
||||
- type: filebrowser
|
||||
useProxy: true
|
||||
options:
|
||||
hostname: http://filebrowser.local:8080
|
||||
apiKey: VUE_APP_FILEBROWSER_KEY
|
||||
source: Documents
|
||||
path: /
|
||||
showRecent: 5
|
||||
favorites:
|
||||
- important-notes.txt
|
||||
- config.yaml
|
||||
```
|
||||
|
||||
**With detailed statistics:**
|
||||
|
||||
```yaml
|
||||
- type: filebrowser
|
||||
useProxy: true
|
||||
options:
|
||||
hostname: http://filebrowser.local:8080
|
||||
apiKey: VUE_APP_FILEBROWSER_KEY
|
||||
source: Downloads
|
||||
showDetailedStats: true
|
||||
showRecent: 10
|
||||
limit: 15
|
||||
```
|
||||
|
||||
#### Widget Sections
|
||||
|
||||
The widget displays up to four sections:
|
||||
|
||||
1. **Storage Stats** - Directory name, total size, file and folder counts
|
||||
2. **Detailed Stats** (optional) - Last modified date, largest file, hidden file count, total items, and file type breakdown with badges
|
||||
3. **Favorites** - Quick-access links to user-specified files
|
||||
4. **Recent Files** - Most recently modified files sorted by date
|
||||
|
||||
#### Info
|
||||
|
||||
- **CORS**: 🟠 Proxied
|
||||
- **Auth**: 🟢 Required
|
||||
- **Price**: 🟢 Free
|
||||
- **Host**: Self-Hosted (see [Filebrowser Quantum](https://github.com/gtsteffaniak/filebrowser))
|
||||
- **Privacy**: _Self-Hosted_
|
||||
|
||||
---
|
||||
|
||||
### Linkding
|
||||
|
||||
Linkding is a self-hosted bookmarking service, which has a clean interface and is simple to set up. This lists the links, filterable by tags.
|
||||
|
|
|
|||
406
src/components/Widgets/Filebrowser.vue
Normal file
406
src/components/Widgets/Filebrowser.vue
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
<template>
|
||||
<div class="filebrowser-widget">
|
||||
<!-- Storage Stats -->
|
||||
<div class="storage-stats" v-if="directoryInfo && !hideStats">
|
||||
<p class="source-name">{{ directoryInfo.name }}</p>
|
||||
<p class="stat-row">
|
||||
<span class="size">{{ formattedSize }}</span>
|
||||
<span class="counts">{{ fileCount }} files, {{ folderCount }} folders</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Stats Section -->
|
||||
<div v-if="showDetailedStats && directoryInfo" class="detailed-stats">
|
||||
<p class="section-title">Statistics</p>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item" v-if="directoryInfo.modified">
|
||||
<span class="stat-label">Last Modified</span>
|
||||
<span class="stat-value">{{ formatDate(directoryInfo.modified) }}</span>
|
||||
</div>
|
||||
<div class="stat-item" v-if="largestFile">
|
||||
<span class="stat-label">Largest File</span>
|
||||
<span class="stat-value" :title="largestFile.name">
|
||||
{{ truncate(largestFile.name, 18) }}
|
||||
<small>({{ formatSize(largestFile.size) }})</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-item" v-if="hiddenCount > 0">
|
||||
<span class="stat-label">Hidden Files</span>
|
||||
<span class="stat-value">{{ hiddenCount }}</span>
|
||||
</div>
|
||||
<div class="stat-item" v-if="totalItems > 0">
|
||||
<span class="stat-label">Total Items</span>
|
||||
<span class="stat-value">{{ totalItems }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-types" v-if="fileTypesList.length">
|
||||
<span class="stat-label">File Types</span>
|
||||
<div class="type-badges">
|
||||
<span
|
||||
v-for="ft in fileTypesList"
|
||||
:key="ft.type"
|
||||
class="type-badge"
|
||||
:title="`${ft.count} ${ft.type} file(s)`"
|
||||
>
|
||||
{{ ft.label }} <small>{{ ft.count }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Favorites Section -->
|
||||
<div v-if="matchedFavorites.length && !hideFavorites" class="file-list">
|
||||
<p class="section-title">Favorites</p>
|
||||
<div class="file-links">
|
||||
<a
|
||||
v-for="(file, idx) in matchedFavorites"
|
||||
:key="`fav-${idx}`"
|
||||
:href="getFileUrl(file)"
|
||||
:title="file.name"
|
||||
target="_blank"
|
||||
class="file-link"
|
||||
>
|
||||
<span class="file-name">{{ truncate(file.name) }}</span>
|
||||
<span class="file-size">{{ formatSize(file.size) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Files Section -->
|
||||
<div v-if="recentFiles.length && !hideRecent" class="file-list">
|
||||
<p class="section-title">Recent</p>
|
||||
<div class="file-links">
|
||||
<a
|
||||
v-for="(file, idx) in recentFiles"
|
||||
:key="`recent-${idx}`"
|
||||
:href="getFileUrl(file)"
|
||||
:title="file.name"
|
||||
target="_blank"
|
||||
class="file-link"
|
||||
>
|
||||
<span class="file-name">{{ truncate(file.name) }}</span>
|
||||
<span class="file-meta">{{ formatDate(file.modified) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WidgetMixin from '@/mixins/WidgetMixin';
|
||||
import { convertBytes, getTimeAgo, truncateStr } from '@/utils/MiscHelpers';
|
||||
|
||||
export default {
|
||||
mixins: [WidgetMixin],
|
||||
data() {
|
||||
return {
|
||||
directoryInfo: null,
|
||||
files: [],
|
||||
folders: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hostname() {
|
||||
const host = this.parseAsEnvVar(this.options.hostname);
|
||||
if (!host) this.error('Filebrowser hostname is required');
|
||||
return host ? host.replace(/\/$/, '') : '';
|
||||
},
|
||||
apiKey() {
|
||||
const key = this.parseAsEnvVar(this.options.apiKey);
|
||||
if (!key) this.error('API key is required');
|
||||
return key;
|
||||
},
|
||||
source() {
|
||||
return this.options.source || '';
|
||||
},
|
||||
path() {
|
||||
return this.options.path || '/';
|
||||
},
|
||||
showRecent() {
|
||||
return this.options.showRecent ?? 5;
|
||||
},
|
||||
limit() {
|
||||
return this.options.limit || 10;
|
||||
},
|
||||
favorites() {
|
||||
return this.options.favorites || [];
|
||||
},
|
||||
hideStats() {
|
||||
return this.options.hideStats || false;
|
||||
},
|
||||
hideFavorites() {
|
||||
return this.options.hideFavorites || false;
|
||||
},
|
||||
hideRecent() {
|
||||
return this.options.hideRecent || false;
|
||||
},
|
||||
showDetailedStats() {
|
||||
return this.options.showDetailedStats || false;
|
||||
},
|
||||
endpoint() {
|
||||
const base = `${this.hostname}/api/resources`;
|
||||
const params = new URLSearchParams({ auth: this.apiKey });
|
||||
if (this.source) params.append('source', this.source);
|
||||
if (this.path && this.path !== '/') params.append('path', this.path);
|
||||
return `${base}?${params.toString()}`;
|
||||
},
|
||||
formattedSize() {
|
||||
return this.directoryInfo ? convertBytes(this.directoryInfo.size) : '';
|
||||
},
|
||||
fileCount() {
|
||||
return this.files.length;
|
||||
},
|
||||
folderCount() {
|
||||
return this.folders.length;
|
||||
},
|
||||
matchedFavorites() {
|
||||
if (this.hideFavorites || !this.favorites.length || !this.files.length) return [];
|
||||
const favSet = new Set(this.favorites.map((f) => f.replace(/^\//, '')));
|
||||
return this.files.filter((file) => favSet.has(file.name)).slice(0, this.limit);
|
||||
},
|
||||
recentFiles() {
|
||||
if (this.hideRecent || !this.showRecent || !this.files.length) return [];
|
||||
const favNames = new Set(this.matchedFavorites.map((f) => f.name));
|
||||
const maxRecent = Math.min(this.showRecent, this.limit);
|
||||
return [...this.files]
|
||||
.filter((f) => !favNames.has(f.name))
|
||||
.sort((a, b) => new Date(b.modified) - new Date(a.modified))
|
||||
.slice(0, maxRecent);
|
||||
},
|
||||
largestFile() {
|
||||
if (!this.files.length) return null;
|
||||
return this.files.reduce((max, f) => (f.size > max.size ? f : max), this.files[0]);
|
||||
},
|
||||
hiddenCount() {
|
||||
const hiddenFiles = this.files.filter((f) => f.hidden).length;
|
||||
const hiddenFolders = this.folders.filter((f) => f.hidden).length;
|
||||
return hiddenFiles + hiddenFolders;
|
||||
},
|
||||
totalItems() {
|
||||
return this.files.length + this.folders.length;
|
||||
},
|
||||
fileTypesList() {
|
||||
if (!this.files.length) return [];
|
||||
const types = {};
|
||||
this.files.forEach((f) => {
|
||||
const type = f.type || 'unknown';
|
||||
types[type] = (types[type] || 0) + 1;
|
||||
});
|
||||
return Object.entries(types)
|
||||
.map(([type, count]) => ({
|
||||
type,
|
||||
count,
|
||||
label: this.getTypeLabel(type),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchData() {
|
||||
if (!this.hostname || !this.apiKey) return;
|
||||
this.makeRequest(this.endpoint).then(this.processData);
|
||||
},
|
||||
processData(data) {
|
||||
if (!data || data.status) {
|
||||
this.error(data?.message || 'Failed to fetch data from Filebrowser');
|
||||
return;
|
||||
}
|
||||
this.directoryInfo = {
|
||||
name: data.name || data.source || 'Files',
|
||||
size: data.size || 0,
|
||||
path: data.path || '/',
|
||||
source: data.source || '',
|
||||
modified: data.modified || null,
|
||||
};
|
||||
this.files = data.files || [];
|
||||
this.folders = data.folders || [];
|
||||
},
|
||||
getFileUrl(file) {
|
||||
const filePath = this.path === '/' ? `/${file.name}` : `${this.path}/${file.name}`;
|
||||
return `${this.hostname}/files${filePath}?source=${this.source}`;
|
||||
},
|
||||
formatSize(bytes) {
|
||||
return convertBytes(bytes);
|
||||
},
|
||||
formatDate(timestamp) {
|
||||
return getTimeAgo(timestamp);
|
||||
},
|
||||
truncate(str, len = 28) {
|
||||
return truncateStr(str, len);
|
||||
},
|
||||
getTypeLabel(type) {
|
||||
const typeMap = {
|
||||
'application/yaml': 'YAML',
|
||||
'application/json': 'JSON',
|
||||
'application/pdf': 'PDF',
|
||||
'application/zip': 'ZIP',
|
||||
'text/plain': 'Text',
|
||||
'text/html': 'HTML',
|
||||
'text/css': 'CSS',
|
||||
'text/javascript': 'JS',
|
||||
directory: 'Folder',
|
||||
blob: 'Binary',
|
||||
};
|
||||
if (typeMap[type]) return typeMap[type];
|
||||
if (type.startsWith('image/')) return 'Image';
|
||||
if (type.startsWith('video/')) return 'Video';
|
||||
if (type.startsWith('audio/')) return 'Audio';
|
||||
if (type.startsWith('application/')) return type.split('/')[1].toUpperCase();
|
||||
return type.split('/').pop() || 'Other';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filebrowser-widget {
|
||||
color: var(--widget-text-color);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
.storage-stats {
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px dashed var(--widget-text-color);
|
||||
|
||||
.source-name {
|
||||
margin: 0 0 0.25rem;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.size {
|
||||
font-weight: bold;
|
||||
color: var(--widget-accent-color, var(--primary));
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.detailed-stats {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px dashed var(--widget-text-color);
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.25rem 0;
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-monospace);
|
||||
color: var(--widget-text-color);
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-types {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--widget-text-color);
|
||||
opacity: 0.8;
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.type-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
background: var(--widget-accent-color, var(--primary));
|
||||
color: var(--widget-background-color, var(--background));
|
||||
border-radius: 0.75rem;
|
||||
opacity: 0.9;
|
||||
|
||||
small {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.file-links {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0;
|
||||
color: var(--widget-text-color);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-bottom-color: var(--widget-accent-color, var(--primary));
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size,
|
||||
.file-meta {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -63,6 +63,7 @@ const COMPAT = {
|
|||
embed: 'EmbedWidget',
|
||||
'eth-gas-prices': 'EthGasPrices',
|
||||
'exchange-rates': 'ExchangeRates',
|
||||
filebrowser: 'Filebrowser',
|
||||
'flight-data': 'Flights',
|
||||
'github-profile-stats': 'GitHubProfile',
|
||||
'github-trending-repos': 'GitHubTrending',
|
||||
|
|
|
|||
Loading…
Reference in a new issue