Skip to content

Commit 71d256d

Browse files
committed
feat: lazy load sources
1 parent fc01c34 commit 71d256d

File tree

2 files changed

+191
-14
lines changed

2 files changed

+191
-14
lines changed

apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
DownloadIcon,
99
EllipsisVerticalIcon,
1010
LinkIcon,
11+
LoaderCircleIcon,
1112
ShieldCheckIcon,
1213
TriangleAlertIcon,
1314
} from '@modrinth/assets'
@@ -30,12 +31,14 @@ const props = defineProps<{
3031
thread: Labrinth.TechReview.Internal.DBThread
3132
reports: Labrinth.TechReview.Internal.FileReport[]
3233
}
34+
loadingIssues: Set<string>
3335
}>()
3436
3537
const { addNotification } = injectNotificationManager()
3638
3739
const emit = defineEmits<{
3840
refetch: []
41+
loadSource: [issueId: string]
3942
}>()
4043
4144
const quickActions: OverflowMenuOption[] = [
@@ -164,6 +167,7 @@ function toggleIssue(issueId: string) {
164167
expandedIssues.value.delete(issueId)
165168
} else {
166169
expandedIssues.value.add(issueId)
170+
emit('loadSource', issueId)
167171
}
168172
}
169173
@@ -414,6 +418,17 @@ function toggleIssue(issueId: string) {
414418
issue.details[0].severity.slice(1).toLowerCase()
415419
}}</span>
416420
</div>
421+
422+
<Transition name="fade">
423+
<div
424+
v-if="loadingIssues.has(issue.id)"
425+
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
426+
>
427+
<span class="text-sm font-medium text-secondary">
428+
<LoaderCircleIcon class="animate-spin size-5" />
429+
Loading source...</span>
430+
</div>
431+
</Transition>
417432
</div>
418433

419434
<div class="flex items-center gap-2" @click.stop>
@@ -489,4 +504,18 @@ pre {
489504
display: inline;
490505
white-space: pre;
491506
}
507+
508+
.fade-enter-active {
509+
transition: opacity 0.3s ease-in;
510+
transition-delay: 0.2s;
511+
}
512+
513+
.fade-leave-active {
514+
transition: opacity 0.15s ease-out;
515+
}
516+
517+
.fade-enter-from,
518+
.fade-leave-to {
519+
opacity: 0;
520+
}
492521
</style>

apps/frontend/src/pages/moderation/technical-review.vue

