✨ Proper user login
This commit is contained in:
@@ -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()
|
||||||
|
@@ -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>
|
||||||
|
@@ -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',
|
||||||
|
@@ -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 }}
|
||||||
|
@@ -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()
|
||||||
|
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
|
@@ -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>
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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 }
|
||||||
})
|
})
|
||||||
|
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
3
bun.lock
3
bun.lock
@@ -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=="],
|
||||||
|
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@@ -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",
|
||||||
|
@@ -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"
|
||||||
},
|
}
|
||||||
});
|
})
|
||||||
|
Reference in New Issue
Block a user