From b897c87f05df67f0ed20375c3708f37a2ce74595 Mon Sep 17 00:00:00 2001 From: tdgao Date: Thu, 29 Oct 2026 12:18:52 -0600 Subject: [PATCH 1/6] feat: copyright projects page --- .../components/ArchiveJsonForm.vue | 65 +++++ .../components/ArchiveModal.vue | 45 ++++ .../components/BulkImportModal.vue | 84 ++++++ .../components/JsonEditor.vue | 55 ++++ .../components/copyright-archives/index.vue | 245 ++++++++++++++++++ .../components/copyright-archives/storage.ts | 71 +++++ .../components/copyright-archives/types.ts | 44 ++++ .../copyright-archives/use-archive-editor.ts | 176 +++++++++++++ .../copyright-archives/validation.ts | 120 +++++++++ apps/frontend/src/pages/admin.vue | 6 + .../src/pages/admin/analytics/events.vue | 17 +- .../src/pages/admin/copyright-archives.vue | 21 ++ 12 files changed, 941 insertions(+), 8 deletions(-) create mode 100644 apps/frontend/src/components/copyright-archives/components/ArchiveJsonForm.vue create mode 100644 apps/frontend/src/components/copyright-archives/components/ArchiveModal.vue create mode 100644 apps/frontend/src/components/copyright-archives/components/BulkImportModal.vue create mode 100644 apps/frontend/src/components/copyright-archives/components/JsonEditor.vue create mode 100644 apps/frontend/src/components/copyright-archives/index.vue create mode 100644 apps/frontend/src/components/copyright-archives/storage.ts create mode 100644 apps/frontend/src/components/copyright-archives/types.ts create mode 100644 apps/frontend/src/components/copyright-archives/use-archive-editor.ts create mode 100644 apps/frontend/src/components/copyright-archives/validation.ts create mode 100644 apps/frontend/src/pages/admin/copyright-archives.vue diff --git a/apps/frontend/src/components/copyright-archives/components/ArchiveJsonForm.vue b/apps/frontend/src/components/copyright-archives/components/ArchiveJsonForm.vue new file mode 100644 index 00000000000..7a6701b93b9 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/components/ArchiveJsonForm.vue @@ -0,0 +1,65 @@ + + + diff --git a/apps/frontend/src/components/copyright-archives/components/ArchiveModal.vue b/apps/frontend/src/components/copyright-archives/components/ArchiveModal.vue new file mode 100644 index 00000000000..15903cdf2e4 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/components/ArchiveModal.vue @@ -0,0 +1,45 @@ + + + diff --git a/apps/frontend/src/components/copyright-archives/components/BulkImportModal.vue b/apps/frontend/src/components/copyright-archives/components/BulkImportModal.vue new file mode 100644 index 00000000000..a3afee91d0e --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/components/BulkImportModal.vue @@ -0,0 +1,84 @@ + + + diff --git a/apps/frontend/src/components/copyright-archives/components/JsonEditor.vue b/apps/frontend/src/components/copyright-archives/components/JsonEditor.vue new file mode 100644 index 00000000000..b2e7ba07337 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/components/JsonEditor.vue @@ -0,0 +1,55 @@ + + + diff --git a/apps/frontend/src/components/copyright-archives/index.vue b/apps/frontend/src/components/copyright-archives/index.vue new file mode 100644 index 00000000000..22c57d1516f --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/index.vue @@ -0,0 +1,245 @@ + + + diff --git a/apps/frontend/src/components/copyright-archives/storage.ts b/apps/frontend/src/components/copyright-archives/storage.ts new file mode 100644 index 00000000000..f161cf10dec --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/storage.ts @@ -0,0 +1,71 @@ +import type { CopyrightProjectArchive } from './types' + +async function openDatabase(): Promise { + if (typeof indexedDB === 'undefined') throw new Error('IndexedDB is unavailable in this browser.') + return new Promise((resolve, reject) => { + const request = indexedDB.open('modrinth-copyright-archives', 1) + request.onupgradeneeded = () => request.result.createObjectStore('archives') + request.onerror = () => reject(request.error ?? new Error('Could not open local storage.')) + request.onblocked = () => + reject(new Error('Local storage is blocked. Close other archive tabs and retry.')) + request.onsuccess = () => resolve(request.result) + }) +} + +export async function loadArchives(): Promise { + const db = await openDatabase() + try { + return await new Promise((resolve, reject) => { + const request = db.transaction('archives', 'readonly').objectStore('archives').get('saved') + request.onsuccess = () => resolve(request.result ?? []) + request.onerror = () => reject(request.error ?? new Error('Could not load archives.')) + }) + } finally { + db.close() + } +} + +export async function saveArchives( + archives: CopyrightProjectArchive[], + original: CopyrightProjectArchive[], +): Promise { + const originalById = new Map(original.map((archive) => [archive.id, archive])) + const archivesById = new Map(archives.map((archive) => [archive.id, archive])) + const changedIds = [...new Set([...originalById.keys(), ...archivesById.keys()])].filter( + (id) => JSON.stringify(originalById.get(id)) !== JSON.stringify(archivesById.get(id)), + ) + const db = await openDatabase() + try { + return await new Promise((resolve, reject) => { + const transaction = db.transaction('archives', 'readwrite') + const store = transaction.objectStore('archives') + let merged: CopyrightProjectArchive[] = [] + let conflict: Error | undefined + transaction.oncomplete = () => resolve(merged) + transaction.onerror = () => reject(transaction.error ?? new Error('Could not save archives.')) + transaction.onabort = () => + reject(conflict ?? transaction.error ?? new Error('Saving archives was aborted.')) + const request = store.get('saved') + request.onsuccess = () => { + const latest = (request.result ?? []) as CopyrightProjectArchive[] + const latestById = new Map(latest.map((archive) => [archive.id, archive])) + for (const id of changedIds) { + if (JSON.stringify(latestById.get(id)) !== JSON.stringify(originalById.get(id))) { + conflict = new Error( + 'An archive you changed was updated or deleted in another tab. Copy your edits, reload the page, and try again.', + ) + transaction.abort() + return + } + const archive = archivesById.get(id) + if (archive) latestById.set(id, archive) + else latestById.delete(id) + } + merged = [...latestById.values()] + store.put(merged, 'saved') + } + }) + } finally { + db.close() + } +} diff --git a/apps/frontend/src/components/copyright-archives/types.ts b/apps/frontend/src/components/copyright-archives/types.ts new file mode 100644 index 00000000000..5dd214089d2 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/types.ts @@ -0,0 +1,44 @@ +export interface CopyrightProjectArchive { + id: string + name: string + modrinthMetadata?: { + projectId: string + versionId: string + projectType: string + } + minecraftMetadata?: Record + license: string + licenseTerms?: { + requiresSource?: string + requiredRedistributionLicense?: string[] + redistributionAllowed?: boolean + commercialUseAllowed?: boolean + attributionRequired?: boolean + } + files: Array<{ + path: string + filename: string + hash_sha512: string + }> +} + +export type ArchiveInput = Omit & { id?: string } +export type ArchiveDraft = Omit & { + minecraftJson: string +} + +export function toDraft(archive: CopyrightProjectArchive): ArchiveDraft { + const { minecraftMetadata, ...rest } = archive + return { + ...rest, + minecraftJson: minecraftMetadata ? JSON.stringify(minecraftMetadata, null, '\t') : '', + } +} + +export function fromDraft(draft: ArchiveDraft): CopyrightProjectArchive { + const { minecraftJson, ...archive } = draft + return { + ...archive, + ...(minecraftJson.trim() ? { minecraftMetadata: JSON.parse(minecraftJson) } : {}), + } +} diff --git a/apps/frontend/src/components/copyright-archives/use-archive-editor.ts b/apps/frontend/src/components/copyright-archives/use-archive-editor.ts new file mode 100644 index 00000000000..960cf0e0534 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/use-archive-editor.ts @@ -0,0 +1,176 @@ +import { injectNotificationManager } from '@modrinth/ui' +import { computed, onMounted, ref } from 'vue' + +import { loadArchives, saveArchives } from './storage' +import { + type ArchiveDraft, + type ArchiveInput, + type CopyrightProjectArchive, + fromDraft, + toDraft, +} from './types' +import { validateArchive } from './validation' + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) +} + +export function useArchiveEditor() { + const { addNotification } = injectNotificationManager() + const saved = ref([]) + const archives = ref([]) + const archiveJson = ref>({}) + const validationErrors = ref>({}) + const loading = ref(true) + const saving = ref(false) + const loadError = ref('') + const saveError = ref('') + const busy = computed(() => loading.value || saving.value || !!loadError.value) + const originalSnapshot = computed(() => + saved.value.map((archive) => JSON.stringify(fromDraft(archive), null, '\t')), + ) + const currentSnapshot = computed(() => + archives.value.map( + (archive) => archiveJson.value[archive.id] ?? JSON.stringify(fromDraft(archive), null, '\t'), + ), + ) + const hasChanges = computed( + () => JSON.stringify(originalSnapshot.value) !== JSON.stringify(currentSnapshot.value), + ) + + async function load() { + loading.value = true + loadError.value = '' + try { + saved.value = (await loadArchives()).map(toDraft) + reset() + } catch (error) { + loadError.value = error instanceof Error ? error.message : String(error) + } finally { + loading.value = false + } + } + + function reset() { + if (saving.value) return + archives.value = clone(saved.value) + archiveJson.value = Object.fromEntries( + archives.value.map((archive) => [archive.id, JSON.stringify(fromDraft(archive), null, '\t')]), + ) + validationErrors.value = {} + saveError.value = '' + } + + async function save(): Promise { + if (busy.value || !hasChanges.value) return false + validationErrors.value = {} + saveError.value = '' + const records: CopyrightProjectArchive[] = [] + for (const archive of archives.value) { + try { + const value: unknown = JSON.parse( + archiveJson.value[archive.id] ?? JSON.stringify(fromDraft(archive)), + ) + const errors = validateArchive(value) + if (!errors.length && (value as ArchiveInput).id !== archive.id) + errors.push('The archive ID must remain unchanged.') + if (errors.length) validationErrors.value[archive.id] = errors.join(' ') + else records.push(value as CopyrightProjectArchive) + } catch { + validationErrors.value[archive.id] = 'Enter valid JSON for a single archive object.' + } + } + if (Object.keys(validationErrors.value).length) return false + saving.value = true + try { + const persisted = await saveArchives(clone(records), clone(saved.value.map(fromDraft))) + saved.value = persisted.map(toDraft) + archives.value = clone(saved.value) + archiveJson.value = Object.fromEntries( + archives.value.map((archive) => [ + archive.id, + JSON.stringify(fromDraft(archive), null, '\t'), + ]), + ) + addNotification({ title: 'Copyright archives saved', type: 'success' }) + return true + } catch (error) { + saveError.value = error instanceof Error ? error.message : String(error) + return false + } finally { + saving.value = false + } + } + + async function upsert(entries: ArchiveInput[]): Promise { + if (busy.value) return false + const errors = entries.flatMap(validateArchive) + if (errors.length) { + saveError.value = errors.join(' ') + return false + } + const replacements = entries.map((entry) => + toDraft({ ...entry, id: entry.id ?? crypto.randomUUID() }), + ) + const nextSaved = clone(saved.value) + const nextArchives = clone(archives.value) + for (const archive of replacements) { + for (const collection of [nextSaved, nextArchives]) { + const index = collection.findIndex((record) => record.id === archive.id) + if (index === -1) collection.push(archive) + else collection[index] = archive + } + } + saving.value = true + saveError.value = '' + try { + await saveArchives(clone(nextSaved.map(fromDraft)), clone(saved.value.map(fromDraft))) + saved.value = nextSaved + archives.value = nextArchives + for (const archive of replacements) { + archiveJson.value[archive.id] = JSON.stringify(fromDraft(archive), null, '\t') + validationErrors.value = Object.fromEntries( + Object.entries(validationErrors.value).filter(([id]) => id !== archive.id), + ) + } + addNotification({ title: 'Copyright archives saved', type: 'success' }) + return true + } catch (error) { + saveError.value = error instanceof Error ? error.message : String(error) + return false + } finally { + saving.value = false + } + } + + function remove(id: string) { + if (busy.value) return + archives.value = archives.value.filter((archive) => archive.id !== id) + archiveJson.value = Object.fromEntries( + Object.entries(archiveJson.value).filter(([archiveId]) => archiveId !== id), + ) + validationErrors.value = Object.fromEntries( + Object.entries(validationErrors.value).filter(([archiveId]) => archiveId !== id), + ) + } + + onMounted(load) + return { + archives, + archiveJson, + validationErrors, + loading, + saving, + busy, + loadError, + saveError, + originalSnapshot, + currentSnapshot, + hasChanges, + load, + upsert, + remove, + save, + reset, + } +} diff --git a/apps/frontend/src/components/copyright-archives/validation.ts b/apps/frontend/src/components/copyright-archives/validation.ts new file mode 100644 index 00000000000..fc88fbfbeb3 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/validation.ts @@ -0,0 +1,120 @@ +import type { ArchiveInput } from './types' + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function validateArchive(value: unknown): string[] { + if (!isObject(value)) return ['Expected an archive object.'] + const errors: string[] = [] + function requiredString(object: Record, key: string, prefix = '') { + if (typeof object[key] !== 'string' || !object[key].trim()) { + errors.push(`${prefix}${key}: enter a non-empty string.`) + } + } + function allowedFields(object: Record, fields: string[], prefix = '') { + for (const key of Object.keys(object)) { + if (!fields.includes(key)) errors.push(`${prefix}${key}: unknown field.`) + } + } + allowedFields(value, [ + 'id', + 'name', + 'modrinthMetadata', + 'minecraftMetadata', + 'license', + 'licenseTerms', + 'files', + ]) + if (value.id !== undefined) requiredString(value, 'id') + requiredString(value, 'name') + requiredString(value, 'license') + if (value.modrinthMetadata !== undefined) { + if (!isObject(value.modrinthMetadata)) errors.push('modrinthMetadata: expected an object.') + else { + allowedFields( + value.modrinthMetadata, + ['projectId', 'versionId', 'projectType'], + 'modrinthMetadata.', + ) + for (const key of ['projectId', 'versionId', 'projectType']) { + requiredString(value.modrinthMetadata, key, 'modrinthMetadata.') + } + } + } + if (value.minecraftMetadata !== undefined && !isObject(value.minecraftMetadata)) { + errors.push('minecraftMetadata: expected a JSON object.') + } + if (value.licenseTerms !== undefined) { + if (!isObject(value.licenseTerms)) errors.push('licenseTerms: expected an object.') + else { + const terms = value.licenseTerms + allowedFields( + terms, + [ + 'requiresSource', + 'requiredRedistributionLicense', + 'redistributionAllowed', + 'commercialUseAllowed', + 'attributionRequired', + ], + 'licenseTerms.', + ) + if (terms.requiresSource !== undefined && typeof terms.requiresSource !== 'string') { + errors.push('licenseTerms.requiresSource: expected a string.') + } + for (const key of ['redistributionAllowed', 'commercialUseAllowed', 'attributionRequired']) { + if (terms[key] !== undefined && typeof terms[key] !== 'boolean') { + errors.push(`licenseTerms.${key}: expected true or false.`) + } + } + if ( + terms.requiredRedistributionLicense !== undefined && + (!Array.isArray(terms.requiredRedistributionLicense) || + terms.requiredRedistributionLicense.some( + (item) => typeof item !== 'string' || !item.trim(), + )) + ) + errors.push( + 'licenseTerms.requiredRedistributionLicense: expected an array of license strings.', + ) + } + } + if (!Array.isArray(value.files)) errors.push('files: expected an array.') + else + value.files.forEach((file: unknown, index: number) => { + const prefix = `files[${index}].` + if (!isObject(file)) { + errors.push(`files[${index}]: expected an object.`) + return + } + allowedFields(file, ['path', 'filename', 'hash_sha512'], prefix) + if (typeof file.path !== 'string') errors.push(`${prefix}path: expected a string.`) + requiredString(file, 'filename', prefix) + if (typeof file.hash_sha512 !== 'string' || !/^[a-f\d]{128}$/i.test(file.hash_sha512)) { + errors.push(`${prefix}hash_sha512: expected 128 hexadecimal characters.`) + } + }) + return errors +} + +export function parseImport(json: string, existingIds: Set): ArchiveInput[] { + const value: unknown = JSON.parse(json) + if (!Array.isArray(value) || !value.length) + throw new Error('Enter a non-empty JSON array of archives.') + const errors: string[] = [] + const ids = new Set() + value.forEach((entry: unknown, index: number) => { + for (const error of validateArchive(entry)) errors.push(`Archive ${index + 1}: ${error}`) + if (isObject(entry) && typeof entry.id === 'string') { + if (ids.has(entry.id)) errors.push(`Archive ${index + 1}: duplicate ID "${entry.id}".`) + if (!existingIds.has(entry.id)) + errors.push( + `Archive ${index + 1}: unknown ID "${entry.id}". Omit the ID to create an archive.`, + ) + ids.add(entry.id) + } + }) + if (errors.length) throw new Error(errors.join('\n')) + return value as ArchiveInput[] +} diff --git a/apps/frontend/src/pages/admin.vue b/apps/frontend/src/pages/admin.vue index b0062a8901d..ede8f9df315 100644 --- a/apps/frontend/src/pages/admin.vue +++ b/apps/frontend/src/pages/admin.vue @@ -60,6 +60,12 @@ icon: ChartIcon, shown: admin, }, + { + link: '/admin/copyright-archives', + label: 'Copyright archives', + icon: FileSearchCornerIcon, + shown: admin, + }, { type: 'heading', label: 'Templates' }, { link: '/admin/emails', diff --git a/apps/frontend/src/pages/admin/analytics/events.vue b/apps/frontend/src/pages/admin/analytics/events.vue index 81b05716e0c..03102766439 100644 --- a/apps/frontend/src/pages/admin/analytics/events.vue +++ b/apps/frontend/src/pages/admin/analytics/events.vue @@ -150,6 +150,7 @@ :columns="columns" :data="sortedEvents" row-key="id" + table-min-width="56rem" >