Compare commits

...

19 Commits

Author SHA1 Message Date
f95d6778c2 🗑️ Remove the route 2025-09-26 00:08:13 +08:00
744622addf 💄 Optimize readability of swagger 2025-09-25 23:59:18 +08:00
2de1e12c33 🐛 Fix bugs 2025-09-25 02:22:53 +08:00
54e8ffea6f Swagger docs 2025-09-25 02:18:13 +08:00
16a5207c02 🐛 Fix api route 2025-09-24 01:15:36 +08:00
52971c2d67 🐛 Fix 2025-09-24 00:58:15 +08:00
056010e8b6 🐛 Fix package 2025-09-24 00:53:26 +08:00
7e8cdb6348 Files 2025-09-24 00:45:31 +08:00
531a082d94 Magic spell 2025-09-24 00:21:41 +08:00
42f1d42506 💄 Optimize posts 2025-09-24 00:04:13 +08:00
8ce154eef2 🐛 Fix reaction 2025-09-21 00:31:47 +08:00
07ec5ffc55 🐛 Remove lock 2025-09-21 00:15:08 +08:00
db6c023651 🗑️ Remove package manager lock 2025-09-21 00:11:43 +08:00
eba829ebb9 Well known proxy 2025-09-21 00:03:43 +08:00
fcfb57f4a5 Better detail post 2025-09-21 00:01:47 +08:00
e9de02b084 :sparklesS: Post reaction 2025-09-20 23:28:25 +08:00
dd6ff13228 OpenGraph 2025-09-20 22:11:42 +08:00
b4c105b43e 🐛 Fix colorscheme 2025-09-20 19:08:41 +08:00
38295124cb 🐛 Fix unauthorized fetch 2025-09-20 19:02:29 +08:00
34 changed files with 1949 additions and 2483 deletions

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -1,33 +1,42 @@
<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" <!-- Blurhash placeholder -->
:style="`width: 100%; max-height: 800px; aspect-ratio: ${aspectRatio}`" <div
> v-if="blurhash"
<!-- Blurhash placeholder --> class="absolute inset-0 z-[-1]"
<div :style="blurhashContainerStyle"
v-if="blurhash" >
class="absolute inset-0 z-[-1]" <canvas
:style="blurhashContainerStyle" ref="blurCanvas"
> class="absolute top-0 left-0 w-full h-full"
<canvas width="32"
ref="blurCanvas" height="32"
class="absolute top-0 left-0 w-full h-full" />
width="32" </div>
height="32" <!-- Main image -->
<img
:src="remoteSource"
class="w-full h-auto rounded-md transition-opacity duration-500 object-cover cursor-pointer"
:class="{ 'opacity-0': !imageLoaded && blurhash }"
@load="imageLoaded = true"
@error="imageLoaded = true"
@click="openExternally"
/> />
</div> </template>
<!-- Main image --> <audio
<img v-else-if="itemType == 'audio'"
class="w-full h-auto"
:src="remoteSource" :src="remoteSource"
class="w-full h-auto rounded-md transition-opacity duration-500" controls
:class="{ 'opacity-0': !imageLoaded && blurhash }" />
@load="imageLoaded = true" <video
@error="imageLoaded = true" 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

View 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>

View 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>

View File

@@ -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>

View File

@@ -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 }
) )

View 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>

View 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>

View File

@@ -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 }
}

View File

@@ -1,8 +1,8 @@
// 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,

View File

@@ -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-avatar <v-menu>
class="me-4" <template v-slot:activator="{ props }">
color="grey-darken-1" <v-avatar
size="32" v-bind="props"
icon="mdi-account" class="me-4"
:image="`${apiBase}/drive/files/${user?.profile.picture?.id}`" color="grey-darken-1"
/> size="32"
icon="mdi-account-circle-outline"
: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[] = [
{ {

View File

@@ -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>

View File

@@ -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>

View File

@@ -128,6 +128,10 @@ import IconDark from '~/assets/images/cloudy-lamb@dark.png'
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)

View File

@@ -11,6 +11,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
useHead({
title: "Auth Completed"
})
definePageMeta({ definePageMeta({
layout: "minimal" layout: "minimal"
}) })

View File

@@ -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}`, "*")

View File

@@ -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"
) )

View File

@@ -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
View 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>

View 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
View 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)
}

View File

@@ -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 />
</div> </div>
</div> </div>
</v-container> </v-container>
@@ -46,8 +47,22 @@ 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)

View File

@@ -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 -->
<attachment-item <div v-if="post.attachments && post.attachments.length > 0">
v-for="attachment in post.attachments" <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
:key="attachment.id" <attachment-item
:item="attachment" v-for="attachment in post.attachments"
/> :key="attachment.id"
:item="attachment"
class="w-full"
/>
</div>
</div>
</v-card-text>
</v-card>
</template>
<!-- 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> </div>
<div v-if="post.tags && post.tags.length > 0" class="mt-4"> <!-- Post Metadata -->
<v-chip <div
v-for="tag in post.tags" class="flex items-center gap-4 text-sm text-medium-emphasis mb-4"
:key="tag" >
size="small" <div class="flex items-center gap-1">
variant="outlined" <v-icon size="16">mdi-calendar</v-icon>
class="mr-2 mb-2" <span>{{ formatDate(post.createdAt) }}</span>
</div>
<div
v-if="post.updatedAt && post.updatedAt !== post.createdAt"
class="flex items-center gap-1"
> >
{{ tag }} <v-icon size="16">mdi-pencil</v-icon>
</v-chip> <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> </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-for="tag in post.tags"
:key="tag"
size="small"
variant="outlined"
color="primary"
class="cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
>
<v-icon start size="16">mdi-tag</v-icon>
{{ tag }}
</v-chip>
</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>

View 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>

View 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>

View File

@@ -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", () => {
@@ -35,9 +36,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) {
error.value = e instanceof Error ? e.message : "An error occurred" if (e instanceof FetchError && e.statusCode == 401) {
user.value = null // Clear user data on error error.value = "Unauthorized"
console.error('Failed to fetch user... ', e) user.value = null
} else {
error.value = e instanceof Error ? e.message : "An error occurred"
user.value = null // Clear user data on error
console.error("Failed to fetch user... ", e)
}
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -60,6 +66,6 @@ export const useUserStore = defineStore("user", () => {
isAuthenticated, isAuthenticated,
fetchUser, fetchUser,
setToken, setToken,
logout, logout
} }
}) })

View File

@@ -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
View 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
}

2309
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -9,22 +9,50 @@ 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"
}
},
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: {

View File

@@ -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"
} }
} }

View 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"
})
}
})