release: v1.2.1 — login captcha, docs site, and bug fixes
Add slider captcha for web login, VitePress documentation on GitHub Pages, and fixes for gallery delete refresh, pagination jump, and copy-format persistence.
This commit is contained in:
1 parent
469d3be735
commit
ff20f97522
66 files changed
+2497
-616
No files matched your search
@@ -11,14 +11,29 @@ const username = ref('')
|
||||
const password = ref('')
|
||||
const secret = ref('')
|
||||
const loading = ref(false)
|
||||
const captchaRef = ref<{ reset: () => Promise<void> } | null>(null)
|
||||
const captchaPayload = ref<{ captchaId: string, captchaPosition: number } | null>(null)
|
||||
|
||||
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
|
||||
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value)
|
||||
Boolean(captchaPayload.value)
|
||||
&& (legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value))
|
||||
)
|
||||
|
||||
function onCaptchaVerified(payload: { id: string, positionPercent: number }) {
|
||||
captchaPayload.value = {
|
||||
captchaId: payload.id,
|
||||
captchaPosition: payload.positionPercent
|
||||
}
|
||||
}
|
||||
|
||||
async function resetCaptcha() {
|
||||
captchaPayload.value = null
|
||||
await captchaRef.value?.reset()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
@@ -27,13 +42,14 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
if (!canSubmit.value || loading.value || !captchaPayload.value) return
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const captcha = captchaPayload.value
|
||||
const result = legacyMode.value
|
||||
? await login({ secret: secret.value })
|
||||
: await login({ username: username.value.trim(), password: password.value })
|
||||
? await login({ secret: secret.value }, captcha)
|
||||
: await login({ username: username.value.trim(), password: password.value }, captcha)
|
||||
|
||||
if (result.ok) {
|
||||
if (result.needsMigration) {
|
||||
@@ -43,8 +59,10 @@ async function submit() {
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
secret.value = ''
|
||||
await resetCaptcha()
|
||||
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
|
||||
} else {
|
||||
await resetCaptcha()
|
||||
toast.add({
|
||||
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
|
||||
color: 'error'
|
||||
@@ -152,6 +170,11 @@ async function submit() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SliderCaptcha
|
||||
ref="captchaRef"
|
||||
@verified="onCaptchaVerified"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
:label="t('auth.login')"
|
||||
|
||||
@@ -14,13 +14,30 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const unitLabel = computed(() => props.unit ?? t('common.itemsUnit'))
|
||||
const jumpInput = ref('')
|
||||
|
||||
function goTo(target: number) {
|
||||
if (target < 1 || target > props.totalPages || target === props.page || props.loading) {
|
||||
if (target < 1 || target > props.totalPages || props.loading) {
|
||||
return
|
||||
}
|
||||
if (target === props.page) {
|
||||
jumpInput.value = ''
|
||||
return
|
||||
}
|
||||
emit('update:page', target)
|
||||
}
|
||||
|
||||
function submitJump() {
|
||||
const raw = String(jumpInput.value ?? '').trim()
|
||||
if (!raw) return
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed)) return
|
||||
goTo(parsed)
|
||||
}
|
||||
|
||||
watch(() => props.page, () => {
|
||||
jumpInput.value = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -43,7 +60,7 @@ function goTo(target: number) {
|
||||
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="flex items-center gap-1"
|
||||
class="flex flex-wrap items-center justify-center gap-1"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-chevrons-left"
|
||||
@@ -77,6 +94,31 @@ function goTo(target: number) {
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goTo(totalPages)"
|
||||
/>
|
||||
<form
|
||||
class="ml-1 flex items-center gap-1"
|
||||
@submit.prevent="submitJump"
|
||||
>
|
||||
<UInput
|
||||
v-model="jumpInput"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="totalPages"
|
||||
size="sm"
|
||||
class="w-16"
|
||||
:placeholder="t('pagination.pagePlaceholder')"
|
||||
:disabled="loading"
|
||||
:aria-label="t('pagination.pagePlaceholder')"
|
||||
@keyup.enter="submitJump"
|
||||
/>
|
||||
<UButton
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:label="t('pagination.goTo')"
|
||||
:disabled="loading || !String(jumpInput ?? '').trim()"
|
||||
@click="submitJump"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
verified: [payload: { id: string, positionPercent: number }]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const THUMB_SIZE = 40
|
||||
const TRACK_INSET = 4
|
||||
|
||||
const trackRef = ref<HTMLElement | null>(null)
|
||||
const challengeId = ref('')
|
||||
const trackSvg = ref('')
|
||||
const sliderPercent = ref(0)
|
||||
const verified = ref(false)
|
||||
const loading = ref(false)
|
||||
const dragging = ref(false)
|
||||
const shake = ref(false)
|
||||
const confirming = ref(false)
|
||||
|
||||
const trackWidth = ref(0)
|
||||
|
||||
function updateTrackWidth() {
|
||||
trackWidth.value = trackRef.value?.clientWidth ?? 0
|
||||
}
|
||||
|
||||
function percentToPx(percent: number) {
|
||||
const max = Math.max(trackWidth.value - THUMB_SIZE - TRACK_INSET * 2, 0)
|
||||
return TRACK_INSET + (percent / 100) * max
|
||||
}
|
||||
|
||||
const thumbLeft = computed(() => percentToPx(sliderPercent.value))
|
||||
const isActive = computed(() => dragging.value || sliderPercent.value > 0.5)
|
||||
const fillWidth = computed(() => `${Math.max(thumbLeft.value + THUMB_SIZE - TRACK_INSET, 0)}px`)
|
||||
const trackSvgUrl = computed(() =>
|
||||
trackSvg.value
|
||||
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(trackSvg.value)}`
|
||||
: ''
|
||||
)
|
||||
const hintOpacity = computed(() => {
|
||||
if (verified.value) return 0
|
||||
if (!isActive.value) return 1
|
||||
return Math.max(0, 1 - sliderPercent.value / 40)
|
||||
})
|
||||
const successLabelLeft = computed(() => `${thumbLeft.value + THUMB_SIZE + 8}px`)
|
||||
|
||||
async function loadChallenge() {
|
||||
loading.value = true
|
||||
verified.value = false
|
||||
dragging.value = false
|
||||
shake.value = false
|
||||
sliderPercent.value = 0
|
||||
trackSvg.value = ''
|
||||
try {
|
||||
const data = await $fetch<{ id: string, svg: string }>('/api/auth/captcha')
|
||||
challengeId.value = data.id
|
||||
trackSvg.value = data.svg
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
updateTrackWidth()
|
||||
}
|
||||
}
|
||||
|
||||
function setSliderFromClientX(clientX: number) {
|
||||
if (!trackRef.value || verified.value || loading.value || confirming.value) return
|
||||
const rect = trackRef.value.getBoundingClientRect()
|
||||
const max = Math.max(rect.width - THUMB_SIZE - TRACK_INSET * 2, 1)
|
||||
const x = Math.min(
|
||||
Math.max(clientX - rect.left - THUMB_SIZE / 2 - TRACK_INSET, 0),
|
||||
max
|
||||
)
|
||||
sliderPercent.value = (x / max) * 100
|
||||
}
|
||||
|
||||
async function tryConfirm() {
|
||||
if (verified.value || !challengeId.value || confirming.value) return
|
||||
|
||||
confirming.value = true
|
||||
try {
|
||||
const position = Math.round(sliderPercent.value)
|
||||
await $fetch('/api/auth/captcha/confirm', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
id: challengeId.value,
|
||||
captchaPosition: position
|
||||
}
|
||||
})
|
||||
verified.value = true
|
||||
emit('verified', {
|
||||
id: challengeId.value,
|
||||
positionPercent: position
|
||||
})
|
||||
} catch {
|
||||
shake.value = true
|
||||
sliderPercent.value = 0
|
||||
window.setTimeout(() => {
|
||||
shake.value = false
|
||||
}, 450)
|
||||
} finally {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onThumbPointerDown(event: PointerEvent) {
|
||||
if (verified.value || loading.value || confirming.value) return
|
||||
dragging.value = true
|
||||
updateTrackWidth()
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function onThumbPointerMove(event: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
setSliderFromClientX(event.clientX)
|
||||
}
|
||||
|
||||
function onThumbPointerUp() {
|
||||
if (!dragging.value) return
|
||||
dragging.value = false
|
||||
if (!verified.value) {
|
||||
void tryConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadChallenge()
|
||||
})
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
watch(trackRef, (el, _, onCleanup) => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = undefined
|
||||
if (!el) return
|
||||
updateTrackWidth()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
resizeObserver = new ResizeObserver(updateTrackWidth)
|
||||
resizeObserver.observe(el)
|
||||
onCleanup(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = undefined
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({ reset: loadChallenge })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="trackRef"
|
||||
class="captcha-track relative h-12 select-none overflow-hidden rounded-lg border transition-[background-color,border-color] duration-300"
|
||||
:class="[
|
||||
verified
|
||||
? 'border-primary/35 bg-primary/10'
|
||||
: 'border-default bg-muted/20',
|
||||
loading ? 'opacity-70' : '',
|
||||
shake ? 'captcha-shake' : ''
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-2"
|
||||
class="size-5 animate-spin text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<img
|
||||
v-if="trackSvgUrl"
|
||||
:src="trackSvgUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 size-full object-fill transition-opacity duration-300"
|
||||
:class="verified ? 'opacity-0' : 'opacity-100'"
|
||||
draggable="false"
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="isActive && !verified"
|
||||
class="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-primary/20 transition-[width] duration-75"
|
||||
:style="{ width: fillWidth }"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!verified"
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center pl-12 text-sm text-muted transition-opacity duration-150"
|
||||
:style="{ opacity: hintOpacity }"
|
||||
>
|
||||
{{ t('auth.captchaHint') }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="verified"
|
||||
class="pointer-events-none absolute inset-y-0 flex items-center pr-3 text-sm font-medium text-primary"
|
||||
:style="{ left: successLabelLeft }"
|
||||
>
|
||||
{{ t('auth.captchaSuccess') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 touch-none rounded-md border transition-[left,background-color,border-color,color,box-shadow,transform] duration-150"
|
||||
:class="[
|
||||
verified
|
||||
? 'z-20 border-primary bg-primary text-white shadow-sm shadow-primary/25'
|
||||
: isActive
|
||||
? 'z-10 border-primary/45 bg-elevated text-primary shadow-md'
|
||||
: 'z-10 border-default/90 bg-elevated text-muted shadow-sm',
|
||||
dragging ? 'scale-[1.02] cursor-grabbing' : verified ? 'cursor-default' : 'cursor-grab'
|
||||
]"
|
||||
:style="{
|
||||
left: `${thumbLeft}px`,
|
||||
width: `${THUMB_SIZE}px`,
|
||||
height: `${THUMB_SIZE}px`
|
||||
}"
|
||||
@pointerdown="onThumbPointerDown"
|
||||
@pointermove="onThumbPointerMove"
|
||||
@pointerup="onThumbPointerUp"
|
||||
@pointercancel="onThumbPointerUp"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center">
|
||||
<UIcon
|
||||
v-if="confirming"
|
||||
name="i-lucide-loader-2"
|
||||
class="size-4 animate-spin"
|
||||
:class="verified ? 'text-white' : isActive ? 'text-primary' : 'text-muted'"
|
||||
/>
|
||||
<UIcon
|
||||
v-else
|
||||
:name="verified ? 'i-lucide-check' : 'i-lucide-chevrons-right'"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.captcha-shake {
|
||||
animation: captcha-shake 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes captcha-shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25%,
|
||||
75% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -66,6 +66,11 @@ export function getFetchErrorMessage(error: unknown, fallback: string): string {
|
||||
|
||||
export type AuthActionResult = { ok: true } | { ok: false, error: string }
|
||||
|
||||
export interface LoginCaptchaPayload {
|
||||
captchaId: string
|
||||
captchaPosition: number
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const { t } = useI18n()
|
||||
const status = useState<AuthStatus>('auth-status', () => 'checking')
|
||||
@@ -117,7 +122,8 @@ export function useAuth() {
|
||||
|
||||
async function login(
|
||||
credentials: { username: string, password: string }
|
||||
| { secret: string }
|
||||
| { secret: string },
|
||||
captcha: LoginCaptchaPayload
|
||||
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
|
||||
try {
|
||||
const result = await $fetch<{
|
||||
@@ -127,7 +133,11 @@ export function useAuth() {
|
||||
}>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: credentials
|
||||
body: {
|
||||
...credentials,
|
||||
captchaId: captcha.captchaId,
|
||||
captchaPosition: captcha.captchaPosition
|
||||
}
|
||||
})
|
||||
if (result.needsMigration) {
|
||||
return { ok: true, needsMigration: true }
|
||||
|
||||
@@ -65,6 +65,10 @@ export function useUploadPreferences() {
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
if (import.meta.client && !loaded.value) {
|
||||
loadPreferences()
|
||||
}
|
||||
|
||||
function savePreferences() {
|
||||
writeStored({
|
||||
compressEnabled: compressEnabled.value,
|
||||
|
||||
@@ -28,7 +28,13 @@ interface SettingsResponse {
|
||||
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const domainSeparationDocUrl = computed(() =>
|
||||
locale.value === 'en'
|
||||
? 'https://o96u.github.io/PicHost/en/guide/domain-separation'
|
||||
: 'https://o96u.github.io/PicHost/guide/domain-separation'
|
||||
)
|
||||
|
||||
const settings = ref<SettingsResponse | null>(null)
|
||||
const savingServer = ref(false)
|
||||
@@ -360,7 +366,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
>
|
||||
{{ t('setup.domainSeparationProxyHint') }}
|
||||
<a
|
||||
href="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md"
|
||||
:href="domainSeparationDocUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline"
|
||||
|
||||
+8
-2
@@ -7,7 +7,13 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { setup, migrate, fetchStatus } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const domainSeparationDocUrl = computed(() =>
|
||||
locale.value === 'en'
|
||||
? 'https://o96u.github.io/PicHost/en/guide/domain-separation'
|
||||
: 'https://o96u.github.io/PicHost/guide/domain-separation'
|
||||
)
|
||||
|
||||
const needsMigration = ref(false)
|
||||
const isMigrate = computed(() => needsMigration.value || route.query.migrate === '1')
|
||||
@@ -196,7 +202,7 @@ async function submit() {
|
||||
<p class="text-xs leading-relaxed text-warning">
|
||||
{{ t('setup.domainSeparationProxyHint') }}
|
||||
<a
|
||||
href="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md"
|
||||
:href="domainSeparationDocUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:underline"
|
||||
|
||||
@@ -251,6 +251,10 @@ async function confirmDelete() {
|
||||
color: 'success'
|
||||
})
|
||||
await refreshStats()
|
||||
await reloadGallery()
|
||||
if (items.value.length === 0 && galleryPage.value > 1) {
|
||||
await goToPage(galleryPage.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length) {
|
||||
|
||||
Reference in new issue
Block a user