First commit

This commit is contained in:
2026-09-30 01:49:35 +02:00
commit 6edd03aace
109 changed files with 27020 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
/* Both ways in — into an existing account or a new one — in a single popup,
drawn and closed in the same shape as the settings popup: esc, the backdrop,
the ✕, or moving to another page. Whoever opens it chooses a tab and may leave
a line of explanation above the form (see useSignInPopup); the editor uses
that when a save comes back saying the session has gone.
Signing in is enough on its own: the editor pages show their form as soon as
there is a session, so the page behind this popup is already ready when it
closes — whatever was being written is still there. */
const { open, mode, notice, show, hide } = useSignInPopup()
const { hide: hideSearch } = useSearch()
const { hide: hideSettings } = useSettings()
const { hide: hideProfile } = useProfileMenu()
const { signIn, signUp } = useAuth()
const { applyAccount } = useAppLocale()
const { t } = useI18n()
const route = useRoute()
const username = ref('')
const password = ref('')
const errorMsg = ref('')
const busy = ref(false)
const panelEl = ref<HTMLElement | null>(null)
const usernameEl = ref<HTMLInputElement | null>(null)
const isSignUp = computed(() => mode.value === 'signup')
const heading = computed(() => (isSignUp.value ? t('auth.signup') : t('auth.signin')))
const submitLabel = computed(() =>
busy.value ? t('auth.busy') : isSignUp.value ? t('auth.signupSubmit') : t('auth.signin')
)
watch(
open,
async (isOpen) => {
if (!isOpen) return
// opening this shouldn't leave another popup stacked behind it
hideSearch()
hideSettings()
hideProfile()
errorMsg.value = ''
await nextTick()
usernameEl.value?.focus()
},
{ flush: 'post' }
)
/* Switching tabs keeps the username — the same person either way — but not the
password, which is a different secret in each direction. */
function setMode(next: SignInMode) {
if (mode.value === next) return
show({ mode: next })
password.value = ''
errorMsg.value = ''
}
async function submit() {
if (busy.value) return
errorMsg.value = ''
busy.value = true
try {
// the account decides the interface language — let it overrule the device
const user = isSignUp.value ? await signUp(username.value, password.value) : await signIn(username.value, password.value)
applyAccount(user?.locale)
password.value = ''
hide()
} catch (err: any) {
errorMsg.value = err?.data?.message || t('common.apiUnreachable')
} finally {
busy.value = false
}
}
/* --- esc closes, navigation closes --- */
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open.value) hide()
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
watch(
() => route.fullPath,
() => {
if (open.value) hide()
}
)
</script>
<template>
<Teleport to="body">
<Transition name="auth-popup">
<div v-if="open" class="auth-overlay" @click.self="hide()">
<div
ref="panelEl"
class="auth-modal"
role="dialog"
aria-modal="true"
:aria-label="heading"
tabindex="-1"
>
<div class="auth-modal-head">
<h2 class="auth-title">{{ heading }}</h2>
<button
type="button"
class="panel-icon-btn"
:aria-label="t('common.close')"
:title="t('common.close')"
@click="hide()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="auth-modal-body">
<div class="auth-tabs" role="tablist" :aria-label="t('auth.tabsAria')">
<button
type="button"
role="tab"
class="auth-tab"
:class="{ active: !isSignUp }"
:aria-selected="!isSignUp"
@click="setMode('signin')"
>
{{ t('auth.signin') }}
</button>
<button
type="button"
role="tab"
class="auth-tab"
:class="{ active: isSignUp }"
:aria-selected="isSignUp"
@click="setMode('signup')"
>
{{ t('auth.signupTab') }}
</button>
</div>
<p v-if="notice" class="auth-notice">{{ notice }}</p>
<form @submit.prevent="submit">
<div class="field">
<label for="auth-username">{{ t('auth.username') }}</label>
<input
id="auth-username"
ref="usernameEl"
v-model="username"
type="text"
autocomplete="username"
autocapitalize="none"
spellcheck="false"
:placeholder="t('auth.usernamePlaceholder')"
required
/>
</div>
<div class="field">
<label for="auth-password">
{{ t('auth.password') }} <span class="hint">{{ t('auth.passwordHint') }}</span>
</label>
<input
id="auth-password"
v-model="password"
type="password"
:autocomplete="isSignUp ? 'new-password' : 'current-password'"
minlength="8"
required
/>
</div>
<p v-if="errorMsg" class="form-error">{{ errorMsg }}</p>
<button type="submit" class="btn btn-primary auth-submit" :disabled="busy">
{{ submitLabel }}
</button>
</form>
<p class="auth-switch">
<template v-if="isSignUp">
{{ t('auth.haveAccount') }}
<button type="button" @click="setMode('signin')">{{ t('auth.signin') }}</button>
</template>
<template v-else>
{{ t('auth.newToMathew') }}
<button type="button" @click="setMode('signup')">{{ t('auth.signup') }}</button>
</template>
</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>