feat: Optimize shared file viewing experience
- Add and update APIs for thumbnail display and file sorting. - Refresh frontend views and components to enhance shared file presentation.
This commit is contained in:
parent
ae0af1f996
commit
8b6b6b622e
@ -1,11 +1,12 @@
|
||||
import { fetchURL, removePrefix, createURL } from "./utils";
|
||||
import { baseURL } from "@/utils/constants";
|
||||
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
export async function fetch(url: string, password: string = "") {
|
||||
url = removePrefix(url);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const res = await fetchURL(
|
||||
`/api/public/share${url}`,
|
||||
`/api/public/share${url}?s=${authStore.shareConfig.sortBy}&a=${Number(authStore.shareConfig.asc)}`,
|
||||
{
|
||||
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password) },
|
||||
},
|
||||
@ -73,3 +74,14 @@ export function getDownloadURL(res: Resource, inline = false) {
|
||||
|
||||
return createURL("api/public/dl/" + res.hash + res.path, params, false);
|
||||
}
|
||||
|
||||
export function getPreviewURL(file: ResourceItem, size: string) {
|
||||
const authStore = useAuthStore();
|
||||
const params = {
|
||||
inline: "true",
|
||||
token: authStore.guestJwt,
|
||||
key: Date.parse(file.modified),
|
||||
};
|
||||
|
||||
return createURL("api/public/preview/" + size + file.path, params, false);
|
||||
}
|
||||
|
||||
@ -14,10 +14,7 @@
|
||||
:aria-selected="isSelected"
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
v-if="!readOnly && type === 'image' && isThumbsEnabled"
|
||||
v-lazy="thumbnailUrl"
|
||||
/>
|
||||
<img v-if="type === 'image' && isThumbsEnabled" v-lazy="thumbnailUrl" />
|
||||
<i v-else class="material-icons"></i>
|
||||
</div>
|
||||
|
||||
@ -42,15 +39,16 @@ import { useLayoutStore } from "@/stores/layout";
|
||||
import { enableThumbs } from "@/utils/constants";
|
||||
import { filesize } from "@/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { files as api } from "@/api";
|
||||
import { files as api, pub as pub_api } from "@/api";
|
||||
import * as upload from "@/utils/upload";
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
|
||||
const touches = ref<number>(0);
|
||||
|
||||
const $showError = inject<IToastError>("$showError")!;
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
@ -95,8 +93,13 @@ const thumbnailUrl = computed(() => {
|
||||
path: props.path,
|
||||
modified: props.modified,
|
||||
};
|
||||
|
||||
return api.getPreviewURL(file as Resource, "thumb");
|
||||
if (route.name === "Share") {
|
||||
const hash = props.url.split("/")[2];
|
||||
file.path = `/${hash}${props.path}`;
|
||||
return pub_api.getPreviewURL(file as Resource, "thumb");
|
||||
} else {
|
||||
return api.getPreviewURL(file as Resource, "thumb");
|
||||
}
|
||||
});
|
||||
|
||||
const isThumbsEnabled = computed(() => {
|
||||
|
||||
@ -1,41 +1,70 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { detectLocale, setLocale } from "@/i18n";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
// convert to a function
|
||||
state: (): {
|
||||
user: IUser | null;
|
||||
jwt: string;
|
||||
} => ({
|
||||
user: null,
|
||||
jwt: "",
|
||||
}),
|
||||
getters: {
|
||||
// user and jwt getter removed, no longer needed
|
||||
isLoggedIn: (state) => state.user !== null,
|
||||
},
|
||||
actions: {
|
||||
// no context as first argument, use `this` instead
|
||||
setUser(user: IUser) {
|
||||
if (user === null) {
|
||||
this.user = null;
|
||||
return;
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const registeredUser = ref<IUser | null>(null);
|
||||
const jwt = ref("");
|
||||
|
||||
const guestJwt = ref("");
|
||||
const guestUser = useStorage("guest", {
|
||||
locale: "zh-cn",
|
||||
viewMode: "list",
|
||||
singleClick: false,
|
||||
perm: { create: false },
|
||||
});
|
||||
|
||||
const shareConfig = ref({ sortBy: "name", asc: false });
|
||||
const isLoggedIn = computed(() => registeredUser.value !== null);
|
||||
const user = computed({
|
||||
get: () =>
|
||||
isLoggedIn.value ? registeredUser.value : (guestUser.value as IUser),
|
||||
set: (val) => {
|
||||
if (isLoggedIn.value) {
|
||||
registeredUser.value = val;
|
||||
} else {
|
||||
guestUser.value = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setLocale(user.locale || detectLocale());
|
||||
this.user = user;
|
||||
},
|
||||
updateUser(user: Partial<IUser>) {
|
||||
if (user.locale) {
|
||||
setLocale(user.locale);
|
||||
}
|
||||
function setUser(_user: IUser) {
|
||||
if (_user === null) {
|
||||
registeredUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.user = { ...this.user, ...cloneDeep(user) } as IUser;
|
||||
},
|
||||
// easily reset state using `$reset`
|
||||
clearUser() {
|
||||
this.$reset();
|
||||
},
|
||||
},
|
||||
setLocale(_user.locale || detectLocale());
|
||||
registeredUser.value = _user;
|
||||
}
|
||||
|
||||
function updateUser(_user: Partial<IUser>) {
|
||||
if (_user.locale) {
|
||||
setLocale(_user.locale);
|
||||
}
|
||||
|
||||
user.value = {
|
||||
...user.value,
|
||||
...cloneDeep(_user),
|
||||
} as IUser;
|
||||
}
|
||||
// easily reset state using `$reset`
|
||||
function clearUser() {
|
||||
registeredUser.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
jwt,
|
||||
guestJwt,
|
||||
shareConfig,
|
||||
|
||||
isLoggedIn,
|
||||
user,
|
||||
|
||||
setUser,
|
||||
updateUser,
|
||||
clearUser,
|
||||
};
|
||||
});
|
||||
|
||||
@ -95,7 +95,8 @@
|
||||
v-if="!req.isDir"
|
||||
class="share__box__element share__box__center share__box__icon"
|
||||
>
|
||||
<i class="material-icons">{{ icon }}</i>
|
||||
<img v-if="req.type === 'image'" :src="raw" />
|
||||
<i v-else class="material-icons">{{ icon }}</i>
|
||||
</div>
|
||||
<div class="share__box__element" style="height: 3em">
|
||||
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
|
||||
@ -236,51 +237,9 @@
|
||||
id="shareList"
|
||||
v-if="req.isDir && req.items.length > 0"
|
||||
class="share__box share__box__items"
|
||||
style="background-color: transparent"
|
||||
>
|
||||
<div class="share__box__header" v-if="req.isDir">
|
||||
{{ t("files.files") }}
|
||||
</div>
|
||||
<div id="listing" class="list file-icons">
|
||||
<item
|
||||
v-for="item in req.items.slice(0, showLimit)"
|
||||
:key="base64(item.name)"
|
||||
v-bind:index="item.index"
|
||||
v-bind:name="item.name"
|
||||
v-bind:isDir="item.isDir"
|
||||
v-bind:url="item.url"
|
||||
v-bind:modified="item.modified"
|
||||
v-bind:type="item.type"
|
||||
v-bind:size="item.size"
|
||||
readOnly
|
||||
>
|
||||
</item>
|
||||
<div
|
||||
v-if="req.items.length > showLimit"
|
||||
class="item"
|
||||
@click="showLimit += 100"
|
||||
>
|
||||
<div>
|
||||
<p class="name">+ {{ req.items.length - showLimit }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="{ active: fileStore.multiple }"
|
||||
id="multiple-selection"
|
||||
>
|
||||
<p>{{ t("files.multipleSelectionEnabled") }}</p>
|
||||
<div
|
||||
@click="() => (fileStore.multiple = false)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:data-title="t('buttons.clear')"
|
||||
:aria-label="t('buttons.clear')"
|
||||
class="action"
|
||||
>
|
||||
<i class="material-icons">clear</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FileListing />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="req.isDir && req.items.length === 0"
|
||||
@ -300,21 +259,24 @@
|
||||
import { pub as api } from "@/api";
|
||||
import { filesize } from "@/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { Base64 } from "js-base64";
|
||||
|
||||
import HeaderBar from "@/components/header/HeaderBar.vue";
|
||||
import Action from "@/components/header/Action.vue";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs.vue";
|
||||
import Errors from "@/views/Errors.vue";
|
||||
import QrcodeVue from "qrcode.vue";
|
||||
import Item from "@/components/files/ListingItem.vue";
|
||||
import FileListing from "@/views/files/FileListing.vue";
|
||||
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { StatusError } from "@/api/utils";
|
||||
import { copy } from "@/utils/clipboard";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const error = ref<StatusError | null>(null);
|
||||
const showLimit = ref<number>(100);
|
||||
@ -331,7 +293,9 @@ const { t } = useI18n({});
|
||||
|
||||
const route = useRoute();
|
||||
const fileStore = useFileStore();
|
||||
const { reload } = storeToRefs(fileStore);
|
||||
const layoutStore = useLayoutStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
watch(route, () => {
|
||||
showLimit.value = 100;
|
||||
@ -352,16 +316,27 @@ const icon = computed(() => {
|
||||
});
|
||||
|
||||
const link = computed(() => (req.value ? api.getDownloadURL(req.value) : ""));
|
||||
|
||||
// 用于转换 URL 的辅助函数
|
||||
function transformUrl(url: string) {
|
||||
return url.replace(/share/, "api/public/dl") + "?token=" + token.value;
|
||||
}
|
||||
const raw = computed(() => {
|
||||
return req.value
|
||||
? req.value.items[fileStore.selected[0]].url.replace(
|
||||
/share/,
|
||||
"api/public/dl"
|
||||
) +
|
||||
"?token=" +
|
||||
token.value
|
||||
: "";
|
||||
// 如果 req.value 不存在,则直接返回空字符串
|
||||
if (!req.value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 如果 req.value 是目录
|
||||
if (req.value.isDir) {
|
||||
const selectedItemUrl = req.value.items[fileStore.selected[0]].url;
|
||||
return transformUrl(selectedItemUrl);
|
||||
}
|
||||
|
||||
// 如果 req.value 不是目录
|
||||
return transformUrl(req.value.url);
|
||||
});
|
||||
|
||||
const inlineLink = computed(() =>
|
||||
req.value ? api.getDownloadURL(req.value, true) : ""
|
||||
);
|
||||
@ -382,7 +357,6 @@ const modTime = computed(() =>
|
||||
);
|
||||
|
||||
// Functions
|
||||
const base64 = (name: any) => Base64.encodeURI(name);
|
||||
const play = () => {
|
||||
if (tag.value) {
|
||||
audio.value?.pause();
|
||||
@ -392,6 +366,10 @@ const play = () => {
|
||||
tag.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
watch(reload, (newValue) => {
|
||||
newValue && fetchData();
|
||||
});
|
||||
const fetchData = async () => {
|
||||
fileStore.reload = false;
|
||||
fileStore.selected = [];
|
||||
@ -408,12 +386,12 @@ const fetchData = async () => {
|
||||
let url = route.path;
|
||||
if (url === "") url = "/";
|
||||
if (url[0] !== "/") url = "/" + url;
|
||||
|
||||
try {
|
||||
const file = await api.fetch(url, password.value);
|
||||
file.hash = hash.value;
|
||||
|
||||
token.value = file.token || "";
|
||||
authStore.guestJwt = token.value;
|
||||
|
||||
fileStore.updateRequest(file);
|
||||
document.title = `${file.name} - ${document.title}`;
|
||||
@ -504,6 +482,7 @@ onMounted(async () => {
|
||||
hash.value = route.params.path[0];
|
||||
window.addEventListener("keydown", keyEvent);
|
||||
await fetchData();
|
||||
fileStore.selected[0] = 0;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@ -74,6 +74,7 @@
|
||||
/>
|
||||
<action icon="info" :label="t('buttons.info')" show="info" />
|
||||
<action
|
||||
v-if="authStore.isLoggedIn"
|
||||
icon="check_circle"
|
||||
:label="t('buttons.selectMultiple')"
|
||||
@action="toggleMultipleSelection"
|
||||
@ -829,6 +830,8 @@ const sort = async (by: string) => {
|
||||
"sorting",
|
||||
]);
|
||||
}
|
||||
authStore.shareConfig.sortBy = by;
|
||||
authStore.shareConfig.asc = asc;
|
||||
} catch (e: any) {
|
||||
$showError(e);
|
||||
}
|
||||
@ -904,8 +907,10 @@ const switchView = async () => {
|
||||
viewMode: modes[authStore.user?.viewMode ?? "list"] || "list",
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
users.update(data, ["viewMode"]).catch($showError);
|
||||
if (authStore.isLoggedIn) {
|
||||
// @ts-ignore
|
||||
users.update(data, ["viewMode"]).catch($showError);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
authStore.updateUser(data);
|
||||
|
||||
@ -90,6 +90,8 @@ func NewHandler(
|
||||
public := api.PathPrefix("/public").Subrouter()
|
||||
public.PathPrefix("/dl").Handler(monkey(publicDlHandler, "/api/public/dl/")).Methods("GET")
|
||||
public.PathPrefix("/share").Handler(monkey(publicShareHandler, "/api/public/share/")).Methods("GET")
|
||||
public.PathPrefix("/preview/{size}/{path:.*}").
|
||||
Handler(monkey(publicPreviewHandler(imgSvc, fileCache, server.EnableThumbnails, server.ResizePreview), "/api/public/preview")).Methods("GET")
|
||||
|
||||
return stripPrefix(server.BaseURL, r), nil
|
||||
}
|
||||
|
||||
@ -111,7 +111,8 @@ func handleImagePreview(
|
||||
}
|
||||
|
||||
func createPreview(imgSvc ImgService, fileCache FileCache,
|
||||
file *files.FileInfo, previewSize PreviewSize) ([]byte, error) {
|
||||
file *files.FileInfo, previewSize PreviewSize,
|
||||
) ([]byte, error) {
|
||||
fd, err := file.Fs.Open(file.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -2,12 +2,14 @@ package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
@ -98,7 +100,16 @@ var publicShareHandler = withHashFile(func(w http.ResponseWriter, r *http.Reques
|
||||
file := d.raw.(*files.FileInfo)
|
||||
|
||||
if file.IsDir {
|
||||
file.Listing.Sorting = files.Sorting{By: "name", Asc: false}
|
||||
sortBy := r.URL.Query().Get("s")
|
||||
ascStr := r.URL.Query().Get("a")
|
||||
asc := false
|
||||
if sortBy == "" {
|
||||
sortBy = "name"
|
||||
}
|
||||
if ascStr == "1" {
|
||||
asc = true
|
||||
}
|
||||
file.Listing.Sorting = files.Sorting{By: sortBy, Asc: asc}
|
||||
file.Listing.ApplySort()
|
||||
return renderJSON(w, r, file)
|
||||
}
|
||||
@ -115,6 +126,28 @@ var publicDlHandler = withHashFile(func(w http.ResponseWriter, r *http.Request,
|
||||
return rawDirHandler(w, r, d, file)
|
||||
})
|
||||
|
||||
func publicPreviewHandler(imgSvc ImgService, fileCache FileCache, enableThumbnails, resizePreview bool) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
vars := mux.Vars(r)
|
||||
previewSize, err := ParsePreviewSize(vars["size"])
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
r.URL.Path = vars["path"]
|
||||
return withHashFile(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
||||
file := d.raw.(*files.FileInfo)
|
||||
setContentDisposition(w, r, file)
|
||||
|
||||
switch file.Type {
|
||||
case "image":
|
||||
return handleImagePreview(w, r, imgSvc, fileCache, file, previewSize, enableThumbnails, resizePreview)
|
||||
default:
|
||||
return http.StatusNotImplemented, fmt.Errorf("can't create preview for %s type", file.Type)
|
||||
}
|
||||
})(w, r, d)
|
||||
}
|
||||
}
|
||||
|
||||
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||
if l.PasswordHash == "" {
|
||||
return 0, nil
|
||||
|
||||
Loading…
Reference in New Issue
Block a user