♻️ Migrate the exploring page

This commit is contained in:
2025-09-19 01:04:05 +08:00
parent 773cc220e0
commit 7904ce9ca7
13 changed files with 498 additions and 9 deletions

View File

@@ -0,0 +1,16 @@
<template>
<img v-if="itemType == 'image'" :src="remoteSource" class="rounded-md">
<audio v-else-if="itemType == 'audio'" :src="remoteSource" controls />
<video v-else-if="itemType == 'video'" :src="remoteSource" controls />
</template>
<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps<{ item: any }>()
const itemType = computed(() => props.item.mime_type.split('/')[0] ?? 'unknown')
const apiBase = useSolarNetworkUrl();
const remoteSource = computed(() => `${apiBase}/drive/files/${props.item.id}?original=true`)
</script>

View File

@@ -0,0 +1,131 @@
<template>
<div class="flex flex-col gap-2">
<pub-select v-model:value="publisher" />
<v-textarea
v-model="content"
placeholder="What's happended?!"
@keydown.meta.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 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">
Post
<template #append>
<v-icon>mdi-send</v-icon>
</template>
</v-btn>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import * as tus from 'tus-js-client'
import { useSolarNetwork } from '~/composables/useSolarNetwork'
import PubSelect from './PubSelect.vue'
const emits = defineEmits(['posted'])
const publisher = ref<string | undefined>()
const content = ref('')
const selectedFiles = ref<File[]>([])
const fileList = ref<any[]>([])
const submitting = ref(false)
async function submit() {
submitting.value = true
const api = useSolarNetwork()
await api(`/posts?pub=${publisher.value}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
content: content.value,
attachments: fileList.value
.filter((e) => e.url != null)
.map((e) => e.url!.split('/').reverse()[0]),
}),
})
submitting.value = false
content.value = ''
fileList.value = []
emits('posted')
}
function handleFileSelect() {
selectedFiles.value.forEach(file => {
uploadFile(file)
})
selectedFiles.value = []
}
function uploadFile(file: File) {
const upload = new tus.Upload(file, {
endpoint: '/cgi/drive/tus',
retryDelays: [0, 3000, 5000, 10000, 20000],
removeFingerprintOnSuccess: false,
uploadDataDuringCreation: false,
metadata: {
filename: file.name,
'content-type': file.type ?? 'application/octet-stream',
},
headers: {
'X-DirectUpload': 'true',
},
onShouldRetry: () => false,
onError: function (error) {
console.error('[DRIVE] Upload failed:', error)
},
onProgress: function (bytesUploaded, bytesTotal) {
// Could show progress
},
onSuccess: function (payload) {
const rawInfo = payload.lastResponse.getHeader('x-fileinfo')
const jsonInfo = JSON.parse(rawInfo as string)
console.log('[DRIVE] Upload successful: ', jsonInfo)
fileList.value.push({
name: file.name,
url: `/cgi/drive/files/${jsonInfo.id}`,
type: jsonInfo.mime_type,
})
},
onBeforeRequest: function (req) {
const xhr = req.getUnderlyingObject()
xhr.withCredentials = true
},
})
upload.findPreviousUploads().then(function (previousUploads) {
if (previousUploads.length) {
upload.resumeFromPreviousUpload(previousUploads[0])
}
upload.start()
})
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<div class="flex gap-3 items-center">
<v-avatar :image="publisherAvatar" size="40" />
<div class="flex-grow-1 flex flex-col">
<p class="flex gap-1 items-baseline">
<span class="font-bold">{{ props.item.publisher.nick }}</span>
<span class="text-xs">@{{ props.item.publisher.name }}</span>
</p>
<p class="text-xs flex gap-1">
<span>{{ DateTime.fromISO(props.item.created_at).toRelative() }}</span>
<span class="font-bold">·</span>
<span>{{ DateTime.fromISO(props.item.created_at).toLocaleString() }}</span>
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { DateTime } from 'luxon'
const props = defineProps<{ item: any }>()
const apiBase = useSolarNetworkUrl();
const publisherAvatar = computed(() =>
props.item.publisher.picture ? `${apiBase}/drive/files/${props.item.publisher.picture.id}` : undefined,
)
</script>

View File

@@ -0,0 +1,50 @@
<template>
<v-card>
<v-card-text>
<div class="flex flex-col gap-3">
<post-header :item="props.item" />
<div v-if="props.item.title || props.item.description">
<h2 v-if="props.item.title" class="text-lg">{{ props.item.title }}</h2>
<p v-if="props.item.description" class="text-sm">
{{ props.item.description }}
</p>
</div>
<article v-if="htmlContent" class="prose prose-sm dark:prose-invert prose-slate prose-p:m-0">
<div v-html="htmlContent"/>
</article>
<div v-if="props.item.attachments" class="d-flex gap-2 flex-wrap">
<attachment-item
v-for="attachment in props.item.attachments"
:key="attachment.id"
:item="attachment"
/>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Marked } from 'marked'
import PostHeader from './PostHeader.vue'
import AttachmentItem from './AttachmentItem.vue'
const props = defineProps<{ item: any }>()
const marked = new Marked()
const htmlContent = ref<string>('')
watch(
props.item,
async (value) => {
if (value.content) htmlContent.value = await marked.parse(value.content)
},
{ immediate: true, deep: true },
)
</script>

View File

@@ -0,0 +1,55 @@
<template>
<v-select
:items="pubStore.publishers"
item-title="nick"
item-value="name"
:model-value="props.value"
@update:model-value="(v) => emits('update:value', v)"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #prepend>
<v-avatar
:image="item.raw.picture ? `${apiBase}/api/drive/files/${item.raw.picture.id}` : undefined"
size="small"
rounded
/>
</template>
<v-list-item-title>{{ item.raw?.nick }}</v-list-item-title>
<v-list-item-subtitle>@{{ item.raw?.name }}</v-list-item-subtitle>
</v-list-item>
</template>
<template #selection="{ item }">
<div class="d-flex align-center">
<v-avatar
:image="item.raw.picture ? `${apiBase}/api/drive/files/${item.raw.picture.id}` : undefined"
size="24"
rounded
class="me-2"
/>
{{ item.raw?.nick }}
</div>
</template>
</v-select>
</template>
<script setup lang="ts">
import { usePubStore } from '~/stores/pub'
import { watch } from 'vue'
const pubStore = usePubStore()
const apiBase = useSolarNetworkUrl()
const props = defineProps<{ value: string | undefined }>()
const emits = defineEmits(['update:value'])
watch(
pubStore,
(value) => {
if (!props.value && value.publishers) {
emits('update:value', pubStore.publishers[0]?.name)
}
},
{ deep: true, immediate: true },
)
</script>

View File

@@ -12,7 +12,7 @@ export function useCustomTheme(): {
valueLight: "light",
initialValue: "light",
onChanged: (dark: boolean) => {
$vuetify.theme.global.name.value = dark ? "dark" : "light"
$vuetify.theme.change(dark ? "dark" : "light")
}
})

View File

@@ -0,0 +1,10 @@
// Solar Network aka the api client
export const useSolarNetwork = () => {
const apiBase = useSolarNetworkUrl();
return $fetch.create({ baseURL: apiBase, credentials: 'include' })
}
export const useSolarNetworkUrl = () => {
const config = useRuntimeConfig()
return config.public.apiBase
}

View File

@@ -1,5 +1,88 @@
<template>
<v-container>
<h1>Welcome!</h1>
<v-row>
<v-col cols="12" md="8" :order="$vuetify.display.lgAndUp ? 1 : 2">
<v-infinite-scroll style="height: calc(100vh - 57px)" :distance="10" @load="fetchActivites">
<div v-for="activity in activites" :key="activity.id" class="mt-4">
<post-item
v-if="activity.type.startsWith('posts')"
:item="activity.data"
@click="router.push('/posts/' + activity.id)"
/>
</div>
</v-infinite-scroll>
</v-col>
<v-col cols="12" md="4" :order="$vuetify.display.lgAndUp ? 2 : 1">
<v-card v-if="!userStore.user" class="w-full mt-4" title="About">
<v-card-text>
<p>Welcome to the <b>Solar Network</b></p>
<p>The open social network. Friendly to everyone.</p>
<p class="mt-4 opacity-75 text-xs">
<span v-if="version == null">Loading...</span>
<span v-else>
v{{ version.version }} @
{{ version.commit.substring(0, 6) }}
{{ version.updatedAt }}
</span>
</p>
</v-card-text>
</v-card>
<v-card class="mt-4 w-full">
<v-card-text>
<post-editor @posted="refreshActivities" />
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useUserStore } from '~/stores/user'
import { useSolarNetwork } from '~/composables/useSolarNetwork'
import PostEditor from '~/components/PostEditor.vue'
import PostItem from '~/components/PostItem.vue'
const router = useRouter()
const userStore = useUserStore()
const version = ref<any>(null)
async function fetchVersion() {
const api = useSolarNetwork()
const resp = await api('/sphere/version')
version.value = resp
}
onMounted(() => fetchVersion())
const loading = ref(false)
const activites = ref<any[]>([])
const activitesLast = computed(() => activites.value[Math.max(activites.value.length - 1, 0)])
const activitesHasMore = ref(true)
async function fetchActivites() {
if (loading.value) return
if (!activitesHasMore.value) return
loading.value = true
const api = useSolarNetwork()
const resp = await api(
activitesLast.value == null
? '/sphere/activities'
: `/sphere/activities?cursor=${new Date(activitesLast.value.created_at).toISOString()}`,
)
const data = resp
activites.value = [...activites.value, ...data]
activitesHasMore.value = data[0]?.type != 'empty'
loading.value = false
}
onMounted(() => fetchActivites())
async function refreshActivities() {
activites.value = []
fetchActivites()
}
</script>

15
app/stores/pub.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useSolarNetwork } from '~/composables/useSolarNetwork'
export const usePubStore = defineStore('pub', () => {
const publishers = ref<any[]>([])
async function fetchPublishers() {
const api = useSolarNetwork()
const resp = await api('/publishers')
publishers.value = resp as any[]
}
return { publishers, fetchPublishers }
})

45
app/stores/user.ts Normal file
View File

@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useSolarNetwork } from '~/composables/useSolarNetwork'
export const useUserStore = defineStore('user', () => {
// State
const user = ref<any>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Getters
const isAuthenticated = computed(() => !!user.value)
// Actions
async function fetchUser(reload = true) {
if (!reload && user.value) return // Skip fetching if already loaded and not forced to
isLoading.value = true
error.value = null
const api = useSolarNetwork()
try {
const response = await api('/id/accounts/me')
user.value = response
} catch (e: any) {
error.value = e.message
user.value = null // Clear user data on error
} finally {
isLoading.value = false
}
}
function logout() {
user.value = null
localStorage.removeItem('authToken')
}
return {
user,
isLoading,
error,
isAuthenticated,
fetchUser,
logout,
}
})