💄 Optimize attachment list
This commit is contained in:
197
app/components/AttachmentList.vue
Normal file
197
app/components/AttachmentList.vue
Normal 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>
|
||||||
@@ -20,16 +20,11 @@
|
|||||||
<div v-html="htmlContent" />
|
<div v-html="htmlContent" />
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div
|
<attachment-list :attachments="props.item.attachments" :max-height="640" />
|
||||||
v-if="props.item.attachments.length > 0"
|
|
||||||
class="d-flex gap-2 flex-wrap"
|
<div v-if="props.item.isTruncated" class="flex gap-2 text-xs opacity-80">
|
||||||
@click.stop
|
<v-icon icon="mdi-dots-horizontal" size="small" />
|
||||||
>
|
<p>Post truncated, tap to see details...</p>
|
||||||
<attachment-item
|
|
||||||
v-for="attachment in props.item.attachments"
|
|
||||||
:key="attachment.id"
|
|
||||||
:item="attachment"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Post Reactions -->
|
<!-- Post Reactions -->
|
||||||
@@ -53,7 +48,7 @@ import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
|
|||||||
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 AttachmentList from "./AttachmentList.vue"
|
||||||
import PostReactionList from "./PostReactionList.vue"
|
import PostReactionList from "./PostReactionList.vue"
|
||||||
|
|
||||||
const props = defineProps<{ item: SnPost }>()
|
const props = defineProps<{ item: SnPost }>()
|
||||||
@@ -66,14 +61,13 @@ const { render } = useMarkdownProcessor()
|
|||||||
const htmlContent = ref<string>("")
|
const htmlContent = ref<string>("")
|
||||||
|
|
||||||
function handleReaction(symbol: string, attitude: number, delta: number) {
|
function handleReaction(symbol: string, attitude: number, delta: number) {
|
||||||
emit('react', symbol, attitude, delta)
|
emit("react", symbol, attitude, delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
props.item,
|
props.item,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (value.content)
|
if (value.content) htmlContent.value = render(value.content)
|
||||||
htmlContent.value = render(value.content)
|
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
@click="showReactionDialog"
|
@click="showReactionDialog"
|
||||||
>
|
>
|
||||||
<v-icon start size="16">mdi-plus</v-icon>
|
<v-icon start size="16">mdi-plus</v-icon>
|
||||||
<span class="text-caption">React</span>
|
<span class="text-xs">React</span>
|
||||||
</v-chip>
|
</v-chip>
|
||||||
|
|
||||||
<!-- Existing Reactions -->
|
<!-- Existing Reactions -->
|
||||||
<v-chip
|
<v-chip
|
||||||
v-for="(count, symbol) in reactions"
|
v-for="(count, symbol) in reactions"
|
||||||
rounded
|
|
||||||
:key="symbol"
|
:key="symbol"
|
||||||
|
rounded
|
||||||
:color="getReactionColor(symbol)"
|
:color="getReactionColor(symbol)"
|
||||||
:disabled="submitting"
|
:disabled="submitting"
|
||||||
@click="reactToPost(symbol)"
|
@click="reactToPost(symbol)"
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="reaction-section">
|
<div class="reaction-section">
|
||||||
<div class="section-header d-flex align-center px-6 py-3">
|
<div class="section-header d-flex align-center px-6 py-3">
|
||||||
<v-icon class="me-2">mdi-emoticon-happy</v-icon>
|
<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>
|
||||||
<div class="reaction-grid">
|
<div class="reaction-grid">
|
||||||
<v-card
|
<v-card
|
||||||
@@ -51,12 +51,12 @@
|
|||||||
>
|
>
|
||||||
<div class="d-flex flex-column align-center justify-center pa-3">
|
<div class="d-flex flex-column align-center justify-center pa-3">
|
||||||
<span class="text-h4 mb-1">{{ reaction.emoji }}</span>
|
<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
|
reaction.symbol
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
v-if="getReactionCount(reaction.symbol) > 0"
|
v-if="getReactionCount(reaction.symbol) > 0"
|
||||||
class="text-caption font-weight-bold"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
x{{ getReactionCount(reaction.symbol) }}
|
x{{ getReactionCount(reaction.symbol) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
<div class="reaction-section">
|
<div class="reaction-section">
|
||||||
<div class="section-header d-flex align-center px-6 py-3">
|
<div class="section-header d-flex align-center px-6 py-3">
|
||||||
<v-icon class="me-2">mdi-emoticon-neutral</v-icon>
|
<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>
|
||||||
<div class="reaction-grid">
|
<div class="reaction-grid">
|
||||||
<v-card
|
<v-card
|
||||||
@@ -83,12 +83,12 @@
|
|||||||
>
|
>
|
||||||
<div class="d-flex flex-column align-center justify-center pa-3">
|
<div class="d-flex flex-column align-center justify-center pa-3">
|
||||||
<span class="text-h4 mb-1">{{ reaction.emoji }}</span>
|
<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
|
reaction.symbol
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
v-if="getReactionCount(reaction.symbol) > 0"
|
v-if="getReactionCount(reaction.symbol) > 0"
|
||||||
class="text-caption font-weight-bold"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
x{{ getReactionCount(reaction.symbol) }}
|
x{{ getReactionCount(reaction.symbol) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
<div class="reaction-section">
|
<div class="reaction-section">
|
||||||
<div class="section-header d-flex align-center px-6 py-3">
|
<div class="section-header d-flex align-center px-6 py-3">
|
||||||
<v-icon class="me-2">mdi-emoticon-sad</v-icon>
|
<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>
|
||||||
<div class="reaction-grid">
|
<div class="reaction-grid">
|
||||||
<v-card
|
<v-card
|
||||||
@@ -115,12 +115,12 @@
|
|||||||
>
|
>
|
||||||
<div class="d-flex flex-column align-center justify-center pa-3">
|
<div class="d-flex flex-column align-center justify-center pa-3">
|
||||||
<span class="text-h4 mb-1">{{ reaction.emoji }}</span>
|
<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
|
reaction.symbol
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
v-if="getReactionCount(reaction.symbol) > 0"
|
v-if="getReactionCount(reaction.symbol) > 0"
|
||||||
class="text-caption font-weight-bold"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
x{{ getReactionCount(reaction.symbol) }}
|
x{{ getReactionCount(reaction.symbol) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from "vue"
|
import { ref } from "vue"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
parentId: string
|
parentId: string
|
||||||
@@ -209,20 +209,21 @@ async function reactToPost(symbol: string) {
|
|||||||
try {
|
try {
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
const api = useSolarNetwork()
|
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",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
attitude: reaction.attitude
|
attitude: reaction.attitude
|
||||||
|
},
|
||||||
|
onResponse: (res) => {
|
||||||
|
statusCode = res.response.status
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if we're removing the reaction (204 status) or adding (200)
|
// 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 = statusCode === 204
|
||||||
const isRemoving =
|
|
||||||
response && typeof response === "object" && "status" in response
|
|
||||||
? (response as any).status === 204
|
|
||||||
: false
|
|
||||||
const delta = isRemoving ? -1 : 1
|
const delta = isRemoving ? -1 : 1
|
||||||
|
|
||||||
emit("react", symbol, reaction.attitude, delta)
|
emit("react", symbol, reaction.attitude, delta)
|
||||||
@@ -243,14 +244,6 @@ function selectReaction(symbol: string) {
|
|||||||
reactToPost(symbol)
|
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[] {
|
function getReactionsByAttitude(attitude: number): ReactionTemplate[] {
|
||||||
return availableReactions.filter((reaction) => reaction.attitude === attitude)
|
return availableReactions.filter((reaction) => reaction.attitude === attitude)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
density="comfortable"
|
density="comfortable"
|
||||||
>
|
>
|
||||||
<v-card-text class="flex flex-col gap-2">
|
<v-card-text class="flex flex-col gap-2">
|
||||||
<div class="flex gap-2" v-if="user?.profile?.time_zone">
|
<div v-if="user?.profile?.timeZone" class="flex gap-2">
|
||||||
<span class="flex items-center gap-2 flex-grow">
|
<span class="flex items-center gap-2 grow">
|
||||||
<v-icon>mdi-clock-outline</v-icon>
|
<v-icon>mdi-clock-outline</v-icon>
|
||||||
Time Zone
|
Time Zone
|
||||||
</span>
|
</span>
|
||||||
@@ -34,18 +34,18 @@
|
|||||||
<span>
|
<span>
|
||||||
{{
|
{{
|
||||||
new Date().toLocaleTimeString(void 0, {
|
new Date().toLocaleTimeString(void 0, {
|
||||||
timeZone: user.profile.time_zone
|
timeZone: user.profile.timeZone
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-bold">·</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 class="font-bold">·</span>
|
||||||
<span>{{ user.profile.time_zone }}</span>
|
<span>{{ user.profile.timeZone }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2" v-if="user?.profile?.location">
|
<div v-if="user?.profile?.location" class="flex gap-2">
|
||||||
<span class="flex items-center gap-2 flex-grow">
|
<span class="flex items-center gap-2 grow">
|
||||||
<v-icon>mdi-map-marker-outline</v-icon>
|
<v-icon>mdi-map-marker-outline</v-icon>
|
||||||
Location
|
Location
|
||||||
</span>
|
</span>
|
||||||
@@ -54,19 +54,19 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
v-if="user?.profile?.firstName || user?.profile?.lastName"
|
||||||
class="flex gap-2"
|
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>
|
<v-icon>mdi-account-edit-outline</v-icon>
|
||||||
Name
|
Name
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{{
|
{{
|
||||||
[
|
[
|
||||||
user.profile.first_name,
|
user.profile.firstName,
|
||||||
user.profile.middle_name,
|
user.profile.middleName,
|
||||||
user.profile.last_name
|
user.profile.lastName
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
@@ -74,10 +74,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex gap-2"
|
|
||||||
v-if="user?.profile?.gender || user?.profile?.pronouns"
|
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>
|
<v-icon>mdi-account-circle</v-icon>
|
||||||
Gender
|
Gender
|
||||||
</span>
|
</span>
|
||||||
@@ -88,16 +88,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<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>
|
<v-icon>mdi-calendar-month-outline</v-icon>
|
||||||
Joined at
|
Joined at
|
||||||
</span>
|
</span>
|
||||||
<span>{{
|
<span>{{
|
||||||
user ? new Date(user.created_at).toLocaleDateString() : ""
|
user ? new Date(user.createdAt).toLocaleDateString() : ""
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2" v-if="user?.profile?.birthday">
|
<div v-if="user?.profile?.birthday" class="flex gap-2">
|
||||||
<span class="flex items-center gap-2 flex-grow">
|
<span class="flex items-center gap-2 grow">
|
||||||
<v-icon>mdi-cake-variant-outline</v-icon>
|
<v-icon>mdi-cake-variant-outline</v-icon>
|
||||||
Birthday
|
Birthday
|
||||||
</span>
|
</span>
|
||||||
@@ -114,13 +114,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
<v-card v-if="user?.perk_subscription">
|
<v-card v-if="user?.perkSubscription">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="text-xl font-bold">
|
<div class="text-xl font-bold">
|
||||||
{{
|
{{
|
||||||
perkSubscriptionNames[user.perk_subscription.identifier]
|
perkSubscriptionNames[user.perkSubscription.identifier]
|
||||||
?.name || "Unknown"
|
?.name || "Unknown"
|
||||||
}}
|
}}
|
||||||
Tier
|
Tier
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
<v-icon
|
<v-icon
|
||||||
size="48"
|
size="48"
|
||||||
:color="
|
:color="
|
||||||
perkSubscriptionNames[user.perk_subscription.identifier]
|
perkSubscriptionNames[user.perkSubscription.identifier]
|
||||||
?.color || '#2196f3'
|
?.color || '#2196f3'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<div>{{ user?.profile?.experience || 0 }} XP</div>
|
<div>{{ user?.profile?.experience || 0 }} XP</div>
|
||||||
</div>
|
</div>
|
||||||
<v-progress-linear
|
<v-progress-linear
|
||||||
:model-value="user?.profile?.leveling_progress || 0"
|
:model-value="user?.profile?.levelingProgress || 0"
|
||||||
color="success"
|
color="success"
|
||||||
class="mb-0"
|
class="mb-0"
|
||||||
rounded
|
rounded
|
||||||
@@ -181,19 +181,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
import { unified } from "unified"
|
import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
|
||||||
import remarkParse from "remark-parse"
|
import type { SnAccount } from "~/types/api"
|
||||||
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()
|
||||||
|
|
||||||
const notFound = ref<boolean>(false)
|
const notFound = ref<boolean>(false)
|
||||||
const user = ref<any>(null)
|
const user = ref<SnAccount | null>(null)
|
||||||
|
|
||||||
const username = computed(() => {
|
const username = computed(() => {
|
||||||
const nameStr = route.params.name?.toString()
|
const nameStr = route.params.name?.toString()
|
||||||
@@ -206,7 +200,7 @@ const apiBase = useSolarNetworkUrl()
|
|||||||
const apiBaseServer = useSolarNetworkUrl()
|
const apiBaseServer = useSolarNetworkUrl()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await useFetch(
|
const { data, error } = await useFetch<SnAccount>(
|
||||||
`${apiBaseServer}/id/accounts/${username.value}`,
|
`${apiBaseServer}/id/accounts/${username.value}`,
|
||||||
{ server: true }
|
{ server: true }
|
||||||
)
|
)
|
||||||
@@ -214,7 +208,7 @@ try {
|
|||||||
if (error.value) {
|
if (error.value) {
|
||||||
console.error("Failed to fetch user:", error.value)
|
console.error("Failed to fetch user:", error.value)
|
||||||
notFound.value = true
|
notFound.value = true
|
||||||
} else {
|
} else if (data.value) {
|
||||||
user.value = data.value
|
user.value = data.value
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -246,14 +240,7 @@ const perkSubscriptionNames: Record<string, PerkSubscriptionInfo> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const processor = unified()
|
const { render } = useMarkdownProcessor()
|
||||||
.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)
|
||||||
|
|
||||||
@@ -261,7 +248,7 @@ watch(
|
|||||||
user,
|
user,
|
||||||
(value) => {
|
(value) => {
|
||||||
htmlBio.value = value?.profile.bio
|
htmlBio.value = value?.profile.bio
|
||||||
? String(processor.processSync(value.profile.bio))
|
? render(value.profile.bio)
|
||||||
: undefined
|
: undefined
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
|
|||||||
@@ -74,16 +74,7 @@
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- Attachments within Content Section -->
|
<!-- Attachments within Content Section -->
|
||||||
<div v-if="post.attachments && post.attachments.length > 0">
|
<attachment-list :attachments="post.attachments || []" />
|
||||||
<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-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -149,16 +140,7 @@
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- Attachments within Merged Section -->
|
<!-- Attachments within Merged Section -->
|
||||||
<div v-if="post.attachments && post.attachments.length > 0">
|
<attachment-list :attachments="post.attachments || []" />
|
||||||
<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-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -210,7 +192,7 @@ import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
|
|||||||
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 AttachmentList from "~/components/AttachmentList.vue"
|
||||||
import PostReactionList from "~/components/PostReactionList.vue"
|
import PostReactionList from "~/components/PostReactionList.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
Reference in New Issue
Block a user