Proper user login

This commit is contained in:
2025-09-20 12:37:37 +08:00
parent e9c7559e38
commit bd78e3f600
16 changed files with 323 additions and 95 deletions

View File

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

View File

@@ -1,17 +1,122 @@
<template> <template>
<img v-if="itemType == 'image'" :src="remoteSource" class="rounded-md"> <div v-if="itemType == 'image'" class="relative rounded-md overflow-hidden" :style="containerStyle">
<!-- Blurhash placeholder -->
<div
v-if="blurhash"
class="absolute inset-0"
:style="blurhashContainerStyle"
>
<canvas
ref="blurCanvas"
class="absolute top-0 left-0 w-full h-full"
width="32"
height="32"
/>
</div>
<!-- Main image -->
<img
:src="remoteSource"
class="w-full h-auto rounded-md"
:class="{ 'opacity-0': !imageLoaded && blurhash }"
@load="imageLoaded = true"
@error="imageLoaded = true"
/>
</div>
<audio v-else-if="itemType == 'audio'" :src="remoteSource" controls /> <audio v-else-if="itemType == 'audio'" :src="remoteSource" controls />
<video v-else-if="itemType == 'video'" :src="remoteSource" controls /> <video v-else-if="itemType == 'video'" :src="remoteSource" controls />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed, ref, onMounted, watch } from "vue"
import type { SnAttachment } from '~/types/api' import { decode } from "blurhash"
import type { SnAttachment } from "~/types/api"
const props = defineProps<{ item: SnAttachment }>() const props = defineProps<{ item: SnAttachment }>()
const itemType = computed(() => props.item.mimeType.split('/')[0] ?? 'unknown') const itemType = computed(() => props.item.mimeType.split("/")[0] ?? "unknown")
const blurhash = computed(() => props.item.fileMeta?.blur)
const imageWidth = computed(() => props.item.fileMeta?.width)
const imageHeight = computed(() => props.item.fileMeta?.height)
const aspectRatio = computed(() => props.item.fileMeta?.ratio ?? (imageWidth.value && imageHeight.value ? imageHeight.value / imageWidth.value : 1))
const imageLoaded = ref(false)
const apiBase = useSolarNetworkUrl(); const blurCanvas = ref<HTMLCanvasElement | null>(null)
const remoteSource = computed(() => `${apiBase}/drive/files/${props.item.id}?original=true`)
const apiBase = useSolarNetworkUrl()
const remoteSource = computed(
() => `${apiBase}/drive/files/${props.item.id}?original=true`
)
const containerStyle = computed(() => {
if (imageWidth.value && imageHeight.value) {
const maxWidth = 640 // Cap maximum width
const maxHeight = 800 // Cap maximum height
let width = imageWidth.value
let height = imageHeight.value
// Scale down if width exceeds max
if (width > maxWidth) {
const ratio = maxWidth / width
width = maxWidth
height = height * ratio
}
// Scale down if height exceeds max
if (height > maxHeight) {
const ratio = maxHeight / height
height = maxHeight
width = width * ratio
}
return {
width: `${width}px`,
height: `${height}px`,
'max-width': '100%',
'max-height': '100%'
}
}
return {
'max-width': '800px',
'max-height': '600px'
}
})
const blurhashContainerStyle = computed(() => {
return {
'padding-bottom': `${aspectRatio.value * 100}%`
}
})
const decodeBlurhash = () => {
if (!blurhash.value || !blurCanvas.value) return
try {
const pixels = decode(blurhash.value, 32, 32)
const imageData = new ImageData(new Uint8ClampedArray(pixels), 32, 32)
const context = blurCanvas.value.getContext("2d")
if (context) {
context.putImageData(imageData, 0, 0)
}
} catch (error) {
console.warn("Failed to decode blurhash:", error)
}
}
onMounted(() => {
decodeBlurhash()
// Fallback timeout to show image if load event doesn't fire
if (blurhash.value) {
setTimeout(() => {
if (!imageLoaded.value) {
imageLoaded.value = true
}
}, 3000) // 3 second timeout
}
})
watch(blurhash, () => {
decodeBlurhash()
})
</script> </script>

View File

@@ -67,7 +67,7 @@ const submitting = ref(false)
async function submit() { async function submit() {
submitting.value = true submitting.value = true
const api = useSolarNetwork() const api = useSolarNetwork()
await api(`/posts?pub=${publisher.value}`, { await api(`/sphere/posts?pub=${publisher.value}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',

View File

@@ -10,21 +10,18 @@
<v-list-item v-bind="itemProps"> <v-list-item v-bind="itemProps">
<template #prepend> <template #prepend>
<v-avatar <v-avatar
:image="item.raw.picture ? `${apiBase}/api/drive/files/${item.raw.picture.id}` : undefined" :image="item.raw.picture ? `${apiBase}/drive/files/${item.raw.picture.id}` : undefined"
size="small" size="small"
rounded
/> />
</template> </template>
<v-list-item-title>{{ item.raw?.nick }}</v-list-item-title>
<v-list-item-subtitle>@{{ item.raw?.name }}</v-list-item-subtitle> <v-list-item-subtitle>@{{ item.raw?.name }}</v-list-item-subtitle>
</v-list-item> </v-list-item>
</template> </template>
<template #selection="{ item }"> <template #selection="{ item }">
<div class="d-flex align-center"> <div class="d-flex align-center">
<v-avatar <v-avatar
:image="item.raw.picture ? `${apiBase}/api/drive/files/${item.raw.picture.id}` : undefined" :image="item.raw.picture ? `${apiBase}/drive/files/${item.raw.picture.id}` : undefined"
size="24" size="24"
rounded
class="me-2" class="me-2"
/> />
{{ item.raw?.nick }} {{ item.raw?.nick }}

View File

@@ -3,7 +3,7 @@ import { useDark, useToggle } from "@vueuse/core"
// composables/useCustomTheme.ts // composables/useCustomTheme.ts
export function useCustomTheme(): { export function useCustomTheme(): {
isDark: WritableComputedRef<boolean, boolean> isDark: WritableComputedRef<boolean, boolean>
toggle: (value?: boolean | undefined) => boolean toggle: (value?: boolean | undefined) => boolean,
} { } {
const { $vuetify } = useNuxtApp() const { $vuetify } = useNuxtApp()

View File

@@ -1,28 +1,45 @@
// Solar Network aka the api client // Solar Network aka the api client
import { keysToCamel, keysToSnake } from '~/utils/transformKeys' import { keysToCamel, keysToSnake } from "~/utils/transformKeys"
export const useSolarNetwork = () => { export const useSolarNetwork = () => {
const apiBase = useSolarNetworkUrl(); const apiBase = useSolarNetworkUrl()
return $fetch.create({ return $fetch.create({
baseURL: apiBase, baseURL: apiBase,
credentials: 'include', credentials: "include",
// Add Authorization header with Bearer token
onRequest: ({ options }) => {
// Get token from user store
const userStore = useUserStore()
const token = userStore.token
if (token) {
if (!options.headers) {
options.headers = new Headers()
}
if (options.headers instanceof Headers) {
options.headers.set("Authorization", `Bearer ${token}`)
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(options.headers as any)["Authorization"] = `Bearer ${token}`
}
}
// Transform request data from camelCase to snake_case
if (options.body && typeof options.body === "object") {
options.body = keysToSnake(options.body)
}
},
// Transform response keys from snake_case to camelCase // Transform response keys from snake_case to camelCase
onResponse: ({ response }) => { onResponse: ({ response }) => {
if (response._data) { if (response._data) {
response._data = keysToCamel(response._data) response._data = keysToCamel(response._data)
} }
},
// Transform request data from camelCase to snake_case
onRequest: ({ options }) => {
if (options.body && typeof options.body === 'object') {
options.body = keysToSnake(options.body)
}
} }
}) })
} }
export const useSolarNetworkUrl = () => { export const useSolarNetworkUrl = () => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
return config.public.apiBase return config.public.development ? "/api" : config.public.apiBase
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<v-app :theme="isDark ? 'dark' : 'light'"> <v-app :theme="isDark ? 'dark' : 'light'">
<v-app-bar flat> <v-app-bar flat class="app-bar-blur">
<v-container class="mx-auto d-flex align-center justify-center"> <v-container class="mx-auto d-flex align-center justify-center">
<v-btn <v-btn
v-for="link in links" v-for="link in links"
@@ -13,9 +13,13 @@
<v-spacer /> <v-spacer />
<v-responsive max-width="160"> <v-avatar
<v-avatar class="me-4" color="grey-darken-1" size="32" /> class="me-4"
</v-responsive> color="grey-darken-1"
size="32"
icon="mdi-account"
:image="`${apiBase}/drive/files/${user?.profile.picture?.id}`"
/>
</v-container> </v-container>
</v-app-bar> </v-app-bar>
@@ -29,6 +33,9 @@
import { useCustomTheme } from "~/composables/useCustomTheme" import { useCustomTheme } from "~/composables/useCustomTheme"
import type { NavLink } from "~/types/navlink" import type { NavLink } from "~/types/navlink"
const apiBase = useSolarNetworkUrl()
const { user } = useUserStore()
const { isDark } = useCustomTheme() const { isDark } = useCustomTheme()
const links: NavLink[] = [ const links: NavLink[] = [
@@ -39,3 +46,22 @@ const links: NavLink[] = [
} }
] ]
</script> </script>
<style scoped>
.app-bar-blur {
-webkit-mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 40%,
rgba(0, 0, 0, 0.5) 65%,
rgba(0, 0, 0, 0) 100%
);
mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 40%,
rgba(0, 0, 0, 0.5) 65%,
rgba(0, 0, 0, 0) 100%
);
mask-repeat: no-repeat;
mask-size: 100%;
}
</style>

View File

@@ -75,7 +75,7 @@ async function getFactors() {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
try { try {
const availableFactors = await api( const availableFactors = await api<SnAuthFactor[]>(
`/id/auth/challenge/${challenge.value.id}/factors` `/id/auth/challenge/${challenge.value.id}/factors`
) )
factors.value = availableFactors.filter( factors.value = availableFactors.filter(
@@ -94,7 +94,7 @@ async function getFactors() {
} }
} }
async function requestVerificationCode(hint: string | null) { async function requestVerificationCode() {
if (!selectedFactorId.value || !challenge.value) return if (!selectedFactorId.value || !challenge.value) return
const isResend = stage.value === "enter-code" const isResend = stage.value === "enter-code"
@@ -104,10 +104,7 @@ async function requestVerificationCode(hint: string | null) {
try { try {
await api( await api(
`/id/auth/challenge/${challenge.value.id}/factors/${selectedFactorId.value}`, `/id/auth/challenge/${challenge.value.id}/factors/${selectedFactorId.value}`,
{ { method: "POST" }
method: "POST",
body: hint
}
) )
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : "An error occurred" error.value = e instanceof Error ? e.message : "An error occurred"
@@ -134,7 +131,7 @@ async function handleFactorSelected() {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
try { try {
await requestVerificationCode(selectedFactor.value.contact ?? null) await requestVerificationCode()
stage.value = "enter-code" stage.value = "enter-code"
} catch { } catch {
// Error is already set by requestVerificationCode // Error is already set by requestVerificationCode
@@ -163,7 +160,7 @@ async function handleVerifyFactor() {
password.value = "" password.value = ""
if (challenge.value.stepRemain === 0) { if (challenge.value!.stepRemain === 0) {
stage.value = "token-exchange" stage.value = "token-exchange"
await exchangeToken() await exchangeToken()
} else { } else {
@@ -184,14 +181,19 @@ async function exchangeToken() {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
try { try {
await api("/id/auth/token", { const tokenResponse = await api<{ token: string }>("/id/auth/token", {
method: "POST", method: "POST",
body: { body: {
grant_type: "authorization_code", grant_type: "authorization_code",
code: challenge.value.id code: challenge.value!.id
} }
}) })
// Store the token in localStorage via user store
if (tokenResponse && tokenResponse.token) {
userStore.setToken(tokenResponse.token)
}
await userStore.fetchUser() await userStore.fetchUser()
const redirectUri = route.query.redirect_uri as string const redirectUri = route.query.redirect_uri as string
@@ -233,7 +235,12 @@ function getFactorName(factorType: number) {
<v-progress-circular indeterminate size="64" /> <v-progress-circular indeterminate size="64" />
</v-overlay> </v-overlay>
<div class="mb-4"> <div class="mb-4">
<img :src="IconLight" alt="CloudyLamb" height="60" width="60"> <img
:src="$vuetify.theme.current.dark ? IconDark : IconLight"
alt="CloudyLamb"
height="60"
width="60"
/>
</div> </div>
<v-row> <v-row>
<v-col cols="12" lg="6" class="d-flex align-start justify-start"> <v-col cols="12" lg="6" class="d-flex align-start justify-start">
@@ -244,41 +251,37 @@ function getFactorName(factorType: number) {
</div> </div>
<div v-if="stage === 'select-factor'"> <div v-if="stage === 'select-factor'">
<h2 class="text-2xl font-bold mb-1">Choose how to sign in</h2> <h2 class="text-2xl font-bold mb-1">Choose how to sign in</h2>
<p class="text-lg"> <p class="text-lg">Select your preferred authentication method</p>
Select your preferred authentication method
</p>
</div> </div>
<div v-if="stage === 'enter-code' && selectedFactor"> <div v-if="stage === 'enter-code' && selectedFactor">
<h2 class="text-2xl font-bold mb-1"> <h2 class="text-2xl font-bold mb-1">
Enter your Enter your
{{ {{
selectedFactor.type === 0 ? "password" : "verification code" selectedFactor.type === 0 ? "password" : "verification code"
}} }}
</h2> </h2>
<p v-if="selectedFactor.type === 1" class="text-lg"> <p v-if="selectedFactor.type === 1" class="text-lg">
A code has been sent to A code has been sent to
{{ selectedFactor.contact || "your email" }}. {{ selectedFactor.contact || "your email" }}.
</p> </p>
<p v-if="selectedFactor.type === 2" class="text-lg"> <p v-if="selectedFactor.type === 2" class="text-lg">
Enter the code from your in-app authenticator. Enter the code from your in-app authenticator.
</p> </p>
<p v-if="selectedFactor.type === 3" class="text-lg"> <p v-if="selectedFactor.type === 3" class="text-lg">
Enter the timed verification code. Enter the timed verification code.
</p> </p>
<p v-if="selectedFactor.type === 4" class="text-lg"> <p v-if="selectedFactor.type === 4" class="text-lg">
Enter your PIN code. Enter your PIN code.
</p> </p>
<p v-if="selectedFactor.type === 0" class="text-lg"> <p v-if="selectedFactor.type === 0" class="text-lg">
Enter your password to continue. Enter your password to continue.
</p> </p>
</div> </div>
<div v-if="stage === 'token-exchange'"> <div v-if="stage === 'token-exchange'">
<h2 class="text-2xl font-bold mb-1">Finalizing Login</h2> <h2 class="text-2xl font-bold mb-1">Finalizing Login</h2>
<p class="text-lg"> <p class="text-lg">Please wait while we complete your sign in.</p>
Please wait while we complete your sign in.
</p>
</div>
</div> </div>
</div>
</v-col> </v-col>
<v-col cols="12" lg="6" class="d-flex align-center justify-stretch"> <v-col cols="12" lg="6" class="d-flex align-center justify-stretch">
<div class="w-full d-flex flex-column md:text-right"> <div class="w-full d-flex flex-column md:text-right">
@@ -328,16 +331,24 @@ function getFactorName(factorType: number) {
<v-list> <v-list>
<v-list-item v-for="factor in factors" :key="factor.id"> <v-list-item v-for="factor in factors" :key="factor.id">
<v-list-item-action> <v-list-item-action>
<v-radio :value="factor.id" :label="getFactorName(factor.type)"></v-radio> <v-radio
:value="factor.id"
:label="getFactorName(factor.type)"
/>
</v-list-item-action> </v-list-item-action>
<template #append> <template #append>
<v-icon>{{ <v-icon>{{
factor.type === 0 ? "mdi-lock" : factor.type === 0
factor.type === 1 ? "mdi-email" : ? "mdi-lock"
factor.type === 2 ? "mdi-cellphone" : : factor.type === 1
factor.type === 3 ? "mdi-clock" : ? "mdi-email"
factor.type === 4 ? "mdi-numeric" : : factor.type === 2
"mdi-shield-key" ? "mdi-cellphone"
: factor.type === 3
? "mdi-clock"
: factor.type === 4
? "mdi-numeric"
: "mdi-shield-key"
}}</v-icon> }}</v-icon>
</template> </template>
</v-list-item> </v-list-item>
@@ -371,9 +382,7 @@ function getFactorName(factorType: number) {
variant="text" variant="text"
class="text-capitalize pl-0" class="text-capitalize pl-0"
color="primary" color="primary"
@click=" @click="requestVerificationCode"
requestVerificationCode(selectedFactor.contact ?? null)
"
> >
Resend Code Resend Code
</v-btn> </v-btn>

View File

@@ -11,7 +11,7 @@
</div> </div>
</div> </div>
<div class="sidebar"> <div class="sidebar">
<v-card v-if="!userStore.user" class="w-full" title="About"> <v-card v-if="!userStore.isAuthenticated" class="w-full" title="About">
<v-card-text> <v-card-text>
<p>Welcome to the <b>Solar Network</b></p> <p>Welcome to the <b>Solar Network</b></p>
<p>The open social network. Friendly to everyone.</p> <p>The open social network. Friendly to everyone.</p>
@@ -26,7 +26,7 @@
</p> </p>
</v-card-text> </v-card-text>
</v-card> </v-card>
<v-card v-else class="mt-4 w-full"> <v-card v-else class="w-full">
<v-card-text> <v-card-text>
<post-editor @posted="refreshActivities" /> <post-editor @posted="refreshActivities" />
</v-card-text> </v-card-text>
@@ -108,11 +108,12 @@ async function refreshActivities() {
.layout { .layout {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0 16px; gap: 16px;
} }
.main { .main {
order: 2; order: 2;
min-width: 0;
} }
.sidebar { .sidebar {

View File

@@ -1,16 +1,26 @@
import { defineStore } from 'pinia' import { defineStore } from "pinia"
import { ref } from 'vue' import { ref } from "vue"
import { useSolarNetwork } from '~/composables/useSolarNetwork' import { useSolarNetwork } from "~/composables/useSolarNetwork"
import type { SnPublisher } from '~/types/api' import type { SnPublisher } from "~/types/api"
export const usePubStore = defineStore('pub', () => { export const usePubStore = defineStore("pub", () => {
const publishers = ref<SnPublisher[]>([]) const publishers = ref<SnPublisher[]>([])
async function fetchPublishers() { async function fetchPublishers() {
const api = useSolarNetwork() const api = useSolarNetwork()
const resp = await api('/publishers') const resp = await api("/sphere/publishers")
publishers.value = resp as SnPublisher[] publishers.value = resp as SnPublisher[]
} }
const userStore = useUserStore()
watch(
userStore,
(value) => {
if (value.isAuthenticated) fetchPublishers()
},
{ deep: true, immediate: true }
)
return { publishers, fetchPublishers } return { publishers, fetchPublishers }
}) })

View File

@@ -1,46 +1,65 @@
import { defineStore } from 'pinia' import { defineStore } from "pinia"
import { ref, computed } from 'vue' import { ref, computed } from "vue"
import { useSolarNetwork } from '~/composables/useSolarNetwork' import { useSolarNetwork } from "~/composables/useSolarNetwork"
import type { SnAccount } from '~/types/api' import type { SnAccount } from "~/types/api"
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore("user", () => {
// State // State
const user = ref<SnAccount | null>(null) const user = ref<SnAccount | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
// The name is match with the remote one (set by server Set-Cookie)
const token = useCookie<string | null>("AuthToken", {
default: () => null,
path: "/",
maxAge: 60 * 60 * 24 * 365 * 10
}) // 10 years
// Getters // Getters
const isAuthenticated = computed(() => !!user.value) const isAuthenticated = computed(() => !!user.value && !!token.value)
// Call fetchUser immediately
fetchUser()
// Actions // Actions
async function fetchUser(reload = true) { async function fetchUser(reload = true) {
if (!reload && user.value) return // Skip fetching if already loaded and not forced to if (!reload && user.value) return // Skip fetching if already loaded and not forced to
isLoading.value = true isLoading.value = true
error.value = null error.value = null
const api = useSolarNetwork() const api = useSolarNetwork()
try { try {
const response = await api('/id/accounts/me') const response = await api("/id/accounts/me")
user.value = response as SnAccount user.value = response as SnAccount
console.log(`Logged in as ${user.value.name}`)
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'An error occurred' error.value = e instanceof Error ? e.message : "An error occurred"
user.value = null // Clear user data on error user.value = null // Clear user data on error
console.error('Failed to fetch user... ', e)
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
function setToken(newToken: string) {
token.value = newToken
}
function logout() { function logout() {
user.value = null user.value = null
localStorage.removeItem('authToken') token.value = null
} }
return { return {
user, user,
token,
isLoading, isLoading,
error, error,
isAuthenticated, isAuthenticated,
fetchUser, fetchUser,
setToken,
logout, logout,
} }
}) })

View File

@@ -14,6 +14,7 @@
"@nuxtjs/tailwindcss": "6.14.0", "@nuxtjs/tailwindcss": "6.14.0",
"@pinia/nuxt": "0.11.2", "@pinia/nuxt": "0.11.2",
"@vueuse/core": "^13.9.0", "@vueuse/core": "^13.9.0",
"blurhash": "^2.0.5",
"cfturnstile-vue3": "^2.0.0", "cfturnstile-vue3": "^2.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"luxon": "^3.7.2", "luxon": "^3.7.2",
@@ -707,6 +708,8 @@
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"blurhash": ["blurhash@2.0.5", "", {}, "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],

View File

@@ -6,7 +6,8 @@ export default withNuxt(
{ {
rules: { rules: {
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off' 'vue/no-v-html': 'off',
'vue/html-self-closing': 'off'
} }
} }
) )

View File

@@ -16,12 +16,21 @@ export default defineNuxtConfig({
features: { features: {
inlineStyles: false inlineStyles: false
}, },
image: {
domains: ["api.solian.app"]
},
runtimeConfig: { runtimeConfig: {
public: { public: {
development: process.env.NODE_ENV == "development",
apiBase: process.env.NUXT_PUBLIC_API_BASE || "https://api.solian.app" apiBase: process.env.NUXT_PUBLIC_API_BASE || "https://api.solian.app"
} }
}, },
image: { nitro: {
domains: ["api.solian.app"] devProxy: {
"/api": {
target: process.env.NUXT_PUBLIC_API_BASE || "https://api.solian.app",
changeOrigin: true
}
}
} }
}) })

View File

@@ -20,6 +20,7 @@
"@nuxtjs/tailwindcss": "6.14.0", "@nuxtjs/tailwindcss": "6.14.0",
"@pinia/nuxt": "0.11.2", "@pinia/nuxt": "0.11.2",
"@vueuse/core": "^13.9.0", "@vueuse/core": "^13.9.0",
"blurhash": "^2.0.5",
"cfturnstile-vue3": "^2.0.0", "cfturnstile-vue3": "^2.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"luxon": "^3.7.2", "luxon": "^3.7.2",

View File

@@ -1,12 +1,44 @@
import { defineVuetifyConfiguration } from "vuetify-nuxt-module/custom-configuration"; import { defineVuetifyConfiguration } from "vuetify-nuxt-module/custom-configuration"
import { md3 } from "vuetify/blueprints"; import { md3 } from "vuetify/blueprints"
export default defineVuetifyConfiguration({ export default defineVuetifyConfiguration({
blueprint: md3, blueprint: md3,
icons: { icons: {
defaultSet: 'mdi' defaultSet: "mdi"
},
theme: {
defaultTheme: "system",
themes: {
light: {
colors: {
background: "#f0f4fa",
surface: "#ffffff",
primary: "#3f51b5",
secondary: "#2196f3",
accent: "#009688",
error: "#f44336",
warning: "#ffc107",
info: "#03a9f4",
success: "#4caf50"
}
},
dark: {
dark: true,
colors: {
background: "#1e1f20",
surface: "#0e0e0e",
primary: "#3f51b5",
secondary: "#2196f3",
accent: "#009688",
error: "#f44336",
warning: "#ffc107",
info: "#03a9f4",
success: "#4caf50"
}
}
}
}, },
date: { date: {
adapter: 'luxon' adapter: "luxon"
}, }
}); })