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:
kissudad@outlook.com 2024-04-06 16:35:47 +08:00 committed by llxxxdd
parent ae0af1f996
commit 8b6b6b622e
8 changed files with 168 additions and 104 deletions

View File

@ -1,11 +1,12 @@
import { fetchURL, removePrefix, createURL } from "./utils"; import { fetchURL, removePrefix, createURL } from "./utils";
import { baseURL } from "@/utils/constants"; import { baseURL } from "@/utils/constants";
import { useAuthStore } from "@/stores/auth";
export async function fetch(url: string, password: string = "") { export async function fetch(url: string, password: string = "") {
url = removePrefix(url); url = removePrefix(url);
const authStore = useAuthStore();
const res = await fetchURL( 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) }, 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); 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);
}

View File

@ -14,10 +14,7 @@
:aria-selected="isSelected" :aria-selected="isSelected"
> >
<div> <div>
<img <img v-if="type === 'image' && isThumbsEnabled" v-lazy="thumbnailUrl" />
v-if="!readOnly && type === 'image' && isThumbsEnabled"
v-lazy="thumbnailUrl"
/>
<i v-else class="material-icons"></i> <i v-else class="material-icons"></i>
</div> </div>
@ -42,15 +39,16 @@ import { useLayoutStore } from "@/stores/layout";
import { enableThumbs } from "@/utils/constants"; import { enableThumbs } from "@/utils/constants";
import { filesize } from "@/utils"; import { filesize } from "@/utils";
import dayjs from "dayjs"; 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 * as upload from "@/utils/upload";
import { computed, inject, ref } from "vue"; import { computed, inject, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter, useRoute } from "vue-router";
const touches = ref<number>(0); const touches = ref<number>(0);
const $showError = inject<IToastError>("$showError")!; const $showError = inject<IToastError>("$showError")!;
const router = useRouter(); const router = useRouter();
const route = useRoute();
const props = defineProps<{ const props = defineProps<{
name: string; name: string;
@ -95,8 +93,13 @@ const thumbnailUrl = computed(() => {
path: props.path, path: props.path,
modified: props.modified, modified: props.modified,
}; };
if (route.name === "Share") {
return api.getPreviewURL(file as Resource, "thumb"); 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(() => { const isThumbsEnabled = computed(() => {

View File

@ -1,41 +1,70 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { detectLocale, setLocale } from "@/i18n"; import { detectLocale, setLocale } from "@/i18n";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
import { useStorage } from "@vueuse/core";
import { computed, ref } from "vue";
export const useAuthStore = defineStore("auth", { export const useAuthStore = defineStore("auth", () => {
// convert to a function const registeredUser = ref<IUser | null>(null);
state: (): { const jwt = ref("");
user: IUser | null;
jwt: string; const guestJwt = ref("");
} => ({ const guestUser = useStorage("guest", {
user: null, locale: "zh-cn",
jwt: "", viewMode: "list",
}), singleClick: false,
getters: { perm: { create: false },
// user and jwt getter removed, no longer needed });
isLoggedIn: (state) => state.user !== null,
}, const shareConfig = ref({ sortBy: "name", asc: false });
actions: { const isLoggedIn = computed(() => registeredUser.value !== null);
// no context as first argument, use `this` instead const user = computed({
setUser(user: IUser) { get: () =>
if (user === null) { isLoggedIn.value ? registeredUser.value : (guestUser.value as IUser),
this.user = null; set: (val) => {
return; if (isLoggedIn.value) {
registeredUser.value = val;
} else {
guestUser.value = val;
} }
},
});
setLocale(user.locale || detectLocale()); function setUser(_user: IUser) {
this.user = user; if (_user === null) {
}, registeredUser.value = null;
updateUser(user: Partial<IUser>) { return;
if (user.locale) { }
setLocale(user.locale);
}
this.user = { ...this.user, ...cloneDeep(user) } as IUser; setLocale(_user.locale || detectLocale());
}, registeredUser.value = _user;
// easily reset state using `$reset` }
clearUser() {
this.$reset(); 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,
};
}); });

View File

@ -95,7 +95,8 @@
v-if="!req.isDir" v-if="!req.isDir"
class="share__box__element share__box__center share__box__icon" 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>
<div class="share__box__element" style="height: 3em"> <div class="share__box__element" style="height: 3em">
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }} <strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
@ -236,51 +237,9 @@
id="shareList" id="shareList"
v-if="req.isDir && req.items.length > 0" v-if="req.isDir && req.items.length > 0"
class="share__box share__box__items" class="share__box share__box__items"
style="background-color: transparent"
> >
<div class="share__box__header" v-if="req.isDir"> <FileListing />
{{ 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>
</div> </div>
<div <div
v-else-if="req.isDir && req.items.length === 0" v-else-if="req.isDir && req.items.length === 0"
@ -300,21 +259,24 @@
import { pub as api } from "@/api"; import { pub as api } from "@/api";
import { filesize } from "@/utils"; import { filesize } from "@/utils";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Base64 } from "js-base64";
import HeaderBar from "@/components/header/HeaderBar.vue"; import HeaderBar from "@/components/header/HeaderBar.vue";
import Action from "@/components/header/Action.vue"; import Action from "@/components/header/Action.vue";
import Breadcrumbs from "@/components/Breadcrumbs.vue"; import Breadcrumbs from "@/components/Breadcrumbs.vue";
import Errors from "@/views/Errors.vue"; import Errors from "@/views/Errors.vue";
import QrcodeVue from "qrcode.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 { useFileStore } from "@/stores/file";
import { useAuthStore } from "@/stores/auth";
import { useLayoutStore } from "@/stores/layout"; import { useLayoutStore } from "@/stores/layout";
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from "vue"; import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { StatusError } from "@/api/utils"; import { StatusError } from "@/api/utils";
import { copy } from "@/utils/clipboard"; import { copy } from "@/utils/clipboard";
import { storeToRefs } from "pinia";
const error = ref<StatusError | null>(null); const error = ref<StatusError | null>(null);
const showLimit = ref<number>(100); const showLimit = ref<number>(100);
@ -331,7 +293,9 @@ const { t } = useI18n({});
const route = useRoute(); const route = useRoute();
const fileStore = useFileStore(); const fileStore = useFileStore();
const { reload } = storeToRefs(fileStore);
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const authStore = useAuthStore();
watch(route, () => { watch(route, () => {
showLimit.value = 100; showLimit.value = 100;
@ -352,16 +316,27 @@ const icon = computed(() => {
}); });
const link = computed(() => (req.value ? api.getDownloadURL(req.value) : "")); 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(() => { const raw = computed(() => {
return req.value // req.value
? req.value.items[fileStore.selected[0]].url.replace( if (!req.value) {
/share/, return "";
"api/public/dl" }
) +
"?token=" + // req.value
token.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(() => const inlineLink = computed(() =>
req.value ? api.getDownloadURL(req.value, true) : "" req.value ? api.getDownloadURL(req.value, true) : ""
); );
@ -382,7 +357,6 @@ const modTime = computed(() =>
); );
// Functions // Functions
const base64 = (name: any) => Base64.encodeURI(name);
const play = () => { const play = () => {
if (tag.value) { if (tag.value) {
audio.value?.pause(); audio.value?.pause();
@ -392,6 +366,10 @@ const play = () => {
tag.value = true; tag.value = true;
} }
}; };
watch(reload, (newValue) => {
newValue && fetchData();
});
const fetchData = async () => { const fetchData = async () => {
fileStore.reload = false; fileStore.reload = false;
fileStore.selected = []; fileStore.selected = [];
@ -408,12 +386,12 @@ const fetchData = async () => {
let url = route.path; let url = route.path;
if (url === "") url = "/"; if (url === "") url = "/";
if (url[0] !== "/") url = "/" + url; if (url[0] !== "/") url = "/" + url;
try { try {
const file = await api.fetch(url, password.value); const file = await api.fetch(url, password.value);
file.hash = hash.value; file.hash = hash.value;
token.value = file.token || ""; token.value = file.token || "";
authStore.guestJwt = token.value;
fileStore.updateRequest(file); fileStore.updateRequest(file);
document.title = `${file.name} - ${document.title}`; document.title = `${file.name} - ${document.title}`;
@ -504,6 +482,7 @@ onMounted(async () => {
hash.value = route.params.path[0]; hash.value = route.params.path[0];
window.addEventListener("keydown", keyEvent); window.addEventListener("keydown", keyEvent);
await fetchData(); await fetchData();
fileStore.selected[0] = 0;
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {

View File

@ -74,6 +74,7 @@
/> />
<action icon="info" :label="t('buttons.info')" show="info" /> <action icon="info" :label="t('buttons.info')" show="info" />
<action <action
v-if="authStore.isLoggedIn"
icon="check_circle" icon="check_circle"
:label="t('buttons.selectMultiple')" :label="t('buttons.selectMultiple')"
@action="toggleMultipleSelection" @action="toggleMultipleSelection"
@ -829,6 +830,8 @@ const sort = async (by: string) => {
"sorting", "sorting",
]); ]);
} }
authStore.shareConfig.sortBy = by;
authStore.shareConfig.asc = asc;
} catch (e: any) { } catch (e: any) {
$showError(e); $showError(e);
} }
@ -904,8 +907,10 @@ const switchView = async () => {
viewMode: modes[authStore.user?.viewMode ?? "list"] || "list", viewMode: modes[authStore.user?.viewMode ?? "list"] || "list",
}; };
// @ts-ignore if (authStore.isLoggedIn) {
users.update(data, ["viewMode"]).catch($showError); // @ts-ignore
users.update(data, ["viewMode"]).catch($showError);
}
// @ts-ignore // @ts-ignore
authStore.updateUser(data); authStore.updateUser(data);

View File

@ -90,6 +90,8 @@ func NewHandler(
public := api.PathPrefix("/public").Subrouter() public := api.PathPrefix("/public").Subrouter()
public.PathPrefix("/dl").Handler(monkey(publicDlHandler, "/api/public/dl/")).Methods("GET") public.PathPrefix("/dl").Handler(monkey(publicDlHandler, "/api/public/dl/")).Methods("GET")
public.PathPrefix("/share").Handler(monkey(publicShareHandler, "/api/public/share/")).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 return stripPrefix(server.BaseURL, r), nil
} }

View File

@ -111,7 +111,8 @@ func handleImagePreview(
} }
func createPreview(imgSvc ImgService, fileCache FileCache, 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) fd, err := file.Fs.Open(file.Path)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -2,12 +2,14 @@ package http
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/gorilla/mux"
"github.com/spf13/afero" "github.com/spf13/afero"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -98,7 +100,16 @@ var publicShareHandler = withHashFile(func(w http.ResponseWriter, r *http.Reques
file := d.raw.(*files.FileInfo) file := d.raw.(*files.FileInfo)
if file.IsDir { 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() file.Listing.ApplySort()
return renderJSON(w, r, file) 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) 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) { func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
if l.PasswordHash == "" { if l.PasswordHash == "" {
return 0, nil return 0, nil