feat: adding metadata button
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -37,3 +37,7 @@ build/
|
|||||||
|
|
||||||
default.nix
|
default.nix
|
||||||
Dockerfile.dev
|
Dockerfile.dev
|
||||||
|
filebrowser.log
|
||||||
|
filebrowser.pid
|
||||||
|
frontend-dev.log
|
||||||
|
frontend.pid
|
||||||
|
|||||||
7924
frontend/package-lock.json
generated
Normal file
7924
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -188,6 +188,21 @@ export async function checksum(url: string, algo: ChecksumAlg) {
|
|||||||
return (await data.json()).checksums[algo];
|
return (await data.json()).checksums[algo];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function metadata(url: string) {
|
||||||
|
// Fetch metadata for a resource. Backend support is required for this to work.
|
||||||
|
const res = await resourceAction(`${url}?metadata=1`, "GET");
|
||||||
|
try {
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMetadata(url: string, content: any) {
|
||||||
|
// Update metadata for a resource. Backend must support PATCH with action=metadata.
|
||||||
|
return resourceAction(`${url}?action=metadata`, "PATCH", JSON.stringify(content));
|
||||||
|
}
|
||||||
|
|
||||||
export function getDownloadURL(file: ResourceItem, inline: any) {
|
export function getDownloadURL(file: ResourceItem, inline: any) {
|
||||||
const params = {
|
const params = {
|
||||||
...(inline && { inline: "true" }),
|
...(inline && { inline: "true" }),
|
||||||
|
|||||||
151
frontend/src/components/prompts/ModifyMetadata.vue
Normal file
151
frontend/src/components/prompts/ModifyMetadata.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card floating metadata-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<h2>{{ $t('metadata') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<p v-if="selectedCount > 1">
|
||||||
|
{{ $t('prompts.filesSelected', { count: selectedCount }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="selectedCount === 0">
|
||||||
|
<p>{{ $t('prompts.noFileSelected') }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="field in fields" :key="field" class="metadata-field">
|
||||||
|
<p class="metadata-title"><strong>{{ fieldLabels[field] || field }}</strong></p>
|
||||||
|
<p class="metadata-current">{{ displayCurrent(field) }}</p>
|
||||||
|
<div class="metadata-edit">
|
||||||
|
<input
|
||||||
|
v-model="newValues[field]"
|
||||||
|
:placeholder="placeholders[field] || ''"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="button button--flat"
|
||||||
|
@click.prevent="applyField(field)"
|
||||||
|
>
|
||||||
|
{{ $t('buttons.apply') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-action">
|
||||||
|
<button
|
||||||
|
id="focus-prompt"
|
||||||
|
type="submit"
|
||||||
|
@click="closeHovers"
|
||||||
|
class="button button--flat"
|
||||||
|
:aria-label="$t('buttons.ok')"
|
||||||
|
:title="$t('buttons.ok')"
|
||||||
|
>
|
||||||
|
{{ $t('buttons.ok') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapActions, mapState } from "pinia";
|
||||||
|
import { useFileStore } from "@/stores/file";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { metadata as apiMetadata, updateMetadata } from "@/api/files";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "modifyMetadata",
|
||||||
|
inject: ["$showError"],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
fields: ["title", "artist", "album", "track", "genre", "date", "comment"],
|
||||||
|
fieldLabels: {
|
||||||
|
title: this.$t ? this.$t('prompts.title') : 'title',
|
||||||
|
artist: this.$t ? this.$t('prompts.artist') : 'artist',
|
||||||
|
album: this.$t ? this.$t('prompts.album') : 'album',
|
||||||
|
track: this.$t ? this.$t('prompts.track') : 'track',
|
||||||
|
genre: this.$t ? this.$t('prompts.genre') : 'genre',
|
||||||
|
date: this.$t ? this.$t('prompts.date') : 'date',
|
||||||
|
comment: this.$t ? this.$t('prompts.comment') : 'comment',
|
||||||
|
},
|
||||||
|
placeholders: {},
|
||||||
|
metadataList: [],
|
||||||
|
newValues: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(useFileStore, ["req", "selected", "selectedCount"]),
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||||
|
async mountedFetch() {
|
||||||
|
// Initialize newValues
|
||||||
|
this.fields.forEach((f) => (this.newValues[f] = ""));
|
||||||
|
|
||||||
|
if (!this.req) return;
|
||||||
|
|
||||||
|
const files = this.selected.map((i) => this.req.items[i].url);
|
||||||
|
|
||||||
|
// Fetch metadata for each selected file if possible
|
||||||
|
try {
|
||||||
|
const promises = files.map((u) => apiMetadata(u));
|
||||||
|
this.metadataList = (await Promise.all(promises)).map((m) => m || {});
|
||||||
|
} catch (e) {
|
||||||
|
// If fetching metadata fails, just keep empty list
|
||||||
|
this.metadataList = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
displayCurrent(field) {
|
||||||
|
if (!this.metadataList || this.metadataList.length === 0) return "";
|
||||||
|
|
||||||
|
const vals = this.metadataList.map((m) => m[field] ?? "");
|
||||||
|
const allEqual = vals.every((v) => v === vals[0]);
|
||||||
|
if (allEqual) return vals[0];
|
||||||
|
return this.$t ? this.$t('prompts.multipleValues') : '(multiple values)';
|
||||||
|
},
|
||||||
|
async applyField(field) {
|
||||||
|
if (!this.req) return;
|
||||||
|
|
||||||
|
const value = this.newValues[field];
|
||||||
|
if (value === undefined || value === null || value === "") return;
|
||||||
|
|
||||||
|
const files = this.selected.map((i) => this.req.items[i].url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(files.map((u) => updateMetadata(u, { [field]: value })));
|
||||||
|
// trigger a refresh of listing
|
||||||
|
const fileStore = useFileStore();
|
||||||
|
fileStore.reload = true;
|
||||||
|
this.closeHovers();
|
||||||
|
} catch (e) {
|
||||||
|
this.$showError(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.mountedFetch();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.metadata-field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.metadata-title {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.metadata-current {
|
||||||
|
color: var(--color-muted, #666);
|
||||||
|
margin: 0.25rem 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
.metadata-edit {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.metadata-edit input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -25,6 +25,7 @@ import Share from "./Share.vue";
|
|||||||
import ShareDelete from "./ShareDelete.vue";
|
import ShareDelete from "./ShareDelete.vue";
|
||||||
import Upload from "./Upload.vue";
|
import Upload from "./Upload.vue";
|
||||||
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
|
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
|
||||||
|
import ModifyMetadata from "./ModifyMetadata.vue";
|
||||||
|
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ const components = new Map<string, any>([
|
|||||||
["replace-rename", ReplaceRename],
|
["replace-rename", ReplaceRename],
|
||||||
["share", Share],
|
["share", Share],
|
||||||
["upload", Upload],
|
["upload", Upload],
|
||||||
|
["modifyMetadata", ModifyMetadata],
|
||||||
["share-delete", ShareDelete],
|
["share-delete", ShareDelete],
|
||||||
["deleteUser", DeleteUser],
|
["deleteUser", DeleteUser],
|
||||||
["discardEditorChanges", DiscardEditorChanges],
|
["discardEditorChanges", DiscardEditorChanges],
|
||||||
|
|||||||
@@ -72,6 +72,12 @@
|
|||||||
:label="t('buttons.upload')"
|
:label="t('buttons.upload')"
|
||||||
@action="uploadFunc"
|
@action="uploadFunc"
|
||||||
/>
|
/>
|
||||||
|
<action
|
||||||
|
v-if="headerButtons.metadata"
|
||||||
|
icon="library_music"
|
||||||
|
:label="t('metadata')"
|
||||||
|
@action="openMetadata"
|
||||||
|
/>
|
||||||
<action icon="info" :label="t('buttons.info')" show="info" />
|
<action icon="info" :label="t('buttons.info')" show="info" />
|
||||||
<action
|
<action
|
||||||
icon="check_circle"
|
icon="check_circle"
|
||||||
@@ -302,6 +308,12 @@
|
|||||||
@action="download"
|
@action="download"
|
||||||
:counter="fileStore.selectedCount"
|
:counter="fileStore.selectedCount"
|
||||||
/>
|
/>
|
||||||
|
<action
|
||||||
|
v-if="headerButtons.metadata"
|
||||||
|
icon="library_music"
|
||||||
|
:label="t('metadata')"
|
||||||
|
@action="openMetadata"
|
||||||
|
/>
|
||||||
<action icon="info" :label="t('buttons.info')" show="info" />
|
<action icon="info" :label="t('buttons.info')" show="info" />
|
||||||
</context-menu>
|
</context-menu>
|
||||||
|
|
||||||
@@ -483,9 +495,29 @@ const headerButtons = computed(() => {
|
|||||||
share: fileStore.selectedCount === 1 && authStore.user?.perm.share,
|
share: fileStore.selectedCount === 1 && authStore.user?.perm.share,
|
||||||
move: fileStore.selectedCount > 0 && authStore.user?.perm.rename,
|
move: fileStore.selectedCount > 0 && authStore.user?.perm.rename,
|
||||||
copy: fileStore.selectedCount > 0 && authStore.user?.perm.create,
|
copy: fileStore.selectedCount > 0 && authStore.user?.perm.create,
|
||||||
|
metadata:
|
||||||
|
fileStore.selectedCount > 0 &&
|
||||||
|
fileStore.req &&
|
||||||
|
fileStore.selected.every((i) => {
|
||||||
|
const name = fileStore.req!.items[i].name || "";
|
||||||
|
const idx = name.lastIndexOf(".");
|
||||||
|
const ext = idx === -1 ? "" : name.substring(idx).toLowerCase();
|
||||||
|
return [".mp3", ".flac"].includes(ext);
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openMetadata = () => {
|
||||||
|
if (!fileStore.req) return;
|
||||||
|
|
||||||
|
const files: string[] = [];
|
||||||
|
for (const i of fileStore.selected) {
|
||||||
|
files.push(fileStore.req.items[i].url);
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutStore.showHover({ prompt: "modifyMetadata", props: { files } });
|
||||||
|
};
|
||||||
|
|
||||||
const isMobile = computed(() => {
|
const isMobile = computed(() => {
|
||||||
return width.value <= 736;
|
return width.value <= 736;
|
||||||
});
|
});
|
||||||
|
|||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "filebrowser",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
75
start-dev.sh
Executable file
75
start-dev.sh
Executable file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
export PATH="$HOME/.local/go/bin:$PATH"
|
||||||
|
|
||||||
|
echo "Project root: $PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Stop any existing dev servers first
|
||||||
|
if [ -x "$PROJECT_ROOT/stop-dev.sh" ]; then
|
||||||
|
echo "Stopping existing dev servers..."
|
||||||
|
"$PROJECT_ROOT/stop-dev.sh" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
echo "Starting backend..."
|
||||||
|
if [ -f filebrowser.log ]; then
|
||||||
|
mv filebrowser.log filebrowser.log.$(date +%s)
|
||||||
|
fi
|
||||||
|
nohup go run main.go > filebrowser.log 2>&1 &
|
||||||
|
backend_pid=$!
|
||||||
|
echo $backend_pid > filebrowser.pid
|
||||||
|
echo "Backend pid: $backend_pid"
|
||||||
|
|
||||||
|
echo "Waiting for backend to listen on 127.0.0.1:8080..."
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if ss -ltn | grep -q "127.0.0.1:8080"; then
|
||||||
|
echo "Backend listening."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Starting frontend (Vite)..."
|
||||||
|
if [ ! -d "$PROJECT_ROOT/frontend" ]; then
|
||||||
|
echo "ERROR: frontend directory not found: $PROJECT_ROOT/frontend"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd "$PROJECT_ROOT/frontend"
|
||||||
|
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
|
pnpm install --no-audit --no-fund
|
||||||
|
else
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$PROJECT_ROOT/frontend-dev.log" ]; then
|
||||||
|
mv "$PROJECT_ROOT/frontend-dev.log" "$PROJECT_ROOT/frontend-dev.log.$(date +%s)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
|
nohup pnpm run dev > "$PROJECT_ROOT/frontend-dev.log" 2>&1 &
|
||||||
|
frontend_pid=$!
|
||||||
|
else
|
||||||
|
nohup npm run dev > "$PROJECT_ROOT/frontend-dev.log" 2>&1 &
|
||||||
|
frontend_pid=$!
|
||||||
|
fi
|
||||||
|
echo $frontend_pid > "$PROJECT_ROOT/frontend.pid"
|
||||||
|
echo "Frontend pid: $frontend_pid"
|
||||||
|
|
||||||
|
echo "Waiting for frontend on 5173..."
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if ss -ltn | grep -q ":5173"; then
|
||||||
|
echo "Frontend listening."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Started. Backend logs: $PROJECT_ROOT/filebrowser.log"
|
||||||
|
echo "Frontend logs: $PROJECT_ROOT/frontend-dev.log"
|
||||||
|
echo "Open http://localhost:5173/ to use the dev UI"
|
||||||
51
stop-dev.sh
Executable file
51
stop-dev.sh
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "Stopping dev servers (project root: $PROJECT_ROOT)"
|
||||||
|
|
||||||
|
# Kill backend from pid file
|
||||||
|
if [ -f "$PROJECT_ROOT/filebrowser.pid" ]; then
|
||||||
|
pid=$(cat "$PROJECT_ROOT/filebrowser.pid")
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo "Killing backend pid $pid"
|
||||||
|
kill "$pid" || true
|
||||||
|
sleep 1
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill -9 "$pid" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rm -f "$PROJECT_ROOT/filebrowser.pid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill frontend from pid file
|
||||||
|
if [ -f "$PROJECT_ROOT/frontend.pid" ]; then
|
||||||
|
pid=$(cat "$PROJECT_ROOT/frontend.pid")
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo "Killing frontend pid $pid"
|
||||||
|
kill "$pid" || true
|
||||||
|
sleep 1
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill -9 "$pid" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rm -f "$PROJECT_ROOT/frontend.pid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also attempt to free ports 8080 and 5173 by killing processes listening on them (best-effort)
|
||||||
|
pids=$(ss -ltnp | grep -E '127\.0\.0\.1:8080|:5173' | grep -oP 'pid=\K[0-9]+' | sort -u || true)
|
||||||
|
for p in $pids; do
|
||||||
|
if [ -n "$p" ]; then
|
||||||
|
if kill -0 "$p" 2>/dev/null; then
|
||||||
|
echo "Killing leftover pid $p"
|
||||||
|
kill "$p" || true
|
||||||
|
sleep 1
|
||||||
|
if kill -0 "$p" 2>/dev/null; then
|
||||||
|
kill -9 "$p" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Stopped."
|
||||||
Reference in New Issue
Block a user