Login

This commit is contained in:
2025-09-20 02:13:02 +08:00
parent 48a9a97e18
commit e9c7559e38
15 changed files with 855 additions and 30 deletions

View File

@@ -5,6 +5,14 @@
</template>
<script lang="ts">
import "@fontsource-variable/nunito"
import "~/assets/css/tailwind.css"
import "@mdi/font/css/materialdesignicons.css"
import { useUserStore } from "~/stores/user"
onMounted(() => {
const userStore = useUserStore()
userStore.fetchUser()
})
</script>

View File

@@ -0,0 +1,10 @@
@import "@fontsource-variable/nunito";
html, body {
--font-family: 'Nunito Variable', sans-serif;
font-family: var(--font-family);
}
@tailwind base;
@tailwind components;
@tailwind utilities;

BIN
app/assets/images/cloudy-lamb.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,70 @@
<template>
<div class="d-flex justify-center">
<div v-if="provider === 'cloudflare'">
<turnstile v-if="!!apiKey" :sitekey="apiKey" @callback="handleSuccess" />
<div v-else class="mx-auto">
<v-progress-circular indeterminate />
</div>
</div>
<div v-else-if="provider === 'hcaptcha'">
<hcaptcha
v-if="!!apiKey"
:sitekey="apiKey"
@verify="(tk: string) => handleSuccess(tk)"
/>
<div v-else class="mx-auto">
<v-progress-circular indeterminate />
</div>
</div>
<div
v-else-if="provider === 'recaptcha'"
class="h-captcha"
:data-sitekey="apiKey"
/>
<div v-else class="d-flex flex-column align-center justify-center gap-1">
<v-icon size="32"> mdi-alert-circle-outline </v-icon>
<span>Captcha provider not configured correctly.</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import Turnstile from "cfturnstile-vue3"
import Hcaptcha from "@hcaptcha/vue3-hcaptcha"
const props = defineProps({
provider: {
type: String,
required: false,
default: ""
},
apiKey: {
type: String,
required: false,
default: ""
}
})
const provider = ref(props.provider)
const apiKey = ref(props.apiKey)
const api = useSolarNetwork()
const emit = defineEmits(["verified"])
function handleSuccess(token: string) {
emit("verified", token)
}
// This function will be used to fetch configuration if needed,
// Like the backend didn't embed the configuration properly.
async function fetchConfiguration() {
const resp = await api<{ provider: string; apiKey: string }>("/id/captcha")
provider.value = resp.provider
apiKey.value = resp.apiKey
}
onMounted(() => {
if (!provider.value || !apiKey.value) fetchConfiguration()
})
</script>

View File

@@ -0,0 +1,163 @@
<template>
<v-container class="d-flex align-center justify-center fill-height">
<v-card class="pa-4" max-width="500">
<v-card-title>Create a new Solar Network ID</v-card-title>
<v-overlay :model-value="isLoading" class="align-center justify-center">
<v-progress-circular indeterminate size="64" />
</v-overlay>
<v-form @submit.prevent="handleCreateAccount">
<v-text-field
v-model="formModel.name"
label="Username"
variant="outlined"
:rules="nameRules"
class="mb-4"
/>
<v-text-field
v-model="formModel.nick"
label="Nickname"
variant="outlined"
:rules="nickRules"
class="mb-4"
/>
<v-text-field
v-model="formModel.email"
label="Email"
placeholder="your@email.com"
variant="outlined"
:rules="emailRules"
class="mb-4"
/>
<v-text-field
v-model="formModel.password"
label="Password"
type="password"
placeholder="Enter your password"
variant="outlined"
:rules="passwordRules"
class="mb-4"
/>
<div class="d-flex justify-center mb-4">
<client-only>
<captcha-widget
:provider="captchaProvider"
:api-key="captchaApiKey"
@verified="onCaptchaVerified"
/>
</client-only>
</div>
<v-btn
type="submit"
color="primary"
block
size="large"
:disabled="isLoading"
>
Create Account
</v-btn>
<div class="mt-3 text-center text-body-2 opacity-75">
<v-btn
variant="text"
block
size="small"
to="/auth/login"
>
Already have an account? Login
</v-btn>
</div>
</v-form>
<v-alert
v-if="error"
type="error"
closable
class="mt-4"
@update:model-value="error = null"
>
{{ error }}
</v-alert>
</v-card>
</v-container>
</template>
<script setup lang="ts">
import { ref, reactive } from "vue"
import { useRouter } from "vue-router"
import { useSolarNetwork } from "~/composables/useSolarNetwork"
import CaptchaWidget from "~/components/CaptchaWidget.vue"
const router = useRouter()
const api = useSolarNetwork()
const isLoading = ref(false)
const error = ref<string | null>(null)
const formModel = reactive({
name: "",
nick: "",
email: "",
password: "",
language: "en-us",
captchaToken: ""
})
// Get captcha provider and API key from global data
const captchaProvider = ref((window as { DyPrefetch?: { provider?: string } }).DyPrefetch?.provider || "")
const captchaApiKey = ref((window as { DyPrefetch?: { api_key?: string } }).DyPrefetch?.api_key || "")
const nameRules = [
(v: string) => !!v || "Please enter a username",
(v: string) =>
/^[A-Za-z0-9_-]+$/.test(v) ||
"Username can only contain letters, numbers, underscores, and hyphens."
]
const nickRules = [(v: string) => !!v || "Please enter a nickname"]
const emailRules = [
(v: string) => !!v || "Please enter your email",
(v: string) => /.+@.+\..+/.test(v) || "Please enter a valid email address"
]
const passwordRules = [
(v: string) => !!v || "Please enter a password",
(v: string) => v.length >= 4 || "Password must be at least 4 characters long"
]
const onCaptchaVerified = (token: string) => {
formModel.captchaToken = token
}
async function handleCreateAccount() {
isLoading.value = true
error.value = null
try {
await api("/id/accounts", {
method: "POST",
body: {
name: formModel.name,
nick: formModel.nick,
email: formModel.email,
password: formModel.password,
language: formModel.language,
captcha_token: formModel.captchaToken
}
})
// On success, redirect to login page
alert(
"Welcome to Solar Network! Your account has been created successfully. Don't forget to check your email for activation instructions."
)
router.push("/login")
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'An error occurred'
} finally {
isLoading.value = false
}
}
</script>

434
app/pages/auth/login.vue Normal file
View File

@@ -0,0 +1,434 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useUserStore } from "~/stores/user"
import { useSolarNetwork } from "~/composables/useSolarNetwork"
import type { SnAuthChallenge, SnAuthFactor } from "~/types/api"
import FingerprintJS from "@fingerprintjs/fingerprintjs"
import IconLight from "~/assets/images/cloudy-lamb.png"
import IconDark from "~/assets/images/cloudy-lamb@dark.png"
// State management
const stage = ref<
"find-account" | "select-factor" | "enter-code" | "token-exchange"
>("find-account")
const isLoading = ref(false)
const error = ref<string | null>(null)
// Stage 1: Find Account
const accountIdentifier = ref("")
const deviceId = ref("")
// Stage 2 & 3: Challenge
const challenge = ref<SnAuthChallenge | null>(null)
const factors = ref<SnAuthFactor[]>([])
const selectedFactorId = ref<string | null>(null)
const password = ref("") // Used for password or verification code
const router = useRouter()
const api = useSolarNetwork()
// Generate deviceId based on browser fingerprint
onMounted(async () => {
const fp = await FingerprintJS.load()
const result = await fp.get()
deviceId.value = result.visitorId
})
const selectedFactor = computed(() => {
if (!selectedFactorId.value) return null
return factors.value.find((f) => f.id === selectedFactorId.value)
})
async function handleFindAccount() {
if (!accountIdentifier.value) {
error.value = "Please enter your email or username."
return
}
isLoading.value = true
error.value = null
try {
challenge.value = await api("/id/auth/challenge", {
method: "POST",
body: {
platform: 1,
account: accountIdentifier.value,
device_id: deviceId.value
}
})
await getFactors()
stage.value = "select-factor"
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "An error occurred"
} finally {
isLoading.value = false
}
}
async function getFactors() {
if (!challenge.value) return
isLoading.value = true
error.value = null
try {
const availableFactors = await api(
`/id/auth/challenge/${challenge.value.id}/factors`
)
factors.value = availableFactors.filter(
(f: SnAuthFactor) => !challenge.value!.blacklistFactors.includes(f.id)
)
if (factors.value.length > 0) {
selectedFactorId.value = null // Let user choose
} else if (challenge.value.stepRemain > 0) {
error.value =
"No more available authentication factors, but authentication is not complete. Please contact support."
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "An error occurred"
} finally {
isLoading.value = false
}
}
async function requestVerificationCode(hint: string | null) {
if (!selectedFactorId.value || !challenge.value) return
const isResend = stage.value === "enter-code"
if (isResend) isLoading.value = true
error.value = null
try {
await api(
`/id/auth/challenge/${challenge.value.id}/factors/${selectedFactorId.value}`,
{
method: "POST",
body: hint
}
)
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "An error occurred"
throw e // Rethrow to be handled by caller
} finally {
if (isResend) isLoading.value = false
}
}
async function handleFactorSelected() {
if (!selectedFactor.value) {
error.value = "Please select an authentication method."
return
}
// For password (0), just move to the next step
if (selectedFactor.value.type === 0) {
stage.value = "enter-code"
return
}
// For code-based factors (1, 2, 3, 4), send the code first
if ([1, 2, 3, 4].includes(selectedFactor.value.type)) {
isLoading.value = true
error.value = null
try {
await requestVerificationCode(selectedFactor.value.contact ?? null)
stage.value = "enter-code"
} catch {
// Error is already set by requestVerificationCode
} finally {
isLoading.value = false
}
}
}
async function handleVerifyFactor() {
if (!selectedFactorId.value || !password.value || !challenge.value) {
error.value = "Please enter your password/code."
return
}
isLoading.value = true
error.value = null
try {
challenge.value = await api(`/id/auth/challenge/${challenge.value.id}`, {
method: "PATCH",
body: {
factor_id: selectedFactorId.value,
password: password.value
}
})
password.value = ""
if (challenge.value.stepRemain === 0) {
stage.value = "token-exchange"
await exchangeToken()
} else {
await getFactors()
stage.value = "select-factor" // MFA step
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "An error occurred"
} finally {
isLoading.value = false
}
}
const userStore = useUserStore()
const route = useRoute()
async function exchangeToken() {
isLoading.value = true
error.value = null
try {
await api("/id/auth/token", {
method: "POST",
body: {
grant_type: "authorization_code",
code: challenge.value.id
}
})
await userStore.fetchUser()
const redirectUri = route.query.redirect_uri as string
if (redirectUri) {
window.location.href = redirectUri
} else {
await router.push("/")
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "An error occurred"
stage.value = "select-factor" // Go back if token exchange fails
} finally {
isLoading.value = false
}
}
function getFactorName(factorType: number) {
switch (factorType) {
case 0: // Password
return "Password"
case 1: // EmailCode
return "Email Code"
case 2: // InAppCode
return "In-App Code"
case 3: // TimedCode
return "Timed Code"
case 4: // PinCode
return "PIN Code"
default:
return "Unknown Factor"
}
}
</script>
<template>
<v-container class="d-flex align-center justify-center fill-height">
<v-card class="pa-8" max-width="1000" rounded="lg" width="100%">
<v-overlay :model-value="isLoading" class="align-center justify-center">
<v-progress-circular indeterminate size="64" />
</v-overlay>
<div class="mb-4">
<img :src="IconLight" alt="CloudyLamb" height="60" width="60">
</div>
<v-row>
<v-col cols="12" lg="6" class="d-flex align-start justify-start">
<div class="md:text-left h-auto">
<div v-if="stage === 'find-account'">
<h2 class="text-2xl font-bold mb-1">Sign in</h2>
<p class="text-lg">Use your Solarpass</p>
</div>
<div v-if="stage === 'select-factor'">
<h2 class="text-2xl font-bold mb-1">Choose how to sign in</h2>
<p class="text-lg">
Select your preferred authentication method
</p>
</div>
<div v-if="stage === 'enter-code' && selectedFactor">
<h2 class="text-2xl font-bold mb-1">
Enter your
{{
selectedFactor.type === 0 ? "password" : "verification code"
}}
</h2>
<p v-if="selectedFactor.type === 1" class="text-lg">
A code has been sent to
{{ selectedFactor.contact || "your email" }}.
</p>
<p v-if="selectedFactor.type === 2" class="text-lg">
Enter the code from your in-app authenticator.
</p>
<p v-if="selectedFactor.type === 3" class="text-lg">
Enter the timed verification code.
</p>
<p v-if="selectedFactor.type === 4" class="text-lg">
Enter your PIN code.
</p>
<p v-if="selectedFactor.type === 0" class="text-lg">
Enter your password to continue.
</p>
</div>
<div v-if="stage === 'token-exchange'">
<h2 class="text-2xl font-bold mb-1">Finalizing Login</h2>
<p class="text-lg">
Please wait while we complete your sign in.
</p>
</div>
</div>
</v-col>
<v-col cols="12" lg="6" class="d-flex align-center justify-stretch">
<div class="w-full d-flex flex-column md:text-right">
<!-- Stage 1: Find Account -->
<div v-if="stage === 'find-account'">
<v-text-field
v-model="accountIdentifier"
label="Email or username"
variant="filled"
class="mb-2"
@keydown.enter="handleFindAccount"
/>
<v-btn variant="text" class="text-capitalize pl-0" color="primary"
>Forgot email?</v-btn
>
<p class="text-body-2 mt-8 mb-4">
Not your computer? Use Private Browsing windows to sign in.
<v-btn
variant="text"
class="text-capitalize pl-0"
color="primary"
href="https://support.google.com/accounts/answer/181449?hl=en"
target="_blank"
>
Learn more about using Guest mode
</v-btn>
</p>
<div class="d-flex justify-space-between align-center mt-8">
<v-btn
variant="text"
class="text-capitalize"
to="/auth/create-account"
>
Create account
</v-btn>
<v-btn color="primary" size="large" @click="handleFindAccount">
Next
</v-btn>
</div>
</div>
<!-- Stage 2: Select Factor -->
<div v-if="stage === 'select-factor' && challenge">
<v-radio-group v-model="selectedFactorId">
<v-list>
<v-list-item v-for="factor in factors" :key="factor.id">
<v-list-item-action>
<v-radio :value="factor.id" :label="getFactorName(factor.type)"></v-radio>
</v-list-item-action>
<template #append>
<v-icon>{{
factor.type === 0 ? "mdi-lock" :
factor.type === 1 ? "mdi-email" :
factor.type === 2 ? "mdi-cellphone" :
factor.type === 3 ? "mdi-clock" :
factor.type === 4 ? "mdi-numeric" :
"mdi-shield-key"
}}</v-icon>
</template>
</v-list-item>
</v-list>
</v-radio-group>
<div class="d-flex justify-end mt-6">
<v-btn
color="primary"
size="large"
:disabled="!selectedFactorId"
@click="handleFactorSelected"
>
Next
</v-btn>
</div>
</div>
<!-- Stage 3: Enter Code -->
<div v-if="stage === 'enter-code' && selectedFactor">
<v-text-field
v-model="password"
:type="selectedFactor.type === 0 ? 'password' : 'text'"
:label="selectedFactor.type === 0 ? 'Password' : 'Code'"
variant="filled"
class="mb-2"
@keydown.enter="handleVerifyFactor"
/>
<div class="d-flex justify-space-between align-center mt-6">
<v-btn
v-if="selectedFactor.type === 1"
variant="text"
class="text-capitalize pl-0"
color="primary"
@click="
requestVerificationCode(selectedFactor.contact ?? null)
"
>
Resend Code
</v-btn>
<v-spacer v-else />
<v-btn color="primary" size="large" @click="handleVerifyFactor">
Verify
</v-btn>
</div>
</div>
<!-- Stage 4: Token Exchange -->
<div v-if="stage === 'token-exchange'">
<div class="d-flex justify-center">
<v-progress-circular indeterminate size="64" color="primary" />
</div>
</div>
<v-alert
v-if="error"
type="error"
closable
class="mt-2"
@update:model-value="error = null"
>
{{ error }}
</v-alert>
</div>
</v-col>
</v-row>
</v-card>
<div
class="d-flex justify-space-between align-center w-100"
style="
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
max-width: 1200px;
"
>
<v-select
:items="['English (United States)']"
model-value="English (United States)"
variant="plain"
density="compact"
hide-details
class="flex-grow-0"
/>
<div class="d-flex">
<v-btn variant="text" size="small" class="text-capitalize">Help</v-btn>
<v-btn variant="text" size="small" class="text-capitalize"
>Privacy</v-btn
>
<v-btn variant="text" size="small" class="text-capitalize">Terms</v-btn>
</div>
</div>
</v-container>
</template>

View File

@@ -1,19 +1,17 @@
<template>
<v-container>
<v-row>
<v-col cols="12" md="8" :order="$vuetify.display.lgAndUp ? 1 : 2">
<v-infinite-scroll style="height: calc(100vh - 57px)" :distance="10" @load="fetchActivites">
<div v-for="activity in activites" :key="activity.id" class="mt-4">
<div class="layout">
<div class="main">
<div v-for="activity in activites" :key="activity.id" class="mb-4">
<post-item
v-if="activity.type.startsWith('posts')"
:item="activity.data"
@click="router.push('/posts/' + activity.id)"
/>
</div>
</v-infinite-scroll>
</v-col>
<v-col cols="12" md="4" :order="$vuetify.display.lgAndUp ? 2 : 1">
<v-card v-if="!userStore.user" class="w-full mt-4" title="About">
</div>
<div class="sidebar">
<v-card v-if="!userStore.user" class="w-full" title="About">
<v-card-text>
<p>Welcome to the <b>Solar Network</b></p>
<p>The open social network. Friendly to everyone.</p>
@@ -28,24 +26,25 @@
</p>
</v-card-text>
</v-card>
<v-card class="mt-4 w-full">
<v-card v-else class="mt-4 w-full">
<v-card-text>
<post-editor @posted="refreshActivities" />
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</div>
</v-container>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useUserStore } from '~/stores/user'
import { useSolarNetwork } from '~/composables/useSolarNetwork'
import type { SnVersion, SnActivity } from '~/types/api'
import { computed, onMounted, ref } from "vue"
import { useInfiniteScroll } from "@vueuse/core"
import { useUserStore } from "~/stores/user"
import { useSolarNetwork } from "~/composables/useSolarNetwork"
import type { SnVersion, SnActivity } from "~/types/api"
import PostEditor from '~/components/PostEditor.vue'
import PostItem from '~/components/PostItem.vue'
import PostEditor from "~/components/PostEditor.vue"
import PostItem from "~/components/PostItem.vue"
const router = useRouter()
@@ -54,7 +53,7 @@ const userStore = useUserStore()
const version = ref<SnVersion | null>(null)
async function fetchVersion() {
const api = useSolarNetwork()
const resp = await api('/sphere/version')
const resp = await api("/sphere/version")
version.value = resp as SnVersion
}
onMounted(() => fetchVersion())
@@ -62,7 +61,9 @@ onMounted(() => fetchVersion())
const loading = ref(false)
const activites = ref<SnActivity[]>([])
const activitesLast = computed(() => activites.value[Math.max(activites.value.length - 1, 0)])
const activitesLast = computed(
() => activites.value[Math.max(activites.value.length - 1, 0)]
)
const activitesHasMore = ref(true)
async function fetchActivites() {
@@ -72,18 +73,73 @@ async function fetchActivites() {
const api = useSolarNetwork()
const resp = await api(
activitesLast.value == null
? '/sphere/activities'
: `/sphere/activities?cursor=${new Date(activitesLast.value.createdAt).toISOString()}`,
? "/sphere/activities"
: `/sphere/activities?cursor=${new Date(
activitesLast.value.createdAt
).toISOString()}`
)
const data = resp as SnActivity[]
activites.value = [...activites.value, ...data]
activitesHasMore.value = data[0]?.type != 'empty'
activitesHasMore.value = data[0]?.type != "empty"
loading.value = false
}
onMounted(() => fetchActivites())
useInfiniteScroll(window, fetchActivites, {
canLoadMore: () => !loading.value && activitesHasMore.value,
distance: 10,
})
async function refreshActivities() {
activites.value = []
fetchActivites()
}
</script>
<style scoped>
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.layout {
display: grid;
grid-template-columns: 1fr;
gap: 0 16px;
}
.main {
order: 2;
}
.sidebar {
order: 1;
overflow-y: auto;
height: auto;
max-height: 100vh;
}
@media (min-width: 960px) {
.layout {
grid-template-columns: 2fr 1fr;
}
.main {
order: unset;
}
.sidebar {
order: unset;
}
}
@media (min-width: 1280px) {
.sidebar {
position: sticky;
top: calc(68px + 8px);
}
}
</style>

65
app/types/api/geo.ts Normal file
View File

@@ -0,0 +1,65 @@
export interface GeoIpLocation {
latitude: number | null
longitude: number | null
countryCode: string | null
country: string | null
city: string | null
}
export interface SnAuthChallenge {
id: string
expiredAt: string | null
stepRemain: number
stepTotal: number
failedAttempts: number
type: number
blacklistFactors: string[]
audiences: unknown[]
scopes: unknown[]
ipAddress: string
userAgent: string
nonce: string | null
location: GeoIpLocation | null
accountId: string
createdAt: string
updatedAt: string
deletedAt: string | null
}
export interface SnAuthSession {
id: string
label: string | null
lastGrantedAt: string
expiredAt: string | null
accountId: string
challengeId: string
challenge: SnAuthChallenge
createdAt: string
updatedAt: string
deletedAt: string | null
}
export interface SnAuthFactor {
id: string
type: number
contact?: string | null
createdAt: string
updatedAt: string
deletedAt: string | null
expiredAt: string | null
enabledAt: string | null
trustworthy: number
createdResponse: Record<string, unknown> | null
}
export interface SnAccountConnection {
id: string
accountId: string
provider: string
providedIdentifier: string
meta: Record<string, unknown>
lastUsedAt: string
createdAt: string
updatedAt: string
deletedAt: string | null
}

View File

@@ -4,3 +4,4 @@ export type { SnVerification, SnPublisher } from './publisher'
export type { SnActivity } from './activity'
export type { SnVersion } from './version'
export type { SnAccountLink, SnAccountBadge, SnAccountPerkSubscription, SnAccountProfile, SnAccount } from './user'
export type { GeoIpLocation, SnAuthChallenge, SnAuthSession, SnAuthFactor, SnAccountConnection } from './geo'

View File

@@ -7,6 +7,7 @@
"@date-io/luxon": "^3.2.0",
"@fingerprintjs/fingerprintjs": "^4.6.2",
"@fontsource-variable/nunito": "^5.2.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@nuxt/eslint": "1.9.0",
"@nuxt/image": "1.11.0",
"@nuxtjs/i18n": "10.1.0",
@@ -194,6 +195,8 @@
"@fontsource-variable/nunito": ["@fontsource-variable/nunito@5.2.7", "", {}, "sha512-2N8QhatkyKgSUbAGZO2FYLioxA32+RyI1EplVLawbpkGjUeui9Qg9VMrpkCaik1ydjFjfLV+kzQ0cGEsMrMenQ=="],
"@hcaptcha/vue3-hcaptcha": ["@hcaptcha/vue3-hcaptcha@1.3.0", "", { "dependencies": { "vue": "^3.2.19" } }, "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],

View File

@@ -10,6 +10,9 @@ export default defineNuxtConfig({
"vuetify-nuxt-module",
"@nuxtjs/i18n"
],
pinia: {
storesDirs: ["./app/stores/**"]
},
features: {
inlineStyles: false
},

View File

@@ -13,6 +13,7 @@
"@date-io/luxon": "^3.2.0",
"@fingerprintjs/fingerprintjs": "^4.6.2",
"@fontsource-variable/nunito": "^5.2.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@nuxt/eslint": "1.9.0",
"@nuxt/image": "1.11.0",
"@nuxtjs/i18n": "10.1.0",

11
tailwind.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Config } from "tailwindcss"
export default <Partial<Config>>{
theme: {
extend: {
fontFamily: {
sans: ["Nunito Variable", "Helvatica", "sans-serif"]
}
}
}
}

View File

@@ -8,5 +8,5 @@ export default defineVuetifyConfiguration({
},
date: {
adapter: 'luxon'
}
},
});