Compare commits

...

2 Commits

Author SHA1 Message Date
f851bcd646 💄 Optimize attachment list 2025-11-05 23:42:10 +08:00
412ebbd083 ♻️ Refactored the post item 2025-11-05 22:36:24 +08:00
12 changed files with 1051 additions and 612 deletions

View File

@@ -0,0 +1,197 @@
<template>
<div v-if="attachments.length > 0" @click.stop>
<!-- Single attachment: direct render -->
<attachment-item
v-if="attachments.length === 1 && attachments[0]"
:item="attachments[0]"
/>
<!-- Multiple attachments -->
<template v-else>
<!-- All images: use carousel -->
<div
v-if="isAllImages"
class="carousel-container rounded-lg overflow-hidden"
:style="carouselStyle"
>
<v-card width="100%" border>
<v-carousel
height="100%"
hide-delimiter-background
show-arrows="hover"
hide-delimiters
progress="primary"
>
<v-carousel-item
v-for="attachment in attachments"
:key="attachment.id"
:src="getAttachmentUrl(attachment)"
cover
/>
</v-carousel>
</v-card>
</div>
<!-- Mixed content: vertical scrollable -->
<div v-else class="space-y-4 max-h-96 overflow-y-auto">
<attachment-item
v-for="attachment in attachments"
:key="attachment.id"
:item="attachment"
/>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue"
import type { SnAttachment } from "~/types/api"
import AttachmentItem from "./AttachmentItem.vue"
const props = defineProps<{
attachments: SnAttachment[]
maxHeight?: number
}>()
const apiBase = useSolarNetworkUrl()
const isAllImages = computed(
() =>
props.attachments.length > 0 &&
props.attachments.every((att) => att.mimeType?.startsWith("image/"))
)
const carouselHeight = computed(() => {
if (!isAllImages.value) return Math.min(400, props.maxHeight || 400)
const aspectRatio = calculateAspectRatio()
// Use a base width of 600px for calculation, adjust height accordingly
const baseWidth = 600
const calculatedHeight = Math.round(baseWidth / aspectRatio)
// Respect maxHeight constraint if provided
const constrainedHeight = props.maxHeight
? Math.min(calculatedHeight, props.maxHeight)
: calculatedHeight
return constrainedHeight
})
const carouselStyle = computed(() => {
if (!isAllImages.value) return {}
const aspectRatio = calculateAspectRatio()
const height = carouselHeight.value
const width = Math.round(height * aspectRatio)
return {
width: `${width}px`,
height: `${height}px`,
maxWidth: "100%" // Ensure it doesn't overflow container
}
})
function calculateAspectRatio(): number {
const ratios: number[] = []
// Collect all valid ratios
for (const attachment of props.attachments) {
const meta = attachment.fileMeta
if (meta && typeof meta === "object" && "ratio" in meta) {
const ratioValue = (meta as Record<string, unknown>).ratio
if (typeof ratioValue === "number" && ratioValue > 0) {
ratios.push(ratioValue)
} else if (typeof ratioValue === "string") {
try {
const parsed = parseFloat(ratioValue)
if (parsed > 0) ratios.push(parsed)
} catch {
// Skip invalid string ratios
}
}
}
}
if (ratios.length === 0) {
// Default to 4:3 aspect ratio when no valid ratios found
return 4 / 3
}
if (ratios.length === 1 && ratios[0]) {
return ratios[0]
}
// Group similar ratios and find the most common one
const commonRatios: Record<number, number> = {}
// Common aspect ratios to round to (with tolerance)
const tolerance = 0.05
const standardRatios = [
1.0,
4 / 3,
3 / 2,
16 / 9,
5 / 3,
5 / 4,
7 / 5,
9 / 16,
2 / 3,
3 / 4,
4 / 5
]
for (const ratio of ratios) {
// Find the closest standard ratio within tolerance
let closestRatio = ratio
let minDiff = Infinity
for (const standard of standardRatios) {
const diff = Math.abs(ratio - standard)
if (diff < minDiff && diff <= tolerance) {
minDiff = diff
closestRatio = standard
}
}
// If no standard ratio is close enough, keep original
if (minDiff === Infinity || minDiff > tolerance) {
closestRatio = ratio
}
commonRatios[closestRatio] = (commonRatios[closestRatio] || 0) + 1
}
// Find the most frequent ratio(s)
let maxCount = 0
const mostFrequent: number[] = []
for (const ratio of Object.keys(commonRatios)) {
const ratioNum = parseFloat(ratio)
const count = commonRatios[ratioNum] || 0
if (count > maxCount) {
maxCount = count
mostFrequent.length = 0
mostFrequent.push(ratioNum)
} else if (count === maxCount) {
mostFrequent.push(ratioNum)
}
}
// If only one most frequent ratio, return it
if (mostFrequent.length === 1 && mostFrequent[0]) {
return mostFrequent[0]
}
// If multiple ratios have the same highest frequency, use median of them
mostFrequent.sort((a, b) => a - b)
const mid = Math.floor(mostFrequent.length / 2)
return mostFrequent.length % 2 === 0
? (mostFrequent[mid - 1]! + mostFrequent[mid]!) / 2
: mostFrequent[mid]!
}
function getAttachmentUrl(attachment: SnAttachment): string {
return `${apiBase}/drive/files/${attachment.id}`
}
</script>

View File

@@ -20,16 +20,11 @@
<div v-html="htmlContent" />
</article>
<div
v-if="props.item.attachments.length > 0"
class="d-flex gap-2 flex-wrap"
@click.stop
>
<attachment-item
v-for="attachment in props.item.attachments"
:key="attachment.id"
:item="attachment"
/>
<attachment-list :attachments="props.item.attachments" :max-height="640" />
<div v-if="props.item.isTruncated" class="flex gap-2 text-xs opacity-80">
<v-icon icon="mdi-dots-horizontal" size="small" />
<p>Post truncated, tap to see details...</p>
</div>
<!-- Post Reactions -->
@@ -49,65 +44,30 @@
<script lang="ts" setup>
import { ref, watch } from "vue"
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 { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
import type { SnPost } from "~/types/api"
import PostHeader from "./PostHeader.vue"
import AttachmentItem from "./AttachmentItem.vue"
import AttachmentList from "./AttachmentList.vue"
import PostReactionList from "./PostReactionList.vue"
const props = defineProps<{ item: SnPost }>()
const emit = defineEmits<{
react: [symbol: string, attitude: number, delta: number]
}>()
const processor = unified()
.use(remarkParse)
.use(remarkMath)
.use(remarkBreaks)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeKatex)
.use(rehypeStringify)
const { render } = useMarkdownProcessor()
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 }
emit("react", symbol, attitude, delta)
}
watch(
props.item,
(value) => {
if (value.content)
htmlContent.value = String(processor.processSync(value.content))
if (value.content) htmlContent.value = render(value.content)
},
{ immediate: true, deep: true }
)

View File

@@ -9,14 +9,14 @@
@click="showReactionDialog"
>
<v-icon start size="16">mdi-plus</v-icon>
<span class="text-caption">React</span>
<span class="text-xs">React</span>
</v-chip>
<!-- Existing Reactions -->
<v-chip
v-for="(count, symbol) in reactions"
rounded
:key="symbol"
rounded
:color="getReactionColor(symbol)"
:disabled="submitting"
@click="reactToPost(symbol)"
@@ -38,7 +38,7 @@
<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>
<span class="font-bold">Positive</span>
</div>
<div class="reaction-grid">
<v-card
@@ -51,12 +51,12 @@
>
<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">{{
<span class="text-xs text-center mb-1">{{
reaction.symbol
}}</span>
<span
v-if="getReactionCount(reaction.symbol) > 0"
class="text-caption font-weight-bold"
class="text-xs"
>
x{{ getReactionCount(reaction.symbol) }}
</span>
@@ -70,7 +70,7 @@
<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>
<span class="font-bold">Neutral</span>
</div>
<div class="reaction-grid">
<v-card
@@ -83,12 +83,12 @@
>
<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">{{
<span class="text-xs text-center mb-1">{{
reaction.symbol
}}</span>
<span
v-if="getReactionCount(reaction.symbol) > 0"
class="text-caption font-weight-bold"
class="text-xs"
>
x{{ getReactionCount(reaction.symbol) }}
</span>
@@ -102,7 +102,7 @@
<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>
<span class="font-bold">Negative</span>
</div>
<div class="reaction-grid">
<v-card
@@ -115,12 +115,12 @@
>
<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">{{
<span class="text-xs text-center mb-1">{{
reaction.symbol
}}</span>
<span
v-if="getReactionCount(reaction.symbol) > 0"
class="text-caption font-weight-bold"
class="text-xs"
>
x{{ getReactionCount(reaction.symbol) }}
</span>
@@ -135,7 +135,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from "vue"
import { ref } from "vue"
interface Props {
parentId: string
@@ -209,20 +209,21 @@ async function reactToPost(symbol: string) {
try {
submitting.value = true
const api = useSolarNetwork()
const response = await api(`/sphere/posts/${props.parentId}/reactions`, {
let statusCode = 200 // default status
await api(`/sphere/posts/${props.parentId}/reactions`, {
method: "POST",
body: {
symbol: symbol,
attitude: reaction.attitude
},
onResponse: (res) => {
statusCode = res.response.status
}
})
// 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 isRemoving = statusCode === 204
const delta = isRemoving ? -1 : 1
emit("react", symbol, reaction.attitude, delta)
@@ -243,14 +244,6 @@ function selectReaction(symbol: string) {
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)
}

View File

@@ -0,0 +1,22 @@
import { createMarkdownExit } from "markdown-exit"
// @ts-ignore
import texmath from "markdown-it-texmath"
import katex from "katex"
export function useMarkdownProcessor() {
const processor = createMarkdownExit({
breaks: true,
html: true,
linkify: true,
typographer: true
// @ts-ignore
}).use(texmath, {
engine: katex,
delimiters: 'dollars',
katexOptions: { macros: { "\\RR": "\\mathbb{R}" } }
})
return {
render: (content: string) => processor.render(content)
}
}

View File

@@ -1,6 +1,6 @@
<template>
<v-app :theme="colorMode.preference">
<v-app-bar flat class="app-bar-blur">
<v-app-bar elevation="2" color="surface-lighten-5">
<v-container class="mx-auto d-flex align-center justify-center">
<img
:src="colorMode.value == 'dark' ? IconDark : IconLight"
@@ -22,7 +22,7 @@
<v-spacer />
<v-menu>
<template v-slot:activator="{ props }">
<template #activator="{ props }">
<v-avatar
v-bind="props"
class="me-4"
@@ -82,22 +82,3 @@ const links: NavLink[] = [
}
]
</script>
<style scoped>
.app-bar-blur {
-webkit-mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 40%,
rgba(0, 0, 0, 0.5) 65%,
rgba(0, 0, 0, 0) 100%
);
mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 40%,
rgba(0, 0, 0, 0.5) 65%,
rgba(0, 0, 0, 0) 100%
);
mask-repeat: no-repeat;
mask-size: 100%;
}
</style>

View File

@@ -25,8 +25,8 @@
density="comfortable"
>
<v-card-text class="flex flex-col gap-2">
<div class="flex gap-2" v-if="user?.profile?.time_zone">
<span class="flex items-center gap-2 flex-grow">
<div v-if="user?.profile?.timeZone" class="flex gap-2">
<span class="flex items-center gap-2 grow">
<v-icon>mdi-clock-outline</v-icon>
Time Zone
</span>
@@ -34,18 +34,18 @@
<span>
{{
new Date().toLocaleTimeString(void 0, {
timeZone: user.profile.time_zone
timeZone: user.profile.timeZone
})
}}
</span>
<span class="font-bold">·</span>
<span>{{ getOffsetUTCString(user.profile.time_zone) }}</span>
<span>{{ getOffsetUTCString(user.profile.timeZone) }}</span>
<span class="font-bold">·</span>
<span>{{ user.profile.time_zone }}</span>
<span>{{ user.profile.timeZone }}</span>
</span>
</div>
<div class="flex gap-2" v-if="user?.profile?.location">
<span class="flex items-center gap-2 flex-grow">
<div v-if="user?.profile?.location" class="flex gap-2">
<span class="flex items-center gap-2 grow">
<v-icon>mdi-map-marker-outline</v-icon>
Location
</span>
@@ -54,19 +54,19 @@
</span>
</div>
<div
v-if="user?.profile?.firstName || user?.profile?.lastName"
class="flex gap-2"
v-if="user?.profile?.first_name || user?.profile?.last_name"
>
<span class="flex items-center gap-2 flex-grow">
<span class="flex items-center gap-2 grow">
<v-icon>mdi-account-edit-outline</v-icon>
Name
</span>
<span>
{{
[
user.profile.first_name,
user.profile.middle_name,
user.profile.last_name
user.profile.firstName,
user.profile.middleName,
user.profile.lastName
]
.filter(Boolean)
.join(" ")
@@ -74,10 +74,10 @@
</span>
</div>
<div
class="flex gap-2"
v-if="user?.profile?.gender || user?.profile?.pronouns"
class="flex gap-2"
>
<span class="flex items-center gap-2 flex-grow">
<span class="flex items-center gap-2 grow">
<v-icon>mdi-account-circle</v-icon>
Gender
</span>
@@ -88,16 +88,16 @@
</span>
</div>
<div class="flex gap-2">
<span class="flex items-center gap-2 flex-grow">
<span class="flex items-center gap-2 grow">
<v-icon>mdi-calendar-month-outline</v-icon>
Joined at
</span>
<span>{{
user ? new Date(user.created_at).toLocaleDateString() : ""
user ? new Date(user.createdAt).toLocaleDateString() : ""
}}</span>
</div>
<div class="flex gap-2" v-if="user?.profile?.birthday">
<span class="flex items-center gap-2 flex-grow">
<div v-if="user?.profile?.birthday" class="flex gap-2">
<span class="flex items-center gap-2 grow">
<v-icon>mdi-cake-variant-outline</v-icon>
Birthday
</span>
@@ -114,13 +114,13 @@
</div>
</v-card-text>
</v-card>
<v-card v-if="user?.perk_subscription">
<v-card v-if="user?.perkSubscription">
<v-card-text>
<div class="flex justify-between items-center">
<div class="flex flex-col">
<div class="text-xl font-bold">
{{
perkSubscriptionNames[user.perk_subscription.identifier]
perkSubscriptionNames[user.perkSubscription.identifier]
?.name || "Unknown"
}}
Tier
@@ -130,7 +130,7 @@
<v-icon
size="48"
:color="
perkSubscriptionNames[user.perk_subscription.identifier]
perkSubscriptionNames[user.perkSubscription.identifier]
?.color || '#2196f3'
"
>
@@ -146,7 +146,7 @@
<div>{{ user?.profile?.experience || 0 }} XP</div>
</div>
<v-progress-linear
:model-value="user?.profile?.leveling_progress || 0"
:model-value="user?.profile?.levelingProgress || 0"
color="success"
class="mb-0"
rounded
@@ -181,19 +181,13 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue"
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"
import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
import type { SnAccount } from "~/types/api"
const route = useRoute()
const notFound = ref<boolean>(false)
const user = ref<any>(null)
const user = ref<SnAccount | null>(null)
const username = computed(() => {
const nameStr = route.params.name?.toString()
@@ -206,7 +200,7 @@ const apiBase = useSolarNetworkUrl()
const apiBaseServer = useSolarNetworkUrl()
try {
const { data, error } = await useFetch(
const { data, error } = await useFetch<SnAccount>(
`${apiBaseServer}/id/accounts/${username.value}`,
{ server: true }
)
@@ -214,7 +208,7 @@ try {
if (error.value) {
console.error("Failed to fetch user:", error.value)
notFound.value = true
} else {
} else if (data.value) {
user.value = data.value
}
} catch (err) {
@@ -246,14 +240,7 @@ const perkSubscriptionNames: Record<string, PerkSubscriptionInfo> = {
}
}
const processor = unified()
.use(remarkParse)
.use(remarkMath)
.use(remarkBreaks)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeKatex)
.use(rehypeStringify)
const { render } = useMarkdownProcessor()
const htmlBio = ref<string | undefined>(undefined)
@@ -261,7 +248,7 @@ watch(
user,
(value) => {
htmlBio.value = value?.profile.bio
? String(processor.processSync(value.profile.bio))
? render(value.profile.bio)
: undefined
},
{ immediate: true, deep: true }

View File

@@ -74,16 +74,7 @@
</article>
<!-- 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
v-for="attachment in post.attachments"
:key="attachment.id"
:item="attachment"
class="w-full"
/>
</div>
</div>
<attachment-list :attachments="post.attachments || []" />
</v-card-text>
</v-card>
</template>
@@ -149,16 +140,7 @@
</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>
<attachment-list :attachments="post.attachments || []" />
</v-card-text>
</v-card>
</template>
@@ -206,33 +188,19 @@
<script setup lang="ts">
import { computed } from "vue"
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 { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
import type { SnPost } from "~/types/api"
import PostHeader from "~/components/PostHeader.vue"
import AttachmentItem from "~/components/AttachmentItem.vue"
import AttachmentList from "~/components/AttachmentList.vue"
import PostReactionList from "~/components/PostReactionList.vue"
const route = useRoute()
const id = route.params.id as string
const processor = unified()
.use(remarkParse)
.use(remarkMath)
.use(remarkBreaks)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeKatex)
.use(rehypeStringify)
const { render } = useMarkdownProcessor()
const apiServer = useSolarNetwork(true)
const apiServer = useSolarNetwork()
const {
data: postData,
@@ -244,7 +212,7 @@ const {
const post = resp as SnPost
let html = ""
if (post.content) {
html = String(processor.processSync(post.content))
html = render(post.content)
}
return { post, html }
} catch (e) {
@@ -314,7 +282,7 @@ function handleReaction(symbol: string, attitude: number, delta: number) {
if (!post.value) return
// Update the reactions count
const reactions = (post.value as any).reactions || {}
const reactions = post.value.reactionsCount || {}
const currentCount = reactions[symbol] || 0
const newCount = Math.max(0, currentCount + delta)
@@ -325,7 +293,7 @@ function handleReaction(symbol: string, attitude: number, delta: number) {
}
// Update the reactionsMade status
const reactionsMade = (post.value as any).reactionsMade || {}
const reactionsMade = post.value.reactionsMade || {}
if (delta > 0) {
reactionsMade[symbol] = true
} else {
@@ -333,7 +301,7 @@ function handleReaction(symbol: string, attitude: number, delta: number) {
}
// Update the post object
;(post.value as any).reactions = reactions
;(post.value as any).reactionsMade = reactionsMade
post.value.reactionsCount = reactions
post.value.reactionsMade = reactionsMade
}
</script>

View File

@@ -36,7 +36,13 @@ export const useUserStore = defineStore("user", () => {
user.value = response
console.log(`[UserStore] Logged in as @${user.value.name}`)
} catch (e: unknown) {
if (e instanceof FetchError && e.statusCode == 401) {
// Check for 401 Unauthorized error
const is401Error = (e instanceof FetchError && e.statusCode === 401) ||
(e && typeof e === 'object' && 'status' in e && (e as { status: number }).status === 401) ||
(e && typeof e === 'object' && 'statusCode' in e && (e as { statusCode: number }).statusCode === 401) ||
(e instanceof Error && (e.message?.includes('401') || e.message?.includes('Unauthorized')))
if (is401Error) {
error.value = "Unauthorized"
user.value = null
} else {

10
app/types/markdown-it-texmath.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
declare module 'markdown-it-texmath' {
interface TexMathOptions {
engine?: any
delimiters?: string
katexOptions?: Record<string, any>
}
function texmath(options?: TexMathOptions): any
export default texmath
}

1130
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,8 @@ export default withNuxt(
"vue/multi-word-component-names": "off",
"vue/no-v-html": "off",
"vue/html-self-closing": "off",
"@typescript-eslint/ban-ts-comment": "off"
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-dynamic-delete": "off"
}
}
)

View File

@@ -19,39 +19,31 @@
"@nuxtjs/color-mode": "3.5.2",
"@nuxtjs/i18n": "10.1.0",
"@pinia/nuxt": "0.11.2",
"@tailwindcss/typography": "^0.5.18",
"@tailwindcss/vite": "^4.1.13",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^13.9.0",
"blurhash": "^2.0.5",
"cfturnstile-vue3": "^2.0.0",
"eslint": "^9.36.0",
"katex": "^0.16.22",
"eslint": "^9.39.1",
"katex": "^0.16.25",
"luxon": "^3.7.2",
"nuxt": "^4.1.2",
"nuxt-og-image": "^5.1.11",
"markdown-exit": "^1.0.0-beta.6",
"markdown-it-texmath": "^1.0.0",
"nuxt": "^4.2.0",
"nuxt-og-image": "^5.1.12",
"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",
"swagger-ui-dist": "^5.30.2",
"tailwindcss": "^4.1.16",
"tus-js-client": "^4.3.1",
"unified": "^11.0.5",
"vue": "^3.5.21",
"vue-router": "^4.5.1",
"vue": "^3.5.22",
"vue-router": "^4.6.3",
"vuetify-nuxt-module": "0.18.7"
},
"devDependencies": {
"@mdi/font": "^7.4.47",
"@types/luxon": "^3.7.1",
"@types/node": "^24.5.2"
"@types/node": "^24.10.0"
}
}