Lines changed: 162 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,142 @@ const { formatMessage } = useVIntl()
3232
const route = useRoute()
3333
const router = useRouter()
3434
35+
const CACHE_TTL = 24 * 60 * 60 * 1000
36+
37+
type CachedSource = {
38+
source: string
39+
timestamp: number
40+
}
41+
42+
function getCachedSource(detailId: string): string | null {
43+
try {
44+
const cached = localStorage.getItem(`tech_review_source_${detailId}`)
45+
if (!cached) return null
46+
47+
const data: CachedSource = JSON.parse(cached)
48+
const now = Date.now()
49+
50+
if (now - data.timestamp > CACHE_TTL) {
51+
localStorage.removeItem(`tech_review_source_${detailId}`)
52+
return null
53+
}
54+
55+
return data.source
56+
} catch {
57+
return null
58+
}
59+
}
60+
61+
function setCachedSource(detailId: string, source: string): void {
62+
try {
63+
const data: CachedSource = {
64+
source,
65+
timestamp: Date.now(),
66+
}
67+
localStorage.setItem(`tech_review_source_${detailId}`, JSON.stringify(data))
68+
} catch (error) {
69+
console.error('Failed to cache source:', error)
70+
}
71+
}
72+
73+
function clearExpiredCache(): void {
74+
try {
75+
const now = Date.now()
76+
const keys = Object.keys(localStorage)
77+
78+
for (const key of keys) {
79+
if (key.startsWith('tech_review_source_')) {
80+
const cached = localStorage.getItem(key)
81+
if (cached) {
82+
const data: CachedSource = JSON.parse(cached)
83+
if (now - data.timestamp > CACHE_TTL) {
84+
localStorage.removeItem(key)
85+
}
86+
}
87+
}
88+
}
89+
} catch (error) {
90+
console.error('Failed to clear expired cache:', error)
91+
}
92+
}
93+
94+
clearExpiredCache()
95+
96+
const loadingIssues = ref<Set<string>>(new Set())
97+
98+
async function loadIssueSource(issueId: string, projectId: string): Promise<void> {
99+
if (loadingIssues.value.has(issueId)) return
100+
101+
loadingIssues.value.add(issueId)
102+
103+
try {
104+
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
105+
106+
for (const detail of issueData.details) {
107+
if (detail.decompiled_source) {
108+
setCachedSource(detail.id, detail.decompiled_source)
109+
110+
const review = reviewItems.value.find((r) => r.project.id === projectId)
111+
if (review) {
112+
for (const report of review.reports) {
113+
for (const issue of report.issues) {
114+
if (issue.id === issueId) {
115+
const existingDetail = issue.details.find((d) => d.id === detail.id)
116+
if (existingDetail) {
117+
existingDetail.decompiled_source = detail.decompiled_source
118+
}
119+
}
120+
}
121+
}
122+
}
123+
}
124+
}
125+
} catch (error) {
126+
console.error('Failed to load issue source:', error)
127+
} finally {
128+
loadingIssues.value.delete(issueId)
129+
}
130+
}
131+
132+
function tryLoadCachedSources(issueId: string, projectId: string): void {
133+
const review = reviewItems.value.find((r) => r.project.id === projectId)
134+
if (!review) return
135+
136+
for (const report of review.reports) {
137+
for (const issue of report.issues) {
138+
if (issue.id === issueId) {
139+
for (const detail of issue.details) {
140+
if (!detail.decompiled_source) {
141+
const cached = getCachedSource(detail.id)
142+
if (cached) {
143+
detail.decompiled_source = cached
144+
}
145+
}
146+
}
147+
}
148+
}
149+
}
150+
}
151+
152+
function handleLoadSource(issueId: string, projectId: string): void {
153+
tryLoadCachedSources(issueId, projectId)
154+
155+
const review = reviewItems.value.find((r) => r.project.id === projectId)
156+
if (!review) return
157+
158+
for (const report of review.reports) {
159+
for (const issue of report.issues) {
160+
if (issue.id === issueId) {
161+
const hasUncached = issue.details.some((d) => !d.decompiled_source)
162+
if (hasUncached) {
163+
loadIssueSource(issueId, projectId)
164+
}
165+
return
166+
}
167+
}
168+
}
169+
}
170+
35171
const messages = defineMessages({
36172
searchPlaceholder: {
37173
id: 'moderation.search.placeholder',
@@ -200,6 +336,18 @@ const filteredItems = computed(() => {
200336
return filtered
201337
})
202338
339+
const filteredIssuesCount = computed(() => {
340+
return filteredItems.value.reduce((total, review) => {
341+
if (currentFilterType.value === 'All issues') {
342+
return total + review.reports.reduce((sum, report) => sum + report.issues.length, 0)
343+
} else {
344+
return total + review.reports.reduce((sum, report) => {
345+
return sum + report.issues.filter((issue) => issue.issue_type === currentFilterType.value).length
346+
}, 0)
347+
}
348+
}, 0)
349+
})
350+
203351
const totalPages = computed(() => Math.ceil((filteredItems.value?.length || 0) / itemsPerPage))
204352
const paginatedItems = computed(() => {
205353
if (!filteredItems.value) return []
@@ -241,13 +389,7 @@ const {
241389
page: 0,
242390
sort_by: toApiSort(currentSortType.value),
243391
})
244-
},
245-
initialData: {
246-
reports: [],
247-
projects: {},
248-
threads: {},
249-
ownership: {},
250-
} as Labrinth.TechReview.Internal.SearchResponse,
392+
}
251393
})
252394
253395
// TEMPORARY: Mock data for development (58 items to match batch scan progress)
@@ -259,8 +401,6 @@ const {
259401
// searchResponse.value = generateMockSearchResponse(58)
260402
// }
261403
262-
// Adapter: Transform flat SearchResponse into project-grouped structure
263-
// for easier consumption by existing UI components
264404
const reviewItems = computed(() => {
265405
if (!searchResponse.value || searchResponse.value.reports.length === 0) {
266406
return []
@@ -283,13 +423,14 @@ const reviewItems = computed(() => {
283423
const projectId = report.project_id
284424
285425
if (!projectMap.has(projectId)) {
286-
// Find the thread associated with this project
287-
const thread = Object.values(response.threads).find((t) => t.project_id === projectId)
426+
// Get project and thread using direct lookups
427+
const project = response.projects[projectId]
428+
const thread = project?.thread_id ? response.threads[project.thread_id] : undefined
288429
289430
if (!thread) continue // Skip if no thread found
290431
291432
projectMap.set(projectId, {
292-
project: response.projects[projectId],
433+
project,
293434
project_owner: response.ownership[projectId],
294435
thread,
295436
reports: [],
@@ -302,6 +443,8 @@ const reviewItems = computed(() => {
302443
return Array.from(projectMap.values())
303444
})
304445
446+
console.log(reviewItems.value)
447+
305448
watch(currentSortType, () => {
306449
goToPage(1)
307450
refetch()
@@ -355,7 +498,7 @@ watch(currentSortType, () => {
355498
<template #selected>
356499
<span class="flex flex-row gap-2 align-middle font-semibold text-primary">
357500
<FilterIcon class="size-4 flex-shrink-0" />
358-
<span class="truncate">{{ currentFilterType }} ({{ filteredItems.length }})</span>
501+
<span class="truncate">{{ currentFilterType }} ({{ filteredIssuesCount }})</span>
359502
</span>
360503
</template>
361504
</Combobox>
@@ -392,7 +535,12 @@ watch(currentSortType, () => {
392535
class="universal-card h-24 animate-pulse"
393536
></div>
394537
<div v-for="(item, idx) in paginatedItems" v-else :key="item.project.id ?? idx" class="">
395-
<ModerationTechRevCard :item="item" @refetch="refetch" />
538+
<ModerationTechRevCard
539+
:item="item"
540+
:loading-issues="loadingIssues"
541+
@refetch="refetch"
542+
@load-source="(issueId: string) => handleLoadSource(issueId, item.project.id)"
543+
/>
396544
</div>
397545
</div>
398546

0 commit comments

Comments
 (0)