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
@@ -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>
|
||||
Reference in new issue
Block a user