Compare commits
25 Commits
09aa144395
...
master
Author | SHA1 | Date | |
---|---|---|---|
888d3cb5ba
|
|||
f8ad92ccf2
|
|||
847fecc67b
|
|||
c9f90bdc33
|
|||
5c5b35b1b5
|
|||
0c36ff1bcd
|
|||
f95d6778c2
|
|||
744622addf
|
|||
2de1e12c33
|
|||
54e8ffea6f
|
|||
16a5207c02
|
|||
52971c2d67
|
|||
056010e8b6
|
|||
7e8cdb6348
|
|||
531a082d94
|
|||
42f1d42506
|
|||
8ce154eef2
|
|||
07ec5ffc55
|
|||
db6c023651
|
|||
eba829ebb9
|
|||
fcfb57f4a5
|
|||
e9de02b084
|
|||
dd6ff13228
|
|||
b4c105b43e
|
|||
38295124cb
|
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<nuxt-loading-indicator :color="colorMode.value == 'dark' ? 'white' : '#3f51b5'" />
|
||||||
<nuxt-layout>
|
<nuxt-layout>
|
||||||
<nuxt-page />
|
<nuxt-page />
|
||||||
</nuxt-layout>
|
</nuxt-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
</script>
|
||||||
|
@@ -18,4 +18,15 @@
|
|||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
|
background-color: rgba(var(--v-theme-background), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-enter-active,
|
||||||
|
.page-leave-active {
|
||||||
|
transition: all 0.4s;
|
||||||
|
}
|
||||||
|
.page-enter-from,
|
||||||
|
.page-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(1rem);
|
||||||
}
|
}
|
@@ -1,9 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="relative rounded-md overflow-hidden" :style="containerStyle">
|
||||||
v-if="itemType == 'image'"
|
<template v-if="itemType == 'image'">
|
||||||
class="relative rounded-md overflow-hidden"
|
|
||||||
:style="`width: 100%; max-height: 800px; aspect-ratio: ${aspectRatio}`"
|
|
||||||
>
|
|
||||||
<!-- Blurhash placeholder -->
|
<!-- Blurhash placeholder -->
|
||||||
<div
|
<div
|
||||||
v-if="blurhash"
|
v-if="blurhash"
|
||||||
@@ -20,14 +17,26 @@
|
|||||||
<!-- Main image -->
|
<!-- Main image -->
|
||||||
<img
|
<img
|
||||||
:src="remoteSource"
|
:src="remoteSource"
|
||||||
class="w-full h-auto rounded-md transition-opacity duration-500"
|
class="w-full h-auto rounded-md transition-opacity duration-500 object-cover cursor-pointer"
|
||||||
:class="{ 'opacity-0': !imageLoaded && blurhash }"
|
:class="{ 'opacity-0': !imageLoaded && blurhash }"
|
||||||
@load="imageLoaded = true"
|
@load="imageLoaded = true"
|
||||||
@error="imageLoaded = true"
|
@error="imageLoaded = true"
|
||||||
|
@click="openExternally"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<audio
|
||||||
|
v-else-if="itemType == 'audio'"
|
||||||
|
class="w-full h-auto"
|
||||||
|
:src="remoteSource"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="itemType == 'video'"
|
||||||
|
class="w-full h-auto"
|
||||||
|
:src="remoteSource"
|
||||||
|
controls
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<audio v-else-if="itemType == 'audio'" :src="remoteSource" controls />
|
|
||||||
<video v-else-if="itemType == 'video'" :src="remoteSource" controls />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@@ -35,7 +44,7 @@ import { computed, ref, onMounted, watch } from "vue"
|
|||||||
import { decode } from "blurhash"
|
import { decode } from "blurhash"
|
||||||
import type { SnAttachment } from "~/types/api"
|
import type { SnAttachment } from "~/types/api"
|
||||||
|
|
||||||
const props = defineProps<{ item: SnAttachment }>()
|
const props = defineProps<{ item: SnAttachment; maxHeight?: string }>()
|
||||||
|
|
||||||
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 blurhash = computed(() => props.item.fileMeta?.blur)
|
||||||
@@ -50,6 +59,10 @@ const aspectRatio = computed(
|
|||||||
)
|
)
|
||||||
const imageLoaded = ref(false)
|
const imageLoaded = ref(false)
|
||||||
|
|
||||||
|
function openExternally() {
|
||||||
|
window.open(remoteSource.value + "?original=true", "_blank")
|
||||||
|
}
|
||||||
|
|
||||||
const blurCanvas = ref<HTMLCanvasElement | null>(null)
|
const blurCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
const apiBase = useSolarNetworkUrl()
|
const apiBase = useSolarNetworkUrl()
|
||||||
@@ -61,6 +74,13 @@ const blurhashContainerStyle = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const containerStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
"max-height": props.maxHeight ?? "720px",
|
||||||
|
"aspect-ratio": aspectRatio.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const decodeBlurhash = () => {
|
const decodeBlurhash = () => {
|
||||||
if (!blurhash.value || !blurCanvas.value) return
|
if (!blurhash.value || !blurCanvas.value) return
|
||||||
|
|
||||||
|
153
app/components/OgImage/ImageCard.vue
Normal file
153
app/components/OgImage/ImageCard.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useOgImageRuntimeConfig } from "#og-image/app/utils"
|
||||||
|
import { useSiteConfig } from "#site-config/app/composables"
|
||||||
|
import { computed, defineComponent, h, resolveComponent } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
colorMode: { type: String, required: false },
|
||||||
|
title: { type: String, required: false, default: "title" },
|
||||||
|
description: { type: String, required: false },
|
||||||
|
icon: { type: [String, Boolean], required: false },
|
||||||
|
siteName: { type: String, required: false },
|
||||||
|
siteLogo: { type: String, required: false },
|
||||||
|
theme: { type: String, required: false, default: "#3f51b5" },
|
||||||
|
backgroundImage: { type: String, required: false },
|
||||||
|
avatarUrl: { type: String, required: false }
|
||||||
|
})
|
||||||
|
const HexRegex = /^#(?:[0-9a-f]{3}){1,2}$/i
|
||||||
|
const runtimeConfig = useOgImageRuntimeConfig()
|
||||||
|
const colorMode = computed(() => {
|
||||||
|
return props.colorMode || runtimeConfig.colorPreference || "light"
|
||||||
|
})
|
||||||
|
|
||||||
|
const themeHex = computed(() => {
|
||||||
|
if (HexRegex.test(props.theme)) return props.theme
|
||||||
|
if (HexRegex.test(`#${props.theme}`)) return `#${props.theme}`
|
||||||
|
if (props.theme.startsWith("rgb")) {
|
||||||
|
const rgb = props.theme
|
||||||
|
.replace("rgb(", "")
|
||||||
|
.replace("rgba(", "")
|
||||||
|
.replace(")", "")
|
||||||
|
.split(",")
|
||||||
|
.map((v) => Number.parseInt(v.trim(), 10))
|
||||||
|
const hex = rgb
|
||||||
|
.map((v) => {
|
||||||
|
const hex2 = v.toString(16)
|
||||||
|
return hex2.length === 1 ? `0${hex2}` : hex2
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
return `#${hex}`
|
||||||
|
}
|
||||||
|
return "#FFFFFF"
|
||||||
|
})
|
||||||
|
const themeRgb = computed(() => {
|
||||||
|
return themeHex.value
|
||||||
|
.replace("#", "")
|
||||||
|
.match(/.{1,2}/g)
|
||||||
|
?.map((v) => Number.parseInt(v, 16))
|
||||||
|
.join(", ")
|
||||||
|
})
|
||||||
|
const textShadow = computed(() => {
|
||||||
|
return '2px 2px 8px rgba(0,0,0,0.8)'
|
||||||
|
})
|
||||||
|
const siteConfig = useSiteConfig()
|
||||||
|
const siteName = computed(() => {
|
||||||
|
return props.siteName || siteConfig.name
|
||||||
|
})
|
||||||
|
const siteLogo = computed(() => {
|
||||||
|
return props.siteLogo || siteConfig.logo
|
||||||
|
})
|
||||||
|
const IconComponent = runtimeConfig.hasNuxtIcon
|
||||||
|
? resolveComponent("Icon")
|
||||||
|
: defineComponent({
|
||||||
|
render() {
|
||||||
|
return h("div", "missing @nuxt/icon")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
typeof props.icon === "string" &&
|
||||||
|
!runtimeConfig.hasNuxtIcon &&
|
||||||
|
process.dev
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"Please install `@nuxt/icon` to use icons with the fallback OG Image component."
|
||||||
|
)
|
||||||
|
console.log("\nnpx nuxi module add icon\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBaseServer = useSolarNetworkUrl(true)
|
||||||
|
|
||||||
|
function toAbsoluteUrl(url: string | undefined) {
|
||||||
|
if (!url) return undefined
|
||||||
|
if (url.startsWith("http"))
|
||||||
|
return `${siteConfig.url}/__og/convert-image?url=${encodeURIComponent(url)}`
|
||||||
|
if (url.startsWith("/api"))
|
||||||
|
return `${siteConfig.url}/__og/convert-image?url=${encodeURIComponent(
|
||||||
|
`${apiBaseServer}${url.replace("/api", "")}`
|
||||||
|
)}`
|
||||||
|
return `${siteConfig.url}${url}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex justify-between relative text-white"
|
||||||
|
:class="[
|
||||||
|
...(colorMode === 'light'
|
||||||
|
? ['bg-white']
|
||||||
|
: ['bg-gray-900'])
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="backgroundImage"
|
||||||
|
class="absolute inset-0 w-full h-full"
|
||||||
|
:class="colorMode === 'light' ? 'bg-white/80' : 'bg-gray-900/80'"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="backgroundImage"
|
||||||
|
:src="toAbsoluteUrl(backgroundImage)"
|
||||||
|
class="absolute top-0 left-0 w-full h-full object-cover"
|
||||||
|
style="min-width: 1200px; min-height: 600px; filter: blur(8px)"
|
||||||
|
/>
|
||||||
|
<div class="h-full w-full justify-between relative p-[60px]">
|
||||||
|
<div class="flex flex-row justify-between items-start">
|
||||||
|
<div class="flex flex-col w-full max-w-[65%]">
|
||||||
|
<h1
|
||||||
|
class="m-0 font-bold mb-[30px] text-[75px]"
|
||||||
|
style="display: block; text-overflow: ellipsis"
|
||||||
|
:style="{ lineClamp: description ? 2 : 3, textShadow }"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
v-if="description"
|
||||||
|
class="text-[35px] leading-12 text-white"
|
||||||
|
style="display: block; line-clamp: 3; text-overflow: ellipsis"
|
||||||
|
:style="{ textShadow }"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="Boolean(icon)" style="width: 30%" class="flex justify-end">
|
||||||
|
<IconComponent
|
||||||
|
:name="icon"
|
||||||
|
size="250px"
|
||||||
|
style="margin: 0 auto; opacity: 0.7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row justify-end items-center text-right gap-3 w-full">
|
||||||
|
<p v-if="siteName" style="font-size: 25px" class="font-bold" :style="{ textShadow }">
|
||||||
|
{{ siteName }}
|
||||||
|
</p>
|
||||||
|
<img
|
||||||
|
v-if="avatarUrl"
|
||||||
|
:src="toAbsoluteUrl(avatarUrl)"
|
||||||
|
height="60"
|
||||||
|
width="60"
|
||||||
|
class="rounded-full mr-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
161
app/components/OgImage/NuxtSeo.vue
Normal file
161
app/components/OgImage/NuxtSeo.vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useOgImageRuntimeConfig } from "#og-image/app/utils"
|
||||||
|
import { useSiteConfig } from "#site-config/app/composables"
|
||||||
|
import { computed, defineComponent, h, resolveComponent } from "vue"
|
||||||
|
const props = defineProps({
|
||||||
|
colorMode: { type: String, required: false },
|
||||||
|
title: { type: String, required: false, default: "title" },
|
||||||
|
description: { type: String, required: false },
|
||||||
|
icon: { type: [String, Boolean], required: false },
|
||||||
|
siteName: { type: String, required: false },
|
||||||
|
siteLogo: { type: String, required: false },
|
||||||
|
theme: { type: String, required: false, default: "#3f51b5" },
|
||||||
|
backgroundImage: { type: String, required: false }
|
||||||
|
})
|
||||||
|
const HexRegex = /^#(?:[0-9a-f]{3}){1,2}$/i
|
||||||
|
const runtimeConfig = useOgImageRuntimeConfig()
|
||||||
|
const colorMode = computed(() => {
|
||||||
|
return props.colorMode || runtimeConfig.colorPreference || "light"
|
||||||
|
})
|
||||||
|
const themeHex = computed(() => {
|
||||||
|
if (HexRegex.test(props.theme)) return props.theme
|
||||||
|
if (HexRegex.test(`#${props.theme}`)) return `#${props.theme}`
|
||||||
|
if (props.theme.startsWith("rgb")) {
|
||||||
|
const rgb = props.theme
|
||||||
|
.replace("rgb(", "")
|
||||||
|
.replace("rgba(", "")
|
||||||
|
.replace(")", "")
|
||||||
|
.split(",")
|
||||||
|
.map((v) => Number.parseInt(v.trim(), 10))
|
||||||
|
const hex = rgb
|
||||||
|
.map((v) => {
|
||||||
|
const hex2 = v.toString(16)
|
||||||
|
return hex2.length === 1 ? `0${hex2}` : hex2
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
return `#${hex}`
|
||||||
|
}
|
||||||
|
return "#FFFFFF"
|
||||||
|
})
|
||||||
|
const themeRgb = computed(() => {
|
||||||
|
return themeHex.value
|
||||||
|
.replace("#", "")
|
||||||
|
.match(/.{1,2}/g)
|
||||||
|
?.map((v) => Number.parseInt(v, 16))
|
||||||
|
.join(", ")
|
||||||
|
})
|
||||||
|
const siteConfig = useSiteConfig()
|
||||||
|
|
||||||
|
const siteName = computed(() => {
|
||||||
|
return props.siteName || siteConfig.name
|
||||||
|
})
|
||||||
|
const siteLogo = computed(() => {
|
||||||
|
return props.siteLogo || siteConfig.logo
|
||||||
|
})
|
||||||
|
const IconComponent = runtimeConfig.hasNuxtIcon
|
||||||
|
? resolveComponent("Icon")
|
||||||
|
: defineComponent({
|
||||||
|
render() {
|
||||||
|
return h("div", "missing @nuxt/icon")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
typeof props.icon === "string" &&
|
||||||
|
!runtimeConfig.hasNuxtIcon &&
|
||||||
|
process.dev
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"Please install `@nuxt/icon` to use icons with the fallback OG Image component."
|
||||||
|
)
|
||||||
|
console.log("\nnpx nuxi module add icon\n")
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex justify-between relative p-[60px]"
|
||||||
|
:class="[
|
||||||
|
colorMode === 'light'
|
||||||
|
? ['bg-white', 'text-gray-900']
|
||||||
|
: ['bg-gray-900', 'text-white']
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="backgroundImage"
|
||||||
|
class="absolute inset-0 w-full h-full bg-cover bg-center"
|
||||||
|
:style="{ backgroundImage: `url(${backgroundImage})` }"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
v-if="backgroundImage"
|
||||||
|
class="absolute inset-0 w-full h-full"
|
||||||
|
:class="colorMode === 'light' ? 'bg-white/80' : 'bg-gray-900/80'"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex absolute top-0 right-[-100%]"
|
||||||
|
:style="{
|
||||||
|
width: '200%',
|
||||||
|
height: '200%',
|
||||||
|
backgroundImage: `radial-gradient(circle, rgba(${themeRgb}, 0.5) 0%, ${
|
||||||
|
colorMode === 'dark'
|
||||||
|
? 'rgba(5, 5, 5,0.3)'
|
||||||
|
: 'rgba(255, 255, 255, 0.7)'
|
||||||
|
} 50%, ${
|
||||||
|
props.colorMode === 'dark'
|
||||||
|
? 'rgba(5, 5, 5,0)'
|
||||||
|
: 'rgba(255, 255, 255, 0)'
|
||||||
|
} 70%)`
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div class="h-full w-full justify-between relative">
|
||||||
|
<div class="flex flex-row justify-between items-start">
|
||||||
|
<div class="flex flex-col w-full max-w-[65%]">
|
||||||
|
<h1
|
||||||
|
class="m-0 font-bold mb-[30px] text-[75px]"
|
||||||
|
style="display: block; text-overflow: ellipsis"
|
||||||
|
:style="{ lineClamp: description ? 2 : 3 }"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
v-if="description"
|
||||||
|
class="text-[35px] leading-12"
|
||||||
|
:class="[
|
||||||
|
colorMode === 'light' ? ['text-gray-700'] : ['text-gray-300']
|
||||||
|
]"
|
||||||
|
style="display: block; line-clamp: 3; text-overflow: ellipsis"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="Boolean(icon)" style="width: 30%" class="flex justify-end">
|
||||||
|
<IconComponent
|
||||||
|
:name="icon"
|
||||||
|
size="250px"
|
||||||
|
style="margin: 0 auto; opacity: 0.7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row justify-center items-center text-left w-full">
|
||||||
|
<img v-if="siteLogo" :src="siteLogo" height="30" width="30" />
|
||||||
|
<template v-else>
|
||||||
|
<svg
|
||||||
|
height="50"
|
||||||
|
width="50"
|
||||||
|
class="mr-3"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
:fill="theme.includes('#') ? theme : `#${theme}`"
|
||||||
|
d="M62.3,-53.9C74.4,-34.5,73.5,-9,67.1,13.8C60.6,36.5,48.7,56.5,30.7,66.1C12.7,75.7,-11.4,74.8,-31.6,65.2C-51.8,55.7,-67.9,37.4,-73.8,15.7C-79.6,-6,-75.1,-31.2,-61.1,-51C-47.1,-70.9,-23.6,-85.4,0.8,-86C25.1,-86.7,50.2,-73.4,62.3,-53.9Z"
|
||||||
|
transform="translate(100 100)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p v-if="siteName" style="font-size: 25px" class="font-bold">
|
||||||
|
{{ siteName }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
@@ -7,29 +7,7 @@
|
|||||||
@keydown.meta.enter.exact="submit"
|
@keydown.meta.enter.exact="submit"
|
||||||
@keydown.ctrl.enter.exact="submit"
|
@keydown.ctrl.enter.exact="submit"
|
||||||
/>
|
/>
|
||||||
<div v-if="fileList.length > 0" class="d-flex gap-2 flex-wrap">
|
|
||||||
<v-img
|
|
||||||
v-for="file in fileList"
|
|
||||||
:key="file.name"
|
|
||||||
:src="file.url"
|
|
||||||
width="100"
|
|
||||||
height="100"
|
|
||||||
class="rounded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<div class="flex gap-2">
|
|
||||||
<v-file-input
|
|
||||||
v-model="selectedFiles"
|
|
||||||
multiple
|
|
||||||
accept="image/*,video/*,audio/*"
|
|
||||||
label="Upload files"
|
|
||||||
prepend-icon="mdi-upload"
|
|
||||||
hide-details
|
|
||||||
density="compact"
|
|
||||||
@change="handleFileSelect"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<v-btn type="primary" :loading="submitting" @click="submit">
|
<v-btn type="primary" :loading="submitting" @click="submit">
|
||||||
Post
|
Post
|
||||||
<template #append>
|
<template #append>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card>
|
<v-card class="px-4 py-3">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<post-header :item="props.item" />
|
<post-header :item="props.item" />
|
||||||
@@ -15,18 +15,33 @@
|
|||||||
|
|
||||||
<article
|
<article
|
||||||
v-if="htmlContent"
|
v-if="htmlContent"
|
||||||
class="prose prose-sm dark:prose-invert prose-slate prose-p:m-0"
|
class="prose prose-sm dark:prose-invert prose-slate prose-p:m-0 max-w-none"
|
||||||
>
|
>
|
||||||
<div v-html="htmlContent" />
|
<div v-html="htmlContent" />
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div v-if="props.item.attachments" class="d-flex gap-2 flex-wrap">
|
<div
|
||||||
|
v-if="props.item.attachments.length > 0"
|
||||||
|
class="d-flex gap-2 flex-wrap"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
<attachment-item
|
<attachment-item
|
||||||
v-for="attachment in props.item.attachments"
|
v-for="attachment in props.item.attachments"
|
||||||
:key="attachment.id"
|
:key="attachment.id"
|
||||||
:item="attachment"
|
:item="attachment"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Reactions -->
|
||||||
|
<div @click.stop>
|
||||||
|
<post-reaction-list
|
||||||
|
:parent-id="props.item.id"
|
||||||
|
:reactions="props.item.reactionsCount"
|
||||||
|
:reactions-made="props.item.reactionsMade"
|
||||||
|
:can-react="true"
|
||||||
|
@react="handleReaction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -34,23 +49,65 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, watch } from "vue"
|
import { ref, watch } from "vue"
|
||||||
import { Marked } from "marked"
|
import { unified } from "unified"
|
||||||
|
import remarkParse from "remark-parse"
|
||||||
|
import remarkMath from "remark-math"
|
||||||
|
import remarkRehype from "remark-rehype"
|
||||||
|
import remarkBreaks from "remark-breaks"
|
||||||
|
import remarkGfm from "remark-gfm"
|
||||||
|
import rehypeKatex from "rehype-katex"
|
||||||
|
import rehypeStringify from "rehype-stringify"
|
||||||
import type { SnPost } from "~/types/api"
|
import type { SnPost } from "~/types/api"
|
||||||
|
|
||||||
import PostHeader from "./PostHeader.vue"
|
import PostHeader from "./PostHeader.vue"
|
||||||
import AttachmentItem from "./AttachmentItem.vue"
|
import AttachmentItem from "./AttachmentItem.vue"
|
||||||
|
import PostReactionList from "./PostReactionList.vue"
|
||||||
|
|
||||||
const props = defineProps<{ item: SnPost }>()
|
const props = defineProps<{ item: SnPost }>()
|
||||||
|
|
||||||
const marked = new Marked()
|
const processor = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkMath)
|
||||||
|
.use(remarkBreaks)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkRehype)
|
||||||
|
.use(rehypeKatex)
|
||||||
|
.use(rehypeStringify)
|
||||||
|
|
||||||
const htmlContent = ref<string>("")
|
const htmlContent = ref<string>("")
|
||||||
|
|
||||||
|
function handleReaction(symbol: string, attitude: number, delta: number) {
|
||||||
|
// Update the local item data
|
||||||
|
if (!props.item) return
|
||||||
|
|
||||||
|
const reactions = (props.item as any).reactions || {}
|
||||||
|
const currentCount = reactions[symbol] || 0
|
||||||
|
const newCount = Math.max(0, currentCount + delta)
|
||||||
|
|
||||||
|
if (newCount === 0) {
|
||||||
|
delete reactions[symbol]
|
||||||
|
} else {
|
||||||
|
reactions[symbol] = newCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the reactionsMade status
|
||||||
|
const reactionsMade = (props.item as any).reactionsMade || {}
|
||||||
|
if (delta > 0) {
|
||||||
|
reactionsMade[symbol] = true
|
||||||
|
} else {
|
||||||
|
delete reactionsMade[symbol]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the item object (this will trigger reactivity)
|
||||||
|
;(props.item as any).reactions = { ...reactions }
|
||||||
|
;(props.item as any).reactionsMade = { ...reactionsMade }
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
props.item,
|
props.item,
|
||||||
async (value) => {
|
(value) => {
|
||||||
if (value.content)
|
if (value.content)
|
||||||
htmlContent.value = await marked.parse(value.content, { breaks: true })
|
htmlContent.value = String(processor.processSync(value.content))
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
)
|
)
|
||||||
|
349
app/components/PostReactionList.vue
Normal file
349
app/components/PostReactionList.vue
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<template>
|
||||||
|
<v-chip-group class="d-flex flex-wrap gap-2">
|
||||||
|
<!-- Add Reaction Button -->
|
||||||
|
<v-chip
|
||||||
|
v-if="canReact"
|
||||||
|
color="primary"
|
||||||
|
rounded
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="showReactionDialog"
|
||||||
|
>
|
||||||
|
<v-icon start size="16">mdi-plus</v-icon>
|
||||||
|
<span class="text-caption">React</span>
|
||||||
|
</v-chip>
|
||||||
|
|
||||||
|
<!-- Existing Reactions -->
|
||||||
|
<v-chip
|
||||||
|
v-for="(count, symbol) in reactions"
|
||||||
|
rounded
|
||||||
|
:key="symbol"
|
||||||
|
:color="getReactionColor(symbol)"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="reactToPost(symbol)"
|
||||||
|
>
|
||||||
|
<span class="reaction-emoji">{{ getReactionEmoji(symbol) }}</span>
|
||||||
|
<span class="reaction-symbol">{{ symbol }}</span>
|
||||||
|
<v-chip size="x-small" variant="flat" class="reaction-count ms-1">
|
||||||
|
{{ count }}
|
||||||
|
</v-chip>
|
||||||
|
</v-chip>
|
||||||
|
</v-chip-group>
|
||||||
|
|
||||||
|
<!-- Reaction Selection Dialog -->
|
||||||
|
<v-dialog v-model="reactionDialog" max-width="500" height="600">
|
||||||
|
<v-card prepend-icon="mdi-emoticon-outline" title="React Post">
|
||||||
|
<!-- Dialog Content -->
|
||||||
|
<div class="dialog-content">
|
||||||
|
<!-- Positive Reactions -->
|
||||||
|
<div class="reaction-section">
|
||||||
|
<div class="section-header d-flex align-center px-6 py-3">
|
||||||
|
<v-icon class="me-2">mdi-emoticon-happy</v-icon>
|
||||||
|
<span class="text-subtitle-1 font-weight-bold">Positive</span>
|
||||||
|
</div>
|
||||||
|
<div class="reaction-grid">
|
||||||
|
<v-card
|
||||||
|
v-for="reaction in getReactionsByAttitude(0)"
|
||||||
|
:key="reaction.symbol"
|
||||||
|
class="reaction-card mx-2"
|
||||||
|
:class="{ selected: isReactionMade(reaction.symbol) }"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="selectReaction(reaction.symbol)"
|
||||||
|
>
|
||||||
|
<div class="d-flex flex-column align-center justify-center pa-3">
|
||||||
|
<span class="text-h4 mb-1">{{ reaction.emoji }}</span>
|
||||||
|
<span class="text-caption text-center mb-1">{{
|
||||||
|
reaction.symbol
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
v-if="getReactionCount(reaction.symbol) > 0"
|
||||||
|
class="text-caption font-weight-bold"
|
||||||
|
>
|
||||||
|
x{{ getReactionCount(reaction.symbol) }}
|
||||||
|
</span>
|
||||||
|
<div v-else class="spacer"></div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Neutral Reactions -->
|
||||||
|
<div class="reaction-section">
|
||||||
|
<div class="section-header d-flex align-center px-6 py-3">
|
||||||
|
<v-icon class="me-2">mdi-emoticon-neutral</v-icon>
|
||||||
|
<span class="text-subtitle-1 font-weight-bold">Neutral</span>
|
||||||
|
</div>
|
||||||
|
<div class="reaction-grid">
|
||||||
|
<v-card
|
||||||
|
v-for="reaction in getReactionsByAttitude(1)"
|
||||||
|
:key="reaction.symbol"
|
||||||
|
class="reaction-card mx-2"
|
||||||
|
:class="{ selected: isReactionMade(reaction.symbol) }"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="selectReaction(reaction.symbol)"
|
||||||
|
>
|
||||||
|
<div class="d-flex flex-column align-center justify-center pa-3">
|
||||||
|
<span class="text-h4 mb-1">{{ reaction.emoji }}</span>
|
||||||
|
<span class="text-caption text-center mb-1">{{
|
||||||
|
reaction.symbol
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
v-if="getReactionCount(reaction.symbol) > 0"
|
||||||
|
class="text-caption font-weight-bold"
|
||||||
|
>
|
||||||
|
x{{ getReactionCount(reaction.symbol) }}
|
||||||
|
</span>
|
||||||
|
<div v-else class="spacer"></div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Negative Reactions -->
|
||||||
|
<div class="reaction-section">
|
||||||
|
<div class="section-header d-flex align-center px-6 py-3">
|
||||||
|
<v-icon class="me-2">mdi-emoticon-sad</v-icon>
|
||||||
|
<span class="text-subtitle-1 font-weight-bold">Negative</span>
|
||||||
|
</div>
|
||||||
|
<div class="reaction-grid">
|
||||||
|
<v-card
|
||||||
|
v-for="reaction in getReactionsByAttitude(2)"
|
||||||
|
:key="reaction.symbol"
|
||||||
|
class="reaction-card mx-2"
|
||||||
|
:class="{ selected: isReactionMade(reaction.symbol) }"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="selectReaction(reaction.symbol)"
|
||||||
|
>
|
||||||
|
<div class="d-flex flex-column align-center justify-center pa-3">
|
||||||
|
<span class="text-h4 mb-1">{{ reaction.emoji }}</span>
|
||||||
|
<span class="text-caption text-center mb-1">{{
|
||||||
|
reaction.symbol
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
v-if="getReactionCount(reaction.symbol) > 0"
|
||||||
|
class="text-caption font-weight-bold"
|
||||||
|
>
|
||||||
|
x{{ getReactionCount(reaction.symbol) }}
|
||||||
|
</span>
|
||||||
|
<div v-else class="spacer"></div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
parentId: string
|
||||||
|
reactions?: Record<string, number>
|
||||||
|
reactionsMade?: Record<string, boolean>
|
||||||
|
canReact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReactionTemplate {
|
||||||
|
symbol: string
|
||||||
|
emoji: string
|
||||||
|
attitude: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
reactions: () => ({}),
|
||||||
|
reactionsMade: () => ({}),
|
||||||
|
canReact: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
react: [symbol: string, attitude: number, delta: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const reactionDialog = ref(false)
|
||||||
|
|
||||||
|
// Available reaction templates
|
||||||
|
const availableReactions: ReactionTemplate[] = [
|
||||||
|
{ symbol: "thumb_up", emoji: "👍", attitude: 0 },
|
||||||
|
{ symbol: "thumb_down", emoji: "👎", attitude: 2 },
|
||||||
|
{ symbol: "just_okay", emoji: "😅", attitude: 1 },
|
||||||
|
{ symbol: "cry", emoji: "😭", attitude: 1 },
|
||||||
|
{ symbol: "confuse", emoji: "🧐", attitude: 1 },
|
||||||
|
{ symbol: "clap", emoji: "👏", attitude: 0 },
|
||||||
|
{ symbol: "laugh", emoji: "😂", attitude: 0 },
|
||||||
|
{ symbol: "angry", emoji: "😡", attitude: 2 },
|
||||||
|
{ symbol: "party", emoji: "🎉", attitude: 0 },
|
||||||
|
{ symbol: "pray", emoji: "🙏", attitude: 0 },
|
||||||
|
{ symbol: "heart", emoji: "❤️", attitude: 0 }
|
||||||
|
]
|
||||||
|
|
||||||
|
function camelToSnake(str: string): string {
|
||||||
|
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReactionEmoji(symbol: string): string {
|
||||||
|
let reaction = availableReactions.find((r) => r.symbol === symbol)
|
||||||
|
if (reaction) return reaction.emoji
|
||||||
|
|
||||||
|
// Try camelCase to snake_case conversion
|
||||||
|
const snakeSymbol = camelToSnake(symbol)
|
||||||
|
reaction = availableReactions.find((r) => r.symbol === snakeSymbol)
|
||||||
|
return reaction?.emoji || "❓"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReactionColor(symbol: string): string {
|
||||||
|
const attitude =
|
||||||
|
availableReactions.find((r) => r.symbol === symbol)?.attitude || 1
|
||||||
|
if (attitude === 0) return "success"
|
||||||
|
if (attitude === 2) return "error"
|
||||||
|
return "primary"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reactToPost(symbol: string) {
|
||||||
|
if (submitting.value) return
|
||||||
|
|
||||||
|
const reaction = availableReactions.find((r) => r.symbol === symbol)
|
||||||
|
if (!reaction) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
submitting.value = true
|
||||||
|
const api = useSolarNetwork()
|
||||||
|
const response = await api(`/sphere/posts/${props.parentId}/reactions`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
symbol: symbol,
|
||||||
|
attitude: reaction.attitude
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if we're removing the reaction (204 status) or adding (200)
|
||||||
|
// In Nuxt, we can check the response status through the fetch response
|
||||||
|
const isRemoving =
|
||||||
|
response && typeof response === "object" && "status" in response
|
||||||
|
? (response as any).status === 204
|
||||||
|
: false
|
||||||
|
const delta = isRemoving ? -1 : 1
|
||||||
|
|
||||||
|
emit("react", symbol, reaction.attitude, delta)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to react to post:", error)
|
||||||
|
// You might want to show a toast notification here
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReactionDialog() {
|
||||||
|
reactionDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectReaction(symbol: string) {
|
||||||
|
reactionDialog.value = false
|
||||||
|
reactToPost(symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed properties and helper functions
|
||||||
|
const totalReactionsCount = computed(() => {
|
||||||
|
return Object.values(props.reactions || {}).reduce(
|
||||||
|
(sum, count) => sum + count,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getReactionsByAttitude(attitude: number): ReactionTemplate[] {
|
||||||
|
return availableReactions.filter((reaction) => reaction.attitude === attitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReactionMade(symbol: string): boolean {
|
||||||
|
return (props.reactionsMade || {})[symbol] || false
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReactionCount(symbol: string): number {
|
||||||
|
return (props.reactions || {})[symbol] || 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.post-reaction-list {
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-chip {
|
||||||
|
height: 28px !important;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-emoji {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-symbol {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-count {
|
||||||
|
height: 16px !important;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog Styles */
|
||||||
|
.reaction-dialog {
|
||||||
|
height: 600px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(600px - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
background-color: rgba(var(--v-theme-surface-variant), 0.5);
|
||||||
|
border-bottom: 1px solid rgb(var(--v-theme-outline-variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-grid {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-grid::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-card {
|
||||||
|
min-width: 80px;
|
||||||
|
height: 100px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-card:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-card.selected {
|
||||||
|
border-color: rgb(var(--v-theme-primary));
|
||||||
|
background-color: rgb(var(--v-theme-primary-container));
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
31
app/components/SidebarFooter.vue
Normal file
31
app/components/SidebarFooter.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col text-xs opacity-80 mx-3 mt-1">
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span class="font-bold">The Solar Network</span>
|
||||||
|
<span class="font-bold">·</span>
|
||||||
|
<span>FloatingIsland</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<a class="link" target="_blank" href="https://solsynth.dev/terms">
|
||||||
|
Terms of Services
|
||||||
|
</a>
|
||||||
|
<span class="font-bold">·</span>
|
||||||
|
<a class="link" target="_blank" href="https://status.solsynth.dev">
|
||||||
|
Service Status
|
||||||
|
</a>
|
||||||
|
<span class="font-bold">·</span>
|
||||||
|
<nuxt-link class="link" target="_blank" to="/swagger"> API </nuxt-link>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 opacity-80">
|
||||||
|
The FloatingIsland do not provides all the features the Solar Network has,
|
||||||
|
for further usage, see
|
||||||
|
<a href="https://web.solian.app" class="font-bold underline">Solian</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
@@ -1,22 +0,0 @@
|
|||||||
import { useDark, useToggle } from "@vueuse/core"
|
|
||||||
|
|
||||||
// composables/useCustomTheme.ts
|
|
||||||
export function useCustomTheme(): {
|
|
||||||
isDark: WritableComputedRef<boolean, boolean>
|
|
||||||
toggle: (value?: boolean | undefined) => boolean,
|
|
||||||
} {
|
|
||||||
const { $vuetify } = useNuxtApp()
|
|
||||||
|
|
||||||
const isDark = useDark({
|
|
||||||
valueDark: "dark",
|
|
||||||
valueLight: "light",
|
|
||||||
initialValue: "light",
|
|
||||||
onChanged: (dark: boolean) => {
|
|
||||||
$vuetify.theme.change(dark ? "dark" : "light")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggle = useToggle(isDark)
|
|
||||||
|
|
||||||
return { isDark, toggle }
|
|
||||||
}
|
|
@@ -1,19 +1,22 @@
|
|||||||
// 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 = (withoutProxy = false) => {
|
||||||
const apiBase = useSolarNetworkUrl()
|
const apiBase = useSolarNetworkUrl(withoutProxy)
|
||||||
|
|
||||||
return $fetch.create({
|
return $fetch.create({
|
||||||
baseURL: apiBase,
|
baseURL: apiBase,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
// Add Authorization header with Bearer token
|
// Add Authorization header with Bearer token
|
||||||
onRequest: ({ options }) => {
|
onRequest: ({ request, options }) => {
|
||||||
|
const side = process.server ? 'SERVER' : 'CLIENT'
|
||||||
|
console.log(`[useSolarNetwork] onRequest for ${request} on ${side}`)
|
||||||
// Get token from user store
|
// Get token from user store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const token = userStore.token
|
const token = userStore.token
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
|
console.log('[useSolarNetwork] Token found, adding Authorization header.')
|
||||||
if (!options.headers) {
|
if (!options.headers) {
|
||||||
options.headers = new Headers()
|
options.headers = new Headers()
|
||||||
}
|
}
|
||||||
@@ -23,6 +26,8 @@ export const useSolarNetwork = () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
;(options.headers as any)["Authorization"] = `Bearer ${token}`
|
;(options.headers as any)["Authorization"] = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[useSolarNetwork] No token found, skipping Authorization header.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform request data from camelCase to snake_case
|
// Transform request data from camelCase to snake_case
|
||||||
|
@@ -1,7 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app :theme="isDark ? 'dark' : 'light'">
|
<v-app :theme="colorMode.preference">
|
||||||
<v-app-bar flat class="app-bar-blur">
|
<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">
|
||||||
|
<img
|
||||||
|
:src="colorMode.value == 'dark' ? IconDark : IconLight"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
class="me-4"
|
||||||
|
alt="The Solar Network"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
v-for="link in links"
|
v-for="link in links"
|
||||||
:key="link.title"
|
:key="link.title"
|
||||||
@@ -13,13 +21,39 @@
|
|||||||
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|
||||||
|
<v-menu>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
<v-avatar
|
<v-avatar
|
||||||
|
v-bind="props"
|
||||||
class="me-4"
|
class="me-4"
|
||||||
color="grey-darken-1"
|
color="grey-darken-1"
|
||||||
size="32"
|
size="32"
|
||||||
icon="mdi-account"
|
icon="mdi-account-circle-outline"
|
||||||
:image="`${apiBase}/drive/files/${user?.profile.picture?.id}`"
|
:image="
|
||||||
|
user?.profile.picture
|
||||||
|
? `${apiBase}/drive/files/${user?.profile.picture?.id}`
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item v-if="!user" to="/auth/login" prepend-icon="mdi-login"
|
||||||
|
>Login</v-list-item
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
v-if="!user"
|
||||||
|
to="/auth/create-account"
|
||||||
|
prepend-icon="mdi-account-plus"
|
||||||
|
>Create Account</v-list-item
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
v-if="user"
|
||||||
|
to="/accounts/me"
|
||||||
|
prepend-icon="mdi-view-dashboard"
|
||||||
|
>Dashboard</v-list-item
|
||||||
|
>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
|
|
||||||
@@ -30,13 +64,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useCustomTheme } from "~/composables/useCustomTheme"
|
import IconLight from "~/assets/images/cloudy-lamb.png"
|
||||||
|
import IconDark from "~/assets/images/cloudy-lamb@dark.png"
|
||||||
|
|
||||||
import type { NavLink } from "~/types/navlink"
|
import type { NavLink } from "~/types/navlink"
|
||||||
|
|
||||||
const apiBase = useSolarNetworkUrl()
|
const apiBase = useSolarNetworkUrl()
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
const { user } = useUserStore()
|
const { user } = useUserStore()
|
||||||
const { isDark } = useCustomTheme()
|
|
||||||
|
|
||||||
const links: NavLink[] = [
|
const links: NavLink[] = [
|
||||||
{
|
{
|
||||||
|
@@ -1,19 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app :theme="isDark ? 'dark' : 'light'">
|
<v-app :theme="colorMode.preference">
|
||||||
<v-app-bar flat height="48">
|
|
||||||
<v-container class="mx-auto d-flex align-center justify-center">
|
|
||||||
<p class="text-sm">Solar Network</p>
|
|
||||||
</v-container>
|
|
||||||
</v-app-bar>
|
|
||||||
|
|
||||||
<v-main>
|
<v-main>
|
||||||
<slot />
|
<slot />
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
|
<nuxt-link to="/">
|
||||||
|
<v-footer app fixed flat height="48">
|
||||||
|
<v-container class="mx-auto d-flex align-center justify-between">
|
||||||
|
<img
|
||||||
|
:src="Icon"
|
||||||
|
alt="Cloudy Lamb"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
<p class="text-sm">Solar Network</p>
|
||||||
|
</v-container>
|
||||||
|
</v-footer>
|
||||||
|
</nuxt-link>
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useCustomTheme } from "~/composables/useCustomTheme"
|
import Icon from "~/assets/images/cloudy-lamb.png"
|
||||||
|
|
||||||
const { isDark } = useCustomTheme()
|
const colorMode = useColorMode()
|
||||||
</script>
|
</script>
|
||||||
|
@@ -156,7 +156,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<v-card v-if="htmlBio" title="Bio" prepend-icon="mdi-pencil">
|
<v-card v-if="htmlBio" title="Bio" prepend-icon="mdi-pencil">
|
||||||
<v-card-text class="px-8">
|
<v-card-text>
|
||||||
<article
|
<article
|
||||||
class="bio-prose prose prose-sm dark:prose-invert prose-slate"
|
class="bio-prose prose prose-sm dark:prose-invert prose-slate"
|
||||||
v-html="htmlBio"
|
v-html="htmlBio"
|
||||||
@@ -181,7 +181,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
import { Marked } from "marked"
|
import { unified } from "unified"
|
||||||
|
import remarkParse from "remark-parse"
|
||||||
|
import remarkMath from "remark-math"
|
||||||
|
import remarkBreaks from "remark-breaks"
|
||||||
|
import remarkRehype from "remark-rehype"
|
||||||
|
import rehypeKatex from "rehype-katex"
|
||||||
|
import rehypeStringify from "rehype-stringify"
|
||||||
|
import remarkGfm from "remark-gfm"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -239,15 +246,22 @@ const perkSubscriptionNames: Record<string, PerkSubscriptionInfo> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const marked = new Marked()
|
const processor = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkMath)
|
||||||
|
.use(remarkBreaks)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkRehype)
|
||||||
|
.use(rehypeKatex)
|
||||||
|
.use(rehypeStringify)
|
||||||
|
|
||||||
const htmlBio = ref<string | undefined>(undefined)
|
const htmlBio = ref<string | undefined>(undefined)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
user,
|
user,
|
||||||
async (value) => {
|
(value) => {
|
||||||
htmlBio.value = value?.profile.bio
|
htmlBio.value = value?.profile.bio
|
||||||
? await marked.parse(value.profile.bio, { breaks: true })
|
? String(processor.processSync(value.profile.bio))
|
||||||
: undefined
|
: undefined
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
@@ -301,7 +315,44 @@ function getOffsetUTCString(targetTimeZone: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
alias: ["/@[name]"]
|
alias: ["/@:name()"]
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: computed(() => {
|
||||||
|
if (notFound.value) {
|
||||||
|
return "User not found"
|
||||||
|
}
|
||||||
|
if (user.value) {
|
||||||
|
return user.value.nick || user.value.name
|
||||||
|
}
|
||||||
|
return "Loading user..."
|
||||||
|
}),
|
||||||
|
meta: computed(() => {
|
||||||
|
if (user.value) {
|
||||||
|
const description = `View the profile of ${
|
||||||
|
user.value.nick || user.value.name
|
||||||
|
} on Solar Network.`
|
||||||
|
return [{ name: "description", content: description }]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
defineOgImage({
|
||||||
|
component: "ImageCard",
|
||||||
|
title: computed(() =>
|
||||||
|
user.value ? user.value.nick || user.value.name : "User Profile"
|
||||||
|
),
|
||||||
|
description: computed(() =>
|
||||||
|
user.value
|
||||||
|
? `View the profile of ${
|
||||||
|
user.value.nick || user.value.name
|
||||||
|
} on Solar Network.`
|
||||||
|
: ""
|
||||||
|
),
|
||||||
|
avatarUrl: computed(() => userPicture.value),
|
||||||
|
backgroundImage: computed(() => userBackground.value)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@@ -7,7 +7,7 @@
|
|||||||
<div class="pa-8">
|
<div class="pa-8">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<img
|
<img
|
||||||
:src="$vuetify.theme.current.dark ? IconDark : IconLight"
|
:src="colorMode.value == 'dark' ? IconDark : IconLight"
|
||||||
alt="CloudyLamb"
|
alt="CloudyLamb"
|
||||||
height="60"
|
height="60"
|
||||||
width="60"
|
width="60"
|
||||||
@@ -34,32 +34,36 @@
|
|||||||
|
|
||||||
<!-- App Info Section -->
|
<!-- App Info Section -->
|
||||||
<div v-if="clientInfo" class="mb-6">
|
<div v-if="clientInfo" class="mb-6">
|
||||||
<div class="d-flex align-center mb-4">
|
<div class="d-flex align-center mb-4 text-left">
|
||||||
<v-avatar
|
|
||||||
v-if="clientInfo.picture"
|
|
||||||
:src="clientInfo.picture.url"
|
|
||||||
:alt="clientInfo.client_name"
|
|
||||||
size="large"
|
|
||||||
class="mr-3"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold">
|
<h3 class="text-xl font-semibold">
|
||||||
{{ clientInfo.client_name || 'Unknown Application' }}
|
{{ clientInfo.clientName || "Unknown Application" }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-base">
|
<p class="text-base">
|
||||||
{{ isNewApp ? 'wants to access your Solar Network account' : 'wants to access your account' }}
|
{{
|
||||||
|
isNewApp
|
||||||
|
? "wants to access your Solar Network account"
|
||||||
|
: "wants to access your account"
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Requested Permissions -->
|
<!-- Requested Permissions -->
|
||||||
<v-card variant="outlined" class="pa-4 mb-4">
|
<v-card variant="outlined" class="pa-4 mb-4 text-left">
|
||||||
<h4 class="font-medium mb-2">
|
<h4 class="font-medium mb-2">
|
||||||
This will allow {{ clientInfo.client_name || 'the app' }} to:
|
This will allow
|
||||||
|
{{ clientInfo.clientName || "the app" }} to
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
<li v-for="scope in requestedScopes" :key="scope" class="d-flex align-start">
|
<li
|
||||||
<v-icon class="mt-1 mr-2" color="success">mdi-check-box</v-icon>
|
v-for="scope in requestedScopes"
|
||||||
|
:key="scope"
|
||||||
|
class="d-flex align-start"
|
||||||
|
>
|
||||||
|
<v-icon class="mt-1 mr-2" color="success" size="18"
|
||||||
|
>mdi-check</v-icon
|
||||||
|
>
|
||||||
<span>{{ scope }}</span>
|
<span>{{ scope }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -86,27 +90,6 @@
|
|||||||
Deny
|
Deny
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 text-sm text-center">
|
|
||||||
By authorizing, you agree to the
|
|
||||||
<v-btn
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
@click="openTerms"
|
|
||||||
class="px-1 text-capitalize"
|
|
||||||
>
|
|
||||||
Terms of Service
|
|
||||||
</v-btn>
|
|
||||||
and
|
|
||||||
<v-btn
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
@click="openPrivacy"
|
|
||||||
class="px-1 text-capitalize"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
@@ -118,26 +101,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from "vue"
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from "vue-router"
|
||||||
import { useSolarNetwork } from '~/composables/useSolarNetwork'
|
import { useSolarNetwork } from "~/composables/useSolarNetwork"
|
||||||
|
|
||||||
import IconLight from '~/assets/images/cloudy-lamb.png'
|
import IconLight from "~/assets/images/cloudy-lamb.png"
|
||||||
import IconDark from '~/assets/images/cloudy-lamb@dark.png'
|
import IconDark from "~/assets/images/cloudy-lamb@dark.png"
|
||||||
|
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const api = useSolarNetwork()
|
const api = useSolarNetwork()
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Authorize Application"
|
||||||
|
})
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const isAuthorizing = ref(false)
|
const isAuthorizing = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const clientInfo = ref<{
|
const clientInfo = ref<{
|
||||||
client_name?: string
|
clientName?: string
|
||||||
home_uri?: string
|
homeUri?: string
|
||||||
picture?: { url: string }
|
picture?: { url: string }
|
||||||
terms_of_service_uri?: string
|
|
||||||
privacy_policy_uri?: string
|
|
||||||
scopes?: string[]
|
scopes?: string[]
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const isNewApp = ref(false)
|
const isNewApp = ref(false)
|
||||||
@@ -154,7 +141,8 @@ async function fetchClientInfo() {
|
|||||||
clientInfo.value = await api(`/id/auth/open/authorize?${queryString}`)
|
clientInfo.value = await api(`/id/auth/open/authorize?${queryString}`)
|
||||||
checkIfNewApp()
|
checkIfNewApp()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.message || 'An error occurred while loading the authorization request'
|
error.value =
|
||||||
|
err.message || "An error occurred while loading the authorization request"
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -169,19 +157,19 @@ function checkIfNewApp() {
|
|||||||
async function handleAuthorize() {
|
async function handleAuthorize() {
|
||||||
isAuthorizing.value = true
|
isAuthorizing.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api<{ redirect_uri?: string }>('/auth/open/authorize', {
|
const data = await api<{ redirectUri?: string }>(
|
||||||
method: 'POST',
|
"/id/auth/open/authorize",
|
||||||
body: {
|
{
|
||||||
...route.query,
|
method: "POST",
|
||||||
authorize: 'true',
|
body: new URLSearchParams(window.location.search.slice(1))
|
||||||
},
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
if (data.redirect_uri) {
|
if (data.redirectUri) {
|
||||||
window.location.href = data.redirect_uri
|
window.location.href = data.redirectUri
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.message || 'An error occurred during authorization'
|
error.value = err.message || "An error occurred during authorization"
|
||||||
} finally {
|
} finally {
|
||||||
isAuthorizing.value = false
|
isAuthorizing.value = false
|
||||||
}
|
}
|
||||||
@@ -191,29 +179,21 @@ function handleDeny() {
|
|||||||
// Redirect back to the client with an error
|
// Redirect back to the client with an error
|
||||||
// Ensure redirect_uri is always a string (not an array)
|
// Ensure redirect_uri is always a string (not an array)
|
||||||
const redirectUriStr = Array.isArray(route.query.redirect_uri)
|
const redirectUriStr = Array.isArray(route.query.redirect_uri)
|
||||||
? route.query.redirect_uri[0] || clientInfo.value?.home_uri || '/'
|
? route.query.redirect_uri[0] || clientInfo.value?.homeUri || "/"
|
||||||
: route.query.redirect_uri || clientInfo.value?.home_uri || '/'
|
: route.query.redirect_uri || clientInfo.value?.homeUri || "/"
|
||||||
const redirectUri = new URL(redirectUriStr)
|
const redirectUri = new URL(redirectUriStr)
|
||||||
// Ensure state is always a string (not an array)
|
// Ensure state is always a string (not an array)
|
||||||
const state = Array.isArray(route.query.state)
|
const state = Array.isArray(route.query.state)
|
||||||
? route.query.state[0] || ''
|
? route.query.state[0] || ""
|
||||||
: route.query.state || ''
|
: route.query.state || ""
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
error: 'access_denied',
|
error: "access_denied",
|
||||||
error_description: 'The user denied the authorization request',
|
error_description: "The user denied the authorization request",
|
||||||
state: state,
|
state: state
|
||||||
})
|
})
|
||||||
window.open(`${redirectUri}?${params}`, "_self")
|
window.open(`${redirectUri}?${params}`, "_self")
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTerms() {
|
|
||||||
window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank")
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPrivacy() {
|
|
||||||
window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchClientInfo()
|
fetchClientInfo()
|
||||||
|
@@ -11,6 +11,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
useHead({
|
||||||
|
title: "Auth Completed"
|
||||||
|
})
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "minimal"
|
layout: "minimal"
|
||||||
})
|
})
|
||||||
|
@@ -36,6 +36,10 @@ import CaptchaWidget from "@/components/CaptchaWidget.vue"
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Captcha Verification"
|
||||||
|
})
|
||||||
|
|
||||||
const onCaptchaVerified = (token: string) => {
|
const onCaptchaVerified = (token: string) => {
|
||||||
if (window.parent !== window) {
|
if (window.parent !== window) {
|
||||||
window.parent.postMessage(`captcha_tk=${token}`, "*")
|
window.parent.postMessage(`captcha_tk=${token}`, "*")
|
||||||
|
@@ -7,7 +7,7 @@
|
|||||||
<div class="pa-8">
|
<div class="pa-8">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<img
|
<img
|
||||||
:src="$vuetify.theme.current.dark ? IconDark : IconLight"
|
:src="colorMode.value == 'dark' ? IconDark : IconLight"
|
||||||
alt="CloudyLamb"
|
alt="CloudyLamb"
|
||||||
height="60"
|
height="60"
|
||||||
width="60"
|
width="60"
|
||||||
@@ -153,6 +153,12 @@ import IconDark from "~/assets/images/cloudy-lamb@dark.png"
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const api = useSolarNetwork()
|
const api = useSolarNetwork()
|
||||||
|
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Create Account"
|
||||||
|
})
|
||||||
|
|
||||||
const stage = ref<"username-nick" | "email" | "password" | "captcha">(
|
const stage = ref<"username-nick" | "email" | "password" | "captcha">(
|
||||||
"username-nick"
|
"username-nick"
|
||||||
)
|
)
|
||||||
|
@@ -11,6 +11,10 @@ import IconLight from "~/assets/images/cloudy-lamb.png"
|
|||||||
import IconDark from "~/assets/images/cloudy-lamb@dark.png"
|
import IconDark from "~/assets/images/cloudy-lamb@dark.png"
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
|
useHead({
|
||||||
|
title: "Sign In"
|
||||||
|
})
|
||||||
|
|
||||||
const stage = ref<
|
const stage = ref<
|
||||||
"find-account" | "select-factor" | "enter-code" | "token-exchange"
|
"find-account" | "select-factor" | "enter-code" | "token-exchange"
|
||||||
>("find-account")
|
>("find-account")
|
||||||
@@ -242,6 +246,8 @@ function getFactorName(factorType: number) {
|
|||||||
return "Unknown Factor"
|
return "Unknown Factor"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const colorMode = useColorMode()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -253,7 +259,7 @@ function getFactorName(factorType: number) {
|
|||||||
<div class="pa-8">
|
<div class="pa-8">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<img
|
<img
|
||||||
:src="$vuetify.theme.current.dark ? IconDark : IconLight"
|
:src="colorMode.value == 'dark' ? IconDark : IconLight"
|
||||||
alt="CloudyLamb"
|
alt="CloudyLamb"
|
||||||
height="60"
|
height="60"
|
||||||
width="60"
|
width="60"
|
||||||
|
246
app/pages/files/[id].vue
Normal file
246
app/pages/files/[id].vue
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-flex align-center justify-center fill-height">
|
||||||
|
<v-card class="pa-6" max-width="1200" width="100%">
|
||||||
|
<v-progress-circular
|
||||||
|
v-if="!fileInfo && !error"
|
||||||
|
indeterminate
|
||||||
|
size="32"
|
||||||
|
></v-progress-circular>
|
||||||
|
<v-alert
|
||||||
|
type="error"
|
||||||
|
title="No file was found"
|
||||||
|
:text="error"
|
||||||
|
v-else-if="error"
|
||||||
|
></v-alert>
|
||||||
|
<div v-else>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<div v-if="fileInfo.isEncrypted">
|
||||||
|
<v-alert type="info" title="Encrypted file" class="mb-4">
|
||||||
|
The file has been encrypted. Preview not available. Please enter
|
||||||
|
the password to download it.
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<v-img
|
||||||
|
v-if="fileType === 'image'"
|
||||||
|
:src="fileSource"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="fileType === 'video'"
|
||||||
|
:src="fileSource"
|
||||||
|
controls
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<audio
|
||||||
|
v-else-if="fileType === 'audio'"
|
||||||
|
:src="fileSource"
|
||||||
|
controls
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<v-alert
|
||||||
|
type="warning"
|
||||||
|
title="Preview Unavailable"
|
||||||
|
text="How can you preview this file?"
|
||||||
|
v-else
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<v-card
|
||||||
|
title="File Information"
|
||||||
|
prepend-icon="mdi-information-outline"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<span class="flex-grow-1 d-flex align-center gap-2">
|
||||||
|
<v-icon size="18">mdi-information</v-icon>
|
||||||
|
File Type
|
||||||
|
</span>
|
||||||
|
<span>{{ fileInfo.mimeType }} ({{ fileType }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<span class="flex-grow-1 d-flex align-center gap-2">
|
||||||
|
<v-icon size="18">mdi-chart-pie</v-icon>
|
||||||
|
File Size
|
||||||
|
</span>
|
||||||
|
<span>{{ formatBytes(fileInfo.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<span class="flex-grow-1 d-flex align-center gap-2">
|
||||||
|
<v-icon size="18">mdi-upload</v-icon>
|
||||||
|
Uploaded At
|
||||||
|
</span>
|
||||||
|
<span>{{
|
||||||
|
new Date(fileInfo.createdAt).toLocaleString()
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<span class="flex-grow-1 d-flex align-center gap-2">
|
||||||
|
<v-icon size="18">mdi-details</v-icon>
|
||||||
|
Technical Info
|
||||||
|
</span>
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
size="x-small"
|
||||||
|
@click="showTechDetails = !showTechDetails"
|
||||||
|
>
|
||||||
|
{{ showTechDetails ? "Hide" : "Show" }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-expand-transition>
|
||||||
|
<div
|
||||||
|
v-if="showTechDetails"
|
||||||
|
class="mt-2 d-flex flex-column gap-1"
|
||||||
|
>
|
||||||
|
<p class="text-caption opacity-75">#{{ fileInfo.id }}</p>
|
||||||
|
|
||||||
|
<v-card class="pa-2" variant="outlined">
|
||||||
|
<pre
|
||||||
|
class="overflow-x-auto px-2 py-1"
|
||||||
|
><code>{{ JSON.stringify(fileInfo.fileMeta, null, 4) }}</code></pre>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</v-expand-transition>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
<v-text-field
|
||||||
|
v-if="fileInfo.isEncrypted"
|
||||||
|
label="Password"
|
||||||
|
v-model="filePass"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<v-btn class="flex-grow-1" @click="downloadFile">Download</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-expand-transition>
|
||||||
|
<v-progress-linear
|
||||||
|
v-if="!!progress"
|
||||||
|
:model-value="progress"
|
||||||
|
:indeterminate="progress < 100"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
|
</v-expand-transition>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
import { computed, onMounted, ref } from "vue"
|
||||||
|
|
||||||
|
import { downloadAndDecryptFile } from "./secure"
|
||||||
|
import { formatBytes } from "./format"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const filePass = ref<string>("")
|
||||||
|
const fileId = route.params.id
|
||||||
|
const passcode = route.query.passcode as string | undefined
|
||||||
|
|
||||||
|
const progress = ref<number | undefined>(0)
|
||||||
|
|
||||||
|
const showTechDetails = ref<boolean>(false)
|
||||||
|
|
||||||
|
const api = useSolarNetwork()
|
||||||
|
|
||||||
|
const fileInfo = ref<any>(null)
|
||||||
|
async function fetchFileInfo() {
|
||||||
|
try {
|
||||||
|
let url = "/drive/files/" + fileId + "/info"
|
||||||
|
if (passcode) {
|
||||||
|
url += `?passcode=${passcode}`
|
||||||
|
}
|
||||||
|
const resp = await api(url)
|
||||||
|
fileInfo.value = resp
|
||||||
|
} catch (err) {
|
||||||
|
error.value = (err as Error).message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => fetchFileInfo())
|
||||||
|
|
||||||
|
const apiBase = useSolarNetworkUrl(false)
|
||||||
|
|
||||||
|
const fileType = computed(() => {
|
||||||
|
if (!fileInfo.value) return "unknown"
|
||||||
|
return fileInfo.value.mimeType?.split("/")[0] || "unknown"
|
||||||
|
})
|
||||||
|
const fileSource = computed(() => {
|
||||||
|
let url = `${apiBase}/drive/files/${fileId}`
|
||||||
|
if (passcode) {
|
||||||
|
url += `?passcode=${passcode}`
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
})
|
||||||
|
|
||||||
|
async function downloadFile() {
|
||||||
|
if (fileInfo.value.isEncrypted && !filePass.value) {
|
||||||
|
alert("Please enter the password to download the file.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (fileInfo.value.isEncrypted) {
|
||||||
|
downloadAndDecryptFile(
|
||||||
|
fileSource.value,
|
||||||
|
filePass.value,
|
||||||
|
fileInfo.value.name,
|
||||||
|
(p: number) => {
|
||||||
|
progress.value = p * 100
|
||||||
|
}
|
||||||
|
).catch((err: any) => {
|
||||||
|
alert("Download failed: " + err.message)
|
||||||
|
progress.value = undefined
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const res = await fetch(fileSource.value)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to download ${fileInfo.value.name}: ${res.statusText}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = res.headers.get("content-length")
|
||||||
|
if (!contentLength) {
|
||||||
|
throw new Error("Content-Length response header is missing.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = parseInt(contentLength, 10)
|
||||||
|
const reader = res.body!.getReader()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let received = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
if (value) {
|
||||||
|
chunks.push(value)
|
||||||
|
received += value.length
|
||||||
|
progress.value = (received / total) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob(chunks as BlobPart[])
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = blobUrl
|
||||||
|
a.download =
|
||||||
|
fileInfo.value.fileName ||
|
||||||
|
"download." + fileInfo.value.mimeType.split("/")[1]
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
window.URL.revokeObjectURL(blobUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
8
app/pages/files/format.ts
Normal file
8
app/pages/files/format.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function formatBytes(bytes: number, decimals = 2): string {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const dm = decimals < 0 ? 0 : decimals
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||||
|
}
|
94
app/pages/files/secure.ts
Normal file
94
app/pages/files/secure.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
export async function downloadAndDecryptFile(
|
||||||
|
url: string,
|
||||||
|
password: string,
|
||||||
|
fileName: string,
|
||||||
|
onProgress?: (progress: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`)
|
||||||
|
|
||||||
|
const contentLength = +(response.headers.get('Content-Length') || 0)
|
||||||
|
const reader = response.body!.getReader()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let received = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
if (value) {
|
||||||
|
chunks.push(value)
|
||||||
|
received += value.length
|
||||||
|
if (contentLength && onProgress) {
|
||||||
|
onProgress(received / contentLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullBuffer = new Uint8Array(received)
|
||||||
|
let offset = 0
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
fullBuffer.set(chunk, offset)
|
||||||
|
offset += chunk.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedBytes = await decryptFile(fullBuffer, password)
|
||||||
|
|
||||||
|
// Create a blob and trigger a download
|
||||||
|
const blob = new Blob([decryptedBytes])
|
||||||
|
const downloadUrl = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = downloadUrl
|
||||||
|
a.download = fileName
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
URL.revokeObjectURL(downloadUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptFile(fileBuffer: Uint8Array, password: string): Promise<Uint8Array> {
|
||||||
|
const salt = fileBuffer.slice(0, 16)
|
||||||
|
const nonce = fileBuffer.slice(16, 28)
|
||||||
|
const tag = fileBuffer.slice(28, 44)
|
||||||
|
const ciphertext = fileBuffer.slice(44)
|
||||||
|
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
enc.encode(password),
|
||||||
|
{ name: 'PBKDF2' },
|
||||||
|
false,
|
||||||
|
['deriveKey'],
|
||||||
|
)
|
||||||
|
const key = await crypto.subtle.deriveKey(
|
||||||
|
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
|
||||||
|
keyMaterial,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['decrypt'],
|
||||||
|
)
|
||||||
|
|
||||||
|
const fullCiphertext = new Uint8Array(ciphertext.length + tag.length)
|
||||||
|
fullCiphertext.set(ciphertext)
|
||||||
|
fullCiphertext.set(tag, ciphertext.length)
|
||||||
|
|
||||||
|
let decrypted: ArrayBuffer
|
||||||
|
try {
|
||||||
|
decrypted = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv: nonce, tagLength: 128 },
|
||||||
|
key,
|
||||||
|
fullCiphertext,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw new Error('Incorrect password or corrupted file.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const magic = new TextEncoder().encode('DYSON1')
|
||||||
|
const decryptedBytes = new Uint8Array(decrypted)
|
||||||
|
for (let i = 0; i < magic.length; i++) {
|
||||||
|
if (decryptedBytes[i] !== magic[i]) {
|
||||||
|
throw new Error('Incorrect password or corrupted file.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decryptedBytes.slice(magic.length)
|
||||||
|
}
|
@@ -10,7 +10,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar">
|
<div class="sidebar flex flex-col gap-3">
|
||||||
<v-card v-if="!userStore.isAuthenticated" 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>
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
<post-editor @posted="refreshActivities" />
|
<post-editor @posted="refreshActivities" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
<sidebar-footer class="max-lg:hidden" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
@@ -46,8 +47,25 @@ import type { SnVersion, SnActivity } from "~/types/api"
|
|||||||
import PostEditor from "~/components/PostEditor.vue"
|
import PostEditor from "~/components/PostEditor.vue"
|
||||||
import PostItem from "~/components/PostItem.vue"
|
import PostItem from "~/components/PostItem.vue"
|
||||||
|
|
||||||
|
import IconLight from "~/assets/images/cloudy-lamb.png"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Explore",
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "The open social network. Friendly to everyone."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
defineOgImage({
|
||||||
|
title: "Explore",
|
||||||
|
description: "The open social network. Friendly to everyone."
|
||||||
|
})
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const version = ref<SnVersion | null>(null)
|
const version = ref<SnVersion | null>(null)
|
||||||
@@ -87,7 +105,7 @@ onMounted(() => fetchActivites())
|
|||||||
|
|
||||||
useInfiniteScroll(window, fetchActivites, {
|
useInfiniteScroll(window, fetchActivites, {
|
||||||
canLoadMore: () => !loading.value && activitesHasMore.value,
|
canLoadMore: () => !loading.value && activitesHasMore.value,
|
||||||
distance: 10,
|
distance: 10
|
||||||
})
|
})
|
||||||
|
|
||||||
async function refreshActivities() {
|
async function refreshActivities() {
|
||||||
|
@@ -1,96 +1,339 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container>
|
<v-container class="py-6">
|
||||||
<div v-if="loading" class="text-center py-8">
|
<div v-if="pending" class="text-center py-12">
|
||||||
<v-progress-circular indeterminate size="64" />
|
<v-progress-circular indeterminate size="64" color="primary" />
|
||||||
<p class="mt-4">Loading post...</p>
|
<p class="mt-4">Loading post...</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="error" class="text-center py-8">
|
<div v-else-if="error" class="text-center py-12">
|
||||||
<v-alert type="error" class="mb-4">
|
<v-alert type="error" class="mb-4" prominent>
|
||||||
{{ error }}
|
<v-alert-title>Error Loading Post</v-alert-title>
|
||||||
|
{{ error?.statusMessage || "Failed to load post" }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="post">
|
<div v-else-if="post" class="max-w-4xl mx-auto">
|
||||||
<v-card class="mb-4">
|
<!-- Article Type: Split Header and Content -->
|
||||||
<v-card-text>
|
<template v-if="post.type === 1">
|
||||||
<div class="flex flex-col gap-3">
|
<!-- Post Header Section (Article) -->
|
||||||
<post-header :item="post" />
|
<v-card class="mb-4 elevation-2" rounded="lg">
|
||||||
|
<v-card-text class="pa-6">
|
||||||
|
<post-header :item="post" class="mb-4" />
|
||||||
|
|
||||||
<div v-if="post.title || post.description">
|
<!-- Post Title and Description -->
|
||||||
<h1 v-if="post.title" class="text-2xl font-bold">
|
<div v-if="post.title || post.description" class="mb-4">
|
||||||
|
<h1
|
||||||
|
v-if="post.title"
|
||||||
|
class="text-3xl font-bold mb-3 leading-tight"
|
||||||
|
>
|
||||||
{{ post.title }}
|
{{ post.title }}
|
||||||
</h1>
|
</h1>
|
||||||
<p v-if="post.description" class="text-sm text-gray-600 dark:text-gray-400">
|
<p
|
||||||
|
v-if="post.description"
|
||||||
|
class="text-lg text-medium-emphasis leading-relaxed"
|
||||||
|
>
|
||||||
{{ post.description }}
|
{{ post.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Metadata -->
|
||||||
|
<div class="flex items-center gap-4 text-sm text-medium-emphasis">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<v-icon size="16">mdi-calendar</v-icon>
|
||||||
|
<span>{{ formatDate(post.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="post.updatedAt && post.updatedAt !== post.createdAt"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<v-icon size="16">mdi-pencil</v-icon>
|
||||||
|
<span>Updated {{ formatDate(post.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="(post as any).viewCount || (post as any).view_count"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<v-icon size="16">mdi-eye</v-icon>
|
||||||
|
<span
|
||||||
|
>{{
|
||||||
|
(post as any).viewCount || (post as any).view_count || 0
|
||||||
|
}}
|
||||||
|
views</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Merged Content and Attachments Section (Article) -->
|
||||||
|
<v-card class="mb-4 elevation-1" rounded="lg">
|
||||||
|
<v-card-text class="pa-8">
|
||||||
<article
|
<article
|
||||||
v-if="htmlContent"
|
v-if="htmlContent"
|
||||||
class="prose prose-lg dark:prose-invert prose-slate prose-p:m-0 max-w-none"
|
class="prose prose-xl dark:prose-invert prose-slate max-w-none mb-8"
|
||||||
>
|
>
|
||||||
<div v-html="htmlContent" />
|
<div v-html="htmlContent" />
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div v-if="post.attachments && post.attachments.length > 0" class="d-flex gap-2 flex-wrap mt-4">
|
<!-- Attachments within Content Section -->
|
||||||
|
<div v-if="post.attachments && post.attachments.length > 0">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<attachment-item
|
<attachment-item
|
||||||
v-for="attachment in post.attachments"
|
v-for="attachment in post.attachments"
|
||||||
:key="attachment.id"
|
:key="attachment.id"
|
||||||
:item="attachment"
|
:item="attachment"
|
||||||
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-if="post.tags && post.tags.length > 0" class="mt-4">
|
<!-- Other Types: Merged Header, Content, and Attachments -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Merged Header, Content, and Attachments Section -->
|
||||||
|
<v-card class="px-4 py-3 mb-4 elevation-1" rounded="lg">
|
||||||
|
<v-card-text class="pa-6">
|
||||||
|
<post-header :item="post" class="mb-4" />
|
||||||
|
|
||||||
|
<!-- Post Title and Description -->
|
||||||
|
<div v-if="post.title || post.description" class="mb-4">
|
||||||
|
<h1
|
||||||
|
v-if="post.title"
|
||||||
|
class="text-3xl font-bold mb-3 leading-tight"
|
||||||
|
>
|
||||||
|
{{ post.title }}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
v-if="post.description"
|
||||||
|
class="text-lg text-medium-emphasis leading-relaxed"
|
||||||
|
>
|
||||||
|
{{ post.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Metadata -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 text-sm text-medium-emphasis mb-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<v-icon size="16">mdi-calendar</v-icon>
|
||||||
|
<span>{{ formatDate(post.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="post.updatedAt && post.updatedAt !== post.createdAt"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<v-icon size="16">mdi-pencil</v-icon>
|
||||||
|
<span>Updated {{ formatDate(post.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="(post as any).viewCount || (post as any).view_count"
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<v-icon size="16">mdi-eye</v-icon>
|
||||||
|
<span
|
||||||
|
>{{
|
||||||
|
(post as any).viewCount || (post as any).view_count || 0
|
||||||
|
}}
|
||||||
|
views</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<article
|
||||||
|
v-if="htmlContent"
|
||||||
|
class="prose prose-xl dark:prose-invert prose-slate max-w-none mb-8"
|
||||||
|
>
|
||||||
|
<div v-html="htmlContent" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Attachments within Merged Section -->
|
||||||
|
<div v-if="post.attachments && post.attachments.length > 0">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<attachment-item
|
||||||
|
v-for="attachment in post.attachments"
|
||||||
|
:key="attachment.id"
|
||||||
|
:item="attachment"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Tags Section -->
|
||||||
|
<v-card
|
||||||
|
v-if="post.tags && post.tags.length > 0"
|
||||||
|
class="mb-4 elevation-1"
|
||||||
|
rounded="lg"
|
||||||
|
>
|
||||||
|
<v-card-title class="text-h6">
|
||||||
|
<v-icon class="mr-2">mdi-tag-multiple</v-icon>
|
||||||
|
Tags
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
<v-chip
|
<v-chip
|
||||||
v-for="tag in post.tags"
|
v-for="tag in post.tags"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
class="mr-2 mb-2"
|
color="primary"
|
||||||
|
class="cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||||
>
|
>
|
||||||
|
<v-icon start size="16">mdi-tag</v-icon>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Post Reactions -->
|
||||||
|
<div>
|
||||||
|
<post-reaction-list
|
||||||
|
can-react
|
||||||
|
:parent-id="id"
|
||||||
|
:reactions="(post as any).reactions || {}"
|
||||||
|
:reactions-made="(post as any).reactionsMade || {}"
|
||||||
|
@react="handleReaction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue"
|
import { computed } from "vue"
|
||||||
import { Marked } from "marked"
|
import { unified } from "unified"
|
||||||
|
import remarkParse from "remark-parse"
|
||||||
|
import remarkMath from "remark-math"
|
||||||
|
import remarkRehype from "remark-rehype"
|
||||||
|
import rehypeKatex from "rehype-katex"
|
||||||
|
import rehypeStringify from "rehype-stringify"
|
||||||
|
import remarkBreaks from "remark-breaks"
|
||||||
|
import remarkGfm from "remark-gfm"
|
||||||
import type { SnPost } from "~/types/api"
|
import type { SnPost } from "~/types/api"
|
||||||
|
|
||||||
import PostHeader from "~/components/PostHeader.vue"
|
import PostHeader from "~/components/PostHeader.vue"
|
||||||
import AttachmentItem from "~/components/AttachmentItem.vue"
|
import AttachmentItem from "~/components/AttachmentItem.vue"
|
||||||
|
import PostReactionList from "~/components/PostReactionList.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const id = route.params.id as string
|
const id = route.params.id as string
|
||||||
|
|
||||||
const post = ref<SnPost | null>(null)
|
const processor = unified()
|
||||||
const loading = ref(true)
|
.use(remarkParse)
|
||||||
const error = ref("")
|
.use(remarkMath)
|
||||||
const htmlContent = ref("")
|
.use(remarkBreaks)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkRehype)
|
||||||
|
.use(rehypeKatex)
|
||||||
|
.use(rehypeStringify)
|
||||||
|
|
||||||
const marked = new Marked()
|
const apiServer = useSolarNetwork(true)
|
||||||
|
|
||||||
async function fetchPost() {
|
const {
|
||||||
|
data: postData,
|
||||||
|
error,
|
||||||
|
pending
|
||||||
|
} = await useAsyncData(`post-${id}`, async () => {
|
||||||
try {
|
try {
|
||||||
const api = useSolarNetwork()
|
const resp = await apiServer(`/sphere/posts/${id}`)
|
||||||
const resp = await api(`/sphere/posts/${id}`)
|
const post = resp as SnPost
|
||||||
post.value = resp as SnPost
|
let html = ""
|
||||||
if (post.value.content) {
|
if (post.content) {
|
||||||
htmlContent.value = await marked.parse(post.value.content, { breaks: true })
|
html = String(processor.processSync(post.content))
|
||||||
}
|
}
|
||||||
|
return { post, html }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Failed to load post"
|
throw createError({
|
||||||
} finally {
|
statusCode: 404,
|
||||||
loading.value = false
|
statusMessage: e instanceof Error ? e.message : "Failed to load post"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const post = computed(() => postData.value?.post || null)
|
||||||
|
const htmlContent = computed(() => postData.value?.html || "")
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: computed(() => {
|
||||||
|
if (pending.value) return "Loading post..."
|
||||||
|
if (error.value) return "Error"
|
||||||
|
if (!post.value) return "Post not found"
|
||||||
|
return `${post.value?.title || "Post"} from ${post.value?.publisher.nick}`
|
||||||
|
}),
|
||||||
|
meta: computed(() => {
|
||||||
|
if (post.value) {
|
||||||
|
const description =
|
||||||
|
post.value.description || post.value.content?.substring(0, 150) || ""
|
||||||
|
return [{ name: "description", content: description }]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiBase = useSolarNetworkUrl()
|
||||||
|
|
||||||
|
const userPicture = computed(() => {
|
||||||
|
return post.value?.publisher.picture
|
||||||
|
? `${apiBase}/drive/files/${post.value.publisher.picture.id}`
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
|
const userBackground = computed(() => {
|
||||||
|
const firstImageAttachment = post.value?.attachments?.find((att) =>
|
||||||
|
att.mimeType?.startsWith("image/")
|
||||||
|
)
|
||||||
|
return firstImageAttachment
|
||||||
|
? `${apiBase}/drive/files/${firstImageAttachment.id}`
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
defineOgImage({
|
||||||
|
component: "ImageCard",
|
||||||
|
title: computed(() => post.value?.title || "Post"),
|
||||||
|
description: computed(
|
||||||
|
() =>
|
||||||
|
post.value?.description || post.value?.content?.substring(0, 150) || ""
|
||||||
|
),
|
||||||
|
avatarUrl: computed(() => userPicture.value),
|
||||||
|
backgroundImage: computed(() => userBackground.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
function handleReaction(symbol: string, attitude: number, delta: number) {
|
||||||
fetchPost()
|
if (!post.value) return
|
||||||
})
|
|
||||||
|
// Update the reactions count
|
||||||
|
const reactions = (post.value as any).reactions || {}
|
||||||
|
const currentCount = reactions[symbol] || 0
|
||||||
|
const newCount = Math.max(0, currentCount + delta)
|
||||||
|
|
||||||
|
if (newCount === 0) {
|
||||||
|
delete reactions[symbol]
|
||||||
|
} else {
|
||||||
|
reactions[symbol] = newCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the reactionsMade status
|
||||||
|
const reactionsMade = (post.value as any).reactionsMade || {}
|
||||||
|
if (delta > 0) {
|
||||||
|
reactionsMade[symbol] = true
|
||||||
|
} else {
|
||||||
|
delete reactionsMade[symbol]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the post object
|
||||||
|
;(post.value as any).reactions = reactions
|
||||||
|
;(post.value as any).reactionsMade = reactionsMade
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
115
app/pages/spells/[...word].vue
Normal file
115
app/pages/spells/[...word].vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-flex align-center justify-center fill-height">
|
||||||
|
<v-card max-width="400" title="Magic Spell" prepend-icon="mdi-magic-staff" class="pa-2">
|
||||||
|
<v-card-text>
|
||||||
|
<v-alert type="success" v-if="done" class="mb-4">
|
||||||
|
The magic spell has been applied successfully. Now you can close this
|
||||||
|
tab and back to the Solar Network!
|
||||||
|
</v-alert>
|
||||||
|
<v-alert
|
||||||
|
type="error"
|
||||||
|
v-else-if="!!error"
|
||||||
|
title="Something went wrong"
|
||||||
|
class="mb-4"
|
||||||
|
>{{ error }}</v-alert
|
||||||
|
>
|
||||||
|
<div v-else-if="!!spell">
|
||||||
|
<p class="mb-2">
|
||||||
|
Magic spell for {{ spellTypes[spell.type] ?? "unknown" }}
|
||||||
|
</p>
|
||||||
|
<div class="d-flex align-center gap-2 mb-2">
|
||||||
|
<v-icon size="18">mdi-account-circle</v-icon>
|
||||||
|
<strong>@{{ spell.account.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center gap-2 mb-2">
|
||||||
|
<v-icon size="18">mdi-play</v-icon>
|
||||||
|
<span>Available at</span>
|
||||||
|
<strong>{{
|
||||||
|
new Date(spell.createdAt ?? spell.affectedAt).toLocaleString()
|
||||||
|
}}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center gap-2 mb-4" v-if="spell.expiredAt">
|
||||||
|
<v-icon size="18">mdi-calendar</v-icon>
|
||||||
|
<span>Until</span>
|
||||||
|
<strong>{{ spell.expiredAt.toString() }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<v-text-field
|
||||||
|
v-if="spell.type == 3"
|
||||||
|
v-model="newPassword"
|
||||||
|
label="New password"
|
||||||
|
type="password"
|
||||||
|
density="comfortable"
|
||||||
|
></v-text-field>
|
||||||
|
<v-btn color="primary" :loading="submitting" @click="applySpell">
|
||||||
|
<v-icon left>mdi-check</v-icon>
|
||||||
|
Apply
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-progress-circular
|
||||||
|
v-else
|
||||||
|
indeterminate
|
||||||
|
size="32"
|
||||||
|
class="mt-4"
|
||||||
|
></v-progress-circular>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue"
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const spellWord: string =
|
||||||
|
typeof route.params.word === "string"
|
||||||
|
? route.params.word
|
||||||
|
: route.params.word?.join("/") || ""
|
||||||
|
const spell = ref<any>(null)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const newPassword = ref<string>()
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const done = ref(false)
|
||||||
|
|
||||||
|
const spellTypes = [
|
||||||
|
"Account Activation",
|
||||||
|
"Account Deactivation",
|
||||||
|
"Account Deletion",
|
||||||
|
"Reset Password",
|
||||||
|
"Contact Method Verification"
|
||||||
|
]
|
||||||
|
|
||||||
|
const api = useSolarNetwork()
|
||||||
|
|
||||||
|
async function fetchSpell() {
|
||||||
|
try {
|
||||||
|
const resp = await api(`/id/spells/${encodeURIComponent(spellWord)}`)
|
||||||
|
spell.value = resp
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applySpell() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await api(`/id/spells/${encodeURIComponent(spellWord)}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: newPassword.value
|
||||||
|
? JSON.stringify({ new_password: newPassword.value })
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
done.value = true
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => fetchSpell())
|
||||||
|
</script>
|
93
app/pages/swagger/index.vue
Normal file
93
app/pages/swagger/index.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
// @ts-ignore
|
||||||
|
import { SwaggerUIBundle, SwaggerUIStandalonePreset } from "swagger-ui-dist"
|
||||||
|
import "swagger-ui-dist/swagger-ui.css"
|
||||||
|
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Load theme once on page load
|
||||||
|
loadTheme(colorMode.value)
|
||||||
|
|
||||||
|
// Reactively switch if user toggles mode
|
||||||
|
watch(colorMode, (newVal) => {
|
||||||
|
loadTheme(newVal.value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadTheme(mode: string) {
|
||||||
|
if (mode === "dark") {
|
||||||
|
import("swagger-themes/themes/one-dark.css")
|
||||||
|
} else {
|
||||||
|
import("swagger-themes/themes/material.css")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBase = useSolarNetworkUrl(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
urls: [
|
||||||
|
{
|
||||||
|
url: `${apiBase}/swagger/ring/v1/swagger.json`,
|
||||||
|
name: "DysonNetwork.Ring"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${apiBase}/swagger/pass/v1/swagger.json`,
|
||||||
|
name: "DysonNetwork.Pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${apiBase}/swagger/sphere/v1/swagger.json`,
|
||||||
|
name: "DysonNetwork.Sphere"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${apiBase}/swagger/drive/v1/swagger.json`,
|
||||||
|
name: "DysonNetwork.Drive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${apiBase}/swagger/develop/v1/swagger.json`,
|
||||||
|
name: "DysonNetwork.Develop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dom_id: "#swagger-ui",
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||||
|
layout: "StandaloneLayout"
|
||||||
|
})
|
||||||
|
// @ts-ignore
|
||||||
|
window.ui = ui
|
||||||
|
})
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: "minimal"
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Solar Network API"
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap");
|
||||||
|
|
||||||
|
.swagger-ui *:not(:is(pre, pre *, textarea, textarea *)) {
|
||||||
|
font-family: var(--font-family) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui pre,
|
||||||
|
.swagger-ui pre *,
|
||||||
|
.swagger-ui textarea,
|
||||||
|
.swagger-ui textarea * {
|
||||||
|
font-family: "IBM Plex Mono", monospace !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.swagger-ui {
|
||||||
|
--secondary-text-color: #ffffff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
24
app/plugins/01.auth.ts
Normal file
24
app/plugins/01.auth.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useUserStore } from '~/stores/user'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const side = process.server ? 'SERVER' : 'CLIENT'
|
||||||
|
console.log(`[AUTH PLUGIN] Running on ${side}`)
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// Prevent fetching if it's already in progress
|
||||||
|
if (userStore.isLoading) {
|
||||||
|
console.log(`[AUTH PLUGIN] User fetch already in progress on ${side}. Skipping.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// On initial app load, fetch the user if a token exists but the user object isn't populated.
|
||||||
|
if (userStore.token && !userStore.user) {
|
||||||
|
console.log(`[AUTH PLUGIN] Token found, user not loaded. Fetching user on ${side}.`)
|
||||||
|
userStore.fetchUser()
|
||||||
|
} else {
|
||||||
|
console.log(`[AUTH PLUGIN] Conditions not met for fetching user on ${side}.`, {
|
||||||
|
hasToken: !!userStore.token,
|
||||||
|
hasUser: !!userStore.user
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
@@ -1,6 +1,7 @@
|
|||||||
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 { FetchError } from "ofetch"
|
||||||
import type { SnAccount } from "~/types/api"
|
import type { SnAccount } from "~/types/api"
|
||||||
|
|
||||||
export const useUserStore = defineStore("user", () => {
|
export const useUserStore = defineStore("user", () => {
|
||||||
@@ -10,7 +11,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
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)
|
// The name is match with the remote one (set by server Set-Cookie)
|
||||||
const token = useCookie<string | null>("AuthToken", {
|
const token = useCookie<string | null>("fl_AuthToken", {
|
||||||
default: () => null,
|
default: () => null,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 24 * 365 * 10
|
maxAge: 60 * 60 * 24 * 365 * 10
|
||||||
@@ -19,9 +20,6 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
// Getters
|
// Getters
|
||||||
const isAuthenticated = computed(() => !!user.value && !!token.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
|
||||||
@@ -35,9 +33,14 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
user.value = response as SnAccount
|
user.value = response as SnAccount
|
||||||
console.log(`Logged in as ${user.value.name}`)
|
console.log(`Logged in as ${user.value.name}`)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof FetchError && e.statusCode == 401) {
|
||||||
|
error.value = "Unauthorized"
|
||||||
|
user.value = null
|
||||||
|
} else {
|
||||||
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)
|
console.error("Failed to fetch user... ", e)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -60,6 +63,6 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
fetchUser,
|
fetchUser,
|
||||||
setToken,
|
setToken,
|
||||||
logout,
|
logout
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@@ -60,7 +60,7 @@ export interface SnPost {
|
|||||||
awardedScore: number;
|
awardedScore: number;
|
||||||
reactionsCount: Record<string, number>;
|
reactionsCount: Record<string, number>;
|
||||||
repliesCount: number;
|
repliesCount: number;
|
||||||
reactionsMade: Record<string, unknown>;
|
reactionsMade: Record<string, boolean>;
|
||||||
repliedGone: boolean;
|
repliedGone: boolean;
|
||||||
forwardedGone: boolean;
|
forwardedGone: boolean;
|
||||||
repliedPostId: string | null;
|
repliedPostId: string | null;
|
||||||
|
18
app/types/marked-katex.d.ts
vendored
Normal file
18
app/types/marked-katex.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
declare module 'marked-katex' {
|
||||||
|
interface Options {
|
||||||
|
throwOnError?: boolean
|
||||||
|
errorColor?: string
|
||||||
|
displayMode?: boolean
|
||||||
|
leqno?: boolean
|
||||||
|
fleqn?: boolean
|
||||||
|
macros?: Record<string, string>
|
||||||
|
colorIsTextColor?: boolean
|
||||||
|
strict?: boolean | 'ignore' | 'warn' | 'error'
|
||||||
|
trust?: boolean | ((context: { command: string; url: string; protocol: string }) => boolean)
|
||||||
|
output?: 'html' | 'mathml' | 'htmlAndMathml'
|
||||||
|
}
|
||||||
|
|
||||||
|
function markedKatex(options?: Options): any
|
||||||
|
|
||||||
|
export default markedKatex
|
||||||
|
}
|
@@ -1,12 +1,9 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import withNuxt from "./.nuxt/eslint.config.mjs"
|
import withNuxt from "./.nuxt/eslint.config.mjs"
|
||||||
|
|
||||||
import tailwind from "eslint-plugin-tailwindcss"
|
|
||||||
|
|
||||||
export default withNuxt(
|
export default withNuxt(
|
||||||
// Your custom configs here
|
// Your custom configs here
|
||||||
{
|
{
|
||||||
...tailwind.configs["flat/recommended"],
|
|
||||||
rules: {
|
rules: {
|
||||||
"vue/multi-word-component-names": "off",
|
"vue/multi-word-component-names": "off",
|
||||||
"vue/no-v-html": "off",
|
"vue/no-v-html": "off",
|
||||||
|
@@ -9,22 +9,51 @@ export default defineNuxtConfig({
|
|||||||
"@nuxt/eslint",
|
"@nuxt/eslint",
|
||||||
"@pinia/nuxt",
|
"@pinia/nuxt",
|
||||||
"vuetify-nuxt-module",
|
"vuetify-nuxt-module",
|
||||||
"@nuxtjs/i18n"
|
"@nuxtjs/i18n",
|
||||||
|
"@nuxtjs/color-mode",
|
||||||
|
"nuxt-og-image"
|
||||||
],
|
],
|
||||||
css: ["~/assets/css/main.css"],
|
css: ["~/assets/css/main.css", "katex/dist/katex.min.css"],
|
||||||
pinia: {
|
app: {
|
||||||
storesDirs: ["./app/stores/**"]
|
pageTransition: { name: "page", mode: "out-in" },
|
||||||
|
head: {
|
||||||
|
titleTemplate: "%s - Solar Network",
|
||||||
|
link: [{ rel: "icon", type: "image/png", href: "/favicon.png" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
site: {
|
||||||
|
url: process.env.NUXT_PUBLIC_SITE_URL || "https://solian.app",
|
||||||
|
name: "Solar Network"
|
||||||
|
},
|
||||||
|
ogImage: {
|
||||||
|
fonts: [
|
||||||
|
"Noto+Sans+SC:400",
|
||||||
|
"Noto+Sans+TC:400",
|
||||||
|
"Noto+Sans+JP:400",
|
||||||
|
"Nunito:400"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
colorMode: {
|
||||||
|
preference: "system",
|
||||||
|
fallback: "light"
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
inlineStyles: false
|
inlineStyles: false
|
||||||
},
|
},
|
||||||
|
pinia: {
|
||||||
|
storesDirs: ["./app/stores/**"]
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: "en"
|
||||||
|
},
|
||||||
image: {
|
image: {
|
||||||
domains: ["api.solian.app"]
|
domains: ["api.solian.app"]
|
||||||
},
|
},
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
development: process.env.NODE_ENV == "development",
|
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",
|
||||||
|
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || "https://solian.app"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
|
23
package.json
23
package.json
@@ -16,6 +16,7 @@
|
|||||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
"@nuxt/eslint": "1.9.0",
|
"@nuxt/eslint": "1.9.0",
|
||||||
"@nuxt/image": "1.11.0",
|
"@nuxt/image": "1.11.0",
|
||||||
|
"@nuxtjs/color-mode": "3.5.2",
|
||||||
"@nuxtjs/i18n": "10.1.0",
|
"@nuxtjs/i18n": "10.1.0",
|
||||||
"@pinia/nuxt": "0.11.2",
|
"@pinia/nuxt": "0.11.2",
|
||||||
"@tailwindcss/typography": "^0.5.18",
|
"@tailwindcss/typography": "^0.5.18",
|
||||||
@@ -23,14 +24,27 @@
|
|||||||
"@vueuse/core": "^13.9.0",
|
"@vueuse/core": "^13.9.0",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"cfturnstile-vue3": "^2.0.0",
|
"cfturnstile-vue3": "^2.0.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.36.0",
|
||||||
"fslightbox-vue": "^2.2.1",
|
"katex": "^0.16.22",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"marked": "^16.3.0",
|
|
||||||
"nuxt": "^4.1.2",
|
"nuxt": "^4.1.2",
|
||||||
|
"nuxt-og-image": "^5.1.11",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"rehype-stringify": "^10.0.1",
|
||||||
|
"remark": "^15.0.1",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-html": "^16.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.2",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
|
"swagger-themes": "^1.4.3",
|
||||||
|
"swagger-ui-dist": "^5.29.0",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tus-js-client": "^4.3.1",
|
"tus-js-client": "^4.3.1",
|
||||||
|
"unified": "^11.0.5",
|
||||||
"vue": "^3.5.21",
|
"vue": "^3.5.21",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vuetify-nuxt-module": "0.18.7"
|
"vuetify-nuxt-module": "0.18.7"
|
||||||
@@ -38,7 +52,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
"@types/luxon": "^3.7.1",
|
"@types/luxon": "^3.7.1",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2"
|
||||||
"eslint-plugin-tailwindcss": "^4.0.0-beta.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
public/favicon.png
Executable file
BIN
public/favicon.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 70 KiB |
36
server/routes/__og/convert-image.ts
Normal file
36
server/routes/__og/convert-image.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
let url = query.url as string
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Missing url parameter"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (url.endsWith(":")) url = url.substring(0, url.length - 1)
|
||||||
|
if (url.endsWith("?original=true"))
|
||||||
|
url = url.substring(0, url.length - "?original=true".length)
|
||||||
|
console.log("Converting image... ", url)
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: "Image not found" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer()
|
||||||
|
const sharp = await import("sharp")
|
||||||
|
const converted = await sharp.default(Buffer.from(buffer)).png().toBuffer()
|
||||||
|
|
||||||
|
setHeader(event, "Content-Type", "image/png")
|
||||||
|
setHeader(event, "Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
||||||
|
return converted
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Image conversion error:", error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Image conversion failed"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
Reference in New Issue
Block a user