This commit is contained in:
1 parent
a325298eb6
commit
1a4fd54b68
5 files changed
+342
-2
No files matched your search
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@/helpers/skin-site-api'
|
} from '@/helpers/skin-site-api'
|
||||||
|
|
||||||
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
|
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
|
||||||
|
import WikiBrowser from './wiki/WikiBrowser.vue'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { handleError, addNotification } = injectNotificationManager()
|
const { handleError, addNotification } = injectNotificationManager()
|
||||||
@@ -259,6 +260,8 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<WikiBrowser @navigate="onNavigate" />
|
||||||
|
|
||||||
<div v-if="loading" class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary">
|
<div v-if="loading" class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary">
|
||||||
<SpinnerIcon class="size-4 animate-spin" />
|
<SpinnerIcon class="size-4 animate-spin" />
|
||||||
{{ formatMessage(messages.loading) }}
|
{{ formatMessage(messages.loading) }}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SpinnerIcon } from '@modrinth/assets'
|
||||||
|
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type SkinSiteWikiPageSummary,
|
||||||
|
type SkinSiteWikiSearchPage,
|
||||||
|
skinSiteWikiCategory,
|
||||||
|
skinSiteWikiRecent,
|
||||||
|
skinSiteWikiSearch,
|
||||||
|
} from '@/helpers/skin-site-api'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ navigate: [title: string] }>()
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
searchPlaceholder: {
|
||||||
|
id: 'app.skin-site.wiki.browser.search-placeholder',
|
||||||
|
defaultMessage: 'Search wiki pages…',
|
||||||
|
},
|
||||||
|
tabSearch: { id: 'app.skin-site.wiki.browser.tab-search', defaultMessage: 'Search' },
|
||||||
|
tabRecent: { id: 'app.skin-site.wiki.browser.tab-recent', defaultMessage: 'Recent' },
|
||||||
|
tabCategory: { id: 'app.skin-site.wiki.browser.tab-category', defaultMessage: 'Category' },
|
||||||
|
categoryPlaceholder: {
|
||||||
|
id: 'app.skin-site.wiki.browser.category-placeholder',
|
||||||
|
defaultMessage: 'Category name',
|
||||||
|
},
|
||||||
|
loadMore: { id: 'app.skin-site.wiki.browser.load-more', defaultMessage: 'Load more' },
|
||||||
|
emptySearch: {
|
||||||
|
id: 'app.skin-site.wiki.browser.empty-search',
|
||||||
|
defaultMessage: 'No pages match your search.',
|
||||||
|
},
|
||||||
|
emptyList: {
|
||||||
|
id: 'app.skin-site.wiki.browser.empty-list',
|
||||||
|
defaultMessage: 'Nothing here yet.',
|
||||||
|
},
|
||||||
|
hintSearch: {
|
||||||
|
id: 'app.skin-site.wiki.browser.hint-search',
|
||||||
|
defaultMessage: 'Type a keyword to search.',
|
||||||
|
},
|
||||||
|
hintCategory: {
|
||||||
|
id: 'app.skin-site.wiki.browser.hint-category',
|
||||||
|
defaultMessage: 'Enter a category name to browse.',
|
||||||
|
},
|
||||||
|
byAuthor: {
|
||||||
|
id: 'app.skin-site.wiki.browser.by-author',
|
||||||
|
defaultMessage: 'by {author}',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type Tab = 'search' | 'recent' | 'category'
|
||||||
|
const tab = ref<Tab>('recent')
|
||||||
|
|
||||||
|
const keyword = ref('')
|
||||||
|
const categoryName = ref('')
|
||||||
|
|
||||||
|
const items = ref<SkinSiteWikiPageSummary[]>([])
|
||||||
|
const nextCursor = ref<number | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
const canLoadMore = computed(() => nextCursor.value != null)
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleDateString()
|
||||||
|
} catch {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
items.value = []
|
||||||
|
nextCursor.value = null
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
const q = keyword.value.trim()
|
||||||
|
if (!q) {
|
||||||
|
resetState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiSearch(q)
|
||||||
|
if (!res.success) throw new Error(res.errorMessage)
|
||||||
|
items.value = res.payload ?? []
|
||||||
|
nextCursor.value = null
|
||||||
|
loaded.value = true
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
resetState()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPage(mode: 'recent' | 'category', append: boolean) {
|
||||||
|
if (mode === 'category' && !categoryName.value.trim()) {
|
||||||
|
resetState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const before = append ? (nextCursor.value ?? undefined) : undefined
|
||||||
|
const res: { success: boolean; payload?: SkinSiteWikiSearchPage; errorMessage?: string } =
|
||||||
|
mode === 'recent'
|
||||||
|
? await skinSiteWikiRecent(before)
|
||||||
|
: await skinSiteWikiCategory(categoryName.value.trim(), before)
|
||||||
|
if (!res.success || !res.payload) throw new Error(res.errorMessage ?? 'request failed')
|
||||||
|
const page = res.payload
|
||||||
|
items.value = append ? [...items.value, ...page.items] : page.items
|
||||||
|
nextCursor.value = page.nextCursor ?? null
|
||||||
|
loaded.value = true
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
if (!append) resetState()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(next: Tab) {
|
||||||
|
if (tab.value === next) return
|
||||||
|
tab.value = next
|
||||||
|
resetState()
|
||||||
|
if (next === 'recent') void fetchPage('recent', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (tab.value === 'recent') void fetchPage('recent', true)
|
||||||
|
else if (tab.value === 'category') void fetchPage('category', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(categoryName, () => {
|
||||||
|
if (tab.value === 'category') {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
watch(keyword, () => {
|
||||||
|
if (tab.value !== 'search') return
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
void runSearch()
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void fetchPage('recent', false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-for="t in (['search', 'recent', 'category'] as Tab[])"
|
||||||
|
:key="t"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-3 py-1 text-xs font-semibold"
|
||||||
|
:class="tab === t ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
|
||||||
|
@click="switchTab(t)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
formatMessage(
|
||||||
|
t === 'search'
|
||||||
|
? messages.tabSearch
|
||||||
|
: t === 'recent'
|
||||||
|
? messages.tabRecent
|
||||||
|
: messages.tabCategory,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-if="tab === 'search'"
|
||||||
|
v-model="keyword"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||||
|
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
|
||||||
|
@keyup.enter="runSearch"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else-if="tab === 'category'"
|
||||||
|
v-model="categoryName"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.categoryPlaceholder)"
|
||||||
|
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
|
||||||
|
@keyup.enter="fetchPage('category', false)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="loading && !items.length" class="flex min-h-16 items-center justify-center gap-2 text-xs text-secondary">
|
||||||
|
<SpinnerIcon class="size-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="tab === 'search' && !keyword.trim()" class="m-0 text-xs text-secondary">
|
||||||
|
{{ formatMessage(messages.hintSearch) }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="tab === 'category' && !categoryName.trim()" class="m-0 text-xs text-secondary">
|
||||||
|
{{ formatMessage(messages.hintCategory) }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="loaded && !items.length" class="m-0 text-xs text-secondary">
|
||||||
|
{{ formatMessage(tab === 'search' ? messages.emptySearch : messages.emptyList) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col divide-y divide-surface-5">
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.title"
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col items-start gap-0.5 py-2 text-left hover:brightness-110"
|
||||||
|
@click="emit('navigate', item.title)"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold text-contrast">{{ item.title }}</span>
|
||||||
|
<span class="text-[10px] text-secondary">
|
||||||
|
{{ formatMessage(messages.byAuthor, { author: item.authorName }) }} ·
|
||||||
|
{{ formatDate(item.updatedAt) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canLoadMore && !loading"
|
||||||
|
type="button"
|
||||||
|
class="self-start rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
|
||||||
|
@click="loadMore"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.loadMore) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -2605,3 +2605,80 @@ export function skinSiteWikiRevert(data: {
|
|||||||
body: data,
|
body: data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// ─── Wiki: 搜索 / 列表 / 最近 / 分类 ───────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SkinSiteWikiPageSummary {
|
||||||
|
title: string
|
||||||
|
updatedAt: number
|
||||||
|
authorName: string
|
||||||
|
readPolicy: SkinSiteWikiReadPolicy
|
||||||
|
editPolicy: SkinSiteWikiEditPolicy
|
||||||
|
redirectTarget?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteWikiPageListItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
namespace: string
|
||||||
|
revisionId: number
|
||||||
|
updatedAt: number
|
||||||
|
authorName: string
|
||||||
|
readPolicy: SkinSiteWikiReadPolicy
|
||||||
|
editPolicy: SkinSiteWikiEditPolicy
|
||||||
|
redirectTarget?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteWikiPageList {
|
||||||
|
items: SkinSiteWikiPageListItem[]
|
||||||
|
nextCursor?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteWikiSearchPage {
|
||||||
|
items: SkinSiteWikiPageSummary[]
|
||||||
|
nextCursor?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiSearch(
|
||||||
|
query: string,
|
||||||
|
): Promise<SkinSiteResponse<SkinSiteWikiPageSummary[]>> {
|
||||||
|
const q = query.trim()
|
||||||
|
if (!q) {
|
||||||
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
payload: [],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as SkinSiteResponse<SkinSiteWikiPageSummary[]>)
|
||||||
|
}
|
||||||
|
return skinSiteRequest<SkinSiteWikiPageSummary[]>('wiki/search?q=' + encodeURIComponent(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiPages(
|
||||||
|
params: {
|
||||||
|
prefix?: string
|
||||||
|
namespace?: string
|
||||||
|
limit?: number
|
||||||
|
cursor?: string
|
||||||
|
} = {},
|
||||||
|
): Promise<SkinSiteResponse<SkinSiteWikiPageList>> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params.prefix) query.set('prefix', params.prefix)
|
||||||
|
if (params.namespace != null) query.set('namespace', params.namespace)
|
||||||
|
if (params.limit != null) query.set('limit', String(params.limit))
|
||||||
|
if (params.cursor) query.set('cursor', params.cursor)
|
||||||
|
return skinSiteRequest<SkinSiteWikiPageList>('wiki/pages?' + query.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiRecent(before?: number): Promise<SkinSiteResponse<SkinSiteWikiSearchPage>> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (before != null) query.set('before', String(before))
|
||||||
|
return skinSiteRequest<SkinSiteWikiSearchPage>('wiki/recent?' + query.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiCategory(
|
||||||
|
name: string,
|
||||||
|
before?: number,
|
||||||
|
): Promise<SkinSiteResponse<SkinSiteWikiSearchPage>> {
|
||||||
|
const query = new URLSearchParams({ name })
|
||||||
|
if (before != null) query.set('before', String(before))
|
||||||
|
return skinSiteRequest<SkinSiteWikiSearchPage>('wiki/category?' + query.toString())
|
||||||
|
}
|
||||||
@@ -12447,5 +12447,16 @@
|
|||||||
"app.skin-site.wiki.revision-preview": {"message": "Preview of revision #{id}"},
|
"app.skin-site.wiki.revision-preview": {"message": "Preview of revision #{id}"},
|
||||||
"app.skin-site.wiki.close-preview": {"message": "Close preview"},
|
"app.skin-site.wiki.close-preview": {"message": "Close preview"},
|
||||||
"app.skin-site.wiki.diff-title": {"message": "Line diff against the current revision"},
|
"app.skin-site.wiki.diff-title": {"message": "Line diff against the current revision"},
|
||||||
"app.skin-site.wiki.cancel": {"message": "Cancel"}
|
"app.skin-site.wiki.cancel": {"message": "Cancel"},
|
||||||
|
"app.skin-site.wiki.browser.search-placeholder": {"message": "Search wiki pages…"},
|
||||||
|
"app.skin-site.wiki.browser.tab-search": {"message": "Search"},
|
||||||
|
"app.skin-site.wiki.browser.tab-recent": {"message": "Recent"},
|
||||||
|
"app.skin-site.wiki.browser.tab-category": {"message": "Category"},
|
||||||
|
"app.skin-site.wiki.browser.category-placeholder": {"message": "Category name"},
|
||||||
|
"app.skin-site.wiki.browser.load-more": {"message": "Load more"},
|
||||||
|
"app.skin-site.wiki.browser.empty-search": {"message": "No pages match your search."},
|
||||||
|
"app.skin-site.wiki.browser.empty-list": {"message": "Nothing here yet."},
|
||||||
|
"app.skin-site.wiki.browser.hint-search": {"message": "Type a keyword to search."},
|
||||||
|
"app.skin-site.wiki.browser.hint-category": {"message": "Enter a category name to browse."},
|
||||||
|
"app.skin-site.wiki.browser.by-author": {"message": "by {author}"}
|
||||||
}
|
}
|
||||||
@@ -12278,5 +12278,16 @@
|
|||||||
"app.skin-site.wiki.revision-preview": {"message": "修订 #{id} 预览"},
|
"app.skin-site.wiki.revision-preview": {"message": "修订 #{id} 预览"},
|
||||||
"app.skin-site.wiki.close-preview": {"message": "关闭预览"},
|
"app.skin-site.wiki.close-preview": {"message": "关闭预览"},
|
||||||
"app.skin-site.wiki.diff-title": {"message": "与当前版本的逐行差异"},
|
"app.skin-site.wiki.diff-title": {"message": "与当前版本的逐行差异"},
|
||||||
"app.skin-site.wiki.cancel": {"message": "取消"}
|
"app.skin-site.wiki.cancel": {"message": "取消"},
|
||||||
|
"app.skin-site.wiki.browser.search-placeholder": {"message": "搜索 Wiki 页面…"},
|
||||||
|
"app.skin-site.wiki.browser.tab-search": {"message": "搜索"},
|
||||||
|
"app.skin-site.wiki.browser.tab-recent": {"message": "最近"},
|
||||||
|
"app.skin-site.wiki.browser.tab-category": {"message": "分类"},
|
||||||
|
"app.skin-site.wiki.browser.category-placeholder": {"message": "分类名称"},
|
||||||
|
"app.skin-site.wiki.browser.load-more": {"message": "加载更多"},
|
||||||
|
"app.skin-site.wiki.browser.empty-search": {"message": "没有匹配的页面。"},
|
||||||
|
"app.skin-site.wiki.browser.empty-list": {"message": "这里还没有内容。"},
|
||||||
|
"app.skin-site.wiki.browser.hint-search": {"message": "输入关键词开始搜索。"},
|
||||||
|
"app.skin-site.wiki.browser.hint-category": {"message": "输入分类名称开始浏览。"},
|
||||||
|
"app.skin-site.wiki.browser.by-author": {"message": "由 {author} 编辑"}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user