diff --git a/app/app.vue b/app/app.vue index d759f4c..4d200ab 100644 --- a/app/app.vue +++ b/app/app.vue @@ -34,6 +34,10 @@ const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSe const { data: app } = await useAsyncData('links', () => queryCollection('app').first()) provide('navigation', navigation) + +const dataStore = useDataStore() +await dataStore.fetchProjects() +await dataStore.fetchContributors() diff --git a/app/pages/projects.vue b/app/pages/projects.vue index 9fe125d..de0b60f 100644 --- a/app/pages/projects.vue +++ b/app/pages/projects.vue @@ -1,9 +1,58 @@ diff --git a/app/stores/data.ts b/app/stores/data.ts index 8220f54..742ae71 100644 --- a/app/stores/data.ts +++ b/app/stores/data.ts @@ -1,6 +1,178 @@ import { defineStore, acceptHMRUpdate } from 'pinia' +import type { ProjectFetch } from '~~/shared/types/projects' -export const useDataStore = defineStore('data', () => {}) +interface DataStoreState { + encrypted: boolean + projects: Project[] + contributors: Contributor[] +} + +export const useDataStore = defineStore('data', { + state: (): DataStoreState => ({ + encrypted: true, + projects: [] as Project[], + contributors: [] as Contributor[] + }), + getters: {}, + actions: { + async fetchProjects(): Promise { + const { assetKey, siteUrl } = useRuntimeConfig().public + const projectsBaseUrl = `${siteUrl}/assets/data/projects` + const url = projectsBaseUrl + (this.encrypted ? '.dat' : '.json') + + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to fetch projects: ${response.status}`) + + let json: string + + if (this.encrypted) { + if (!assetKey) throw new Error('NUXT_PUBLIC_ASSET_KEY environment variable is not set.') + + const rawKey = Uint8Array.from(atob(assetKey), c => c.charCodeAt(0)) + const key = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, true, ['decrypt']) + + const buffer = await response.arrayBuffer() + const data = new Uint8Array(buffer) + + const iv = data.slice(0, 12) + const encryptedData = data.slice(12) + + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedData) + json = new TextDecoder().decode(decrypted) + } else { + json = await response.text() + } + + const projects: ProjectFetch[] = JSON.parse(json) + + const remap = projects.map(c => + replaceNulls({ + id: c.id, + to: c.html_url, + name: c.name, + fullName: c.full_name, + description: c.description, + owner: { + src: c.owner?.avatar_url, + alt: c.owner?.login + }, + updatedAt: c.updated_at, + stars: c.stargazers_count, + forks: c.forks_count, + release: { + tag: c.latest_release?.tag, + name: c.latest_release?.name, + publishedAt: c.latest_release?.published_at, + url: c.latest_release?.url, + assets: c.latest_release?.assets?.map(a => ({ + name: a?.name, + url: a?.url, + type: a?.type, + size: a?.size, + downloads: a?.downloads + })) + } + }) + ) + + this.projects = remap + + return remap + } catch (error) { + console.error('Error loading projects:', error) + return [] + } + }, + async fetchContributors(): Promise { + const { assetKey, siteUrl } = useRuntimeConfig().public + const contributorsBaseUrl = `${siteUrl}/assets/data/contributors` + const url = contributorsBaseUrl + (this.encrypted ? '.dat' : '.json') + + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to fetch contributors: ${response.status}`) + + let json: string + + if (this.encrypted) { + if (!assetKey) throw new Error('NUXT_PUBLIC_ASSET_KEY environment variable is not set.') + + const rawKey = Uint8Array.from(atob(assetKey), c => c.charCodeAt(0)) + const key = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, true, ['decrypt']) + + const buffer = await response.arrayBuffer() + const data = new Uint8Array(buffer) + + const iv = data.slice(0, 12) + const encryptedData = data.slice(12) + + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedData) + json = new TextDecoder().decode(decrypted) + } else { + json = await response.text() + } + + const contributors: ContributorFetch[] = JSON.parse(json) + + const remap = contributors.map(c => + replaceNulls({ + id: c.id, + avatar: { + src: c.avatar_url, + alt: c.login + }, + username: c.login, + to: c.html_url + }) + ) + + this.contributors = remap + + return remap + } catch (error) { + console.error('Error loading contributors:', error) + return [] + } + }, + getProjects({ + itemsToShow = 0, + featured = [], + sortBy = 'stars' + }: { + itemsToShow: number + featured: string[] + sortBy: 'name' | 'updated' | 'stars' | 'forks' + }): Project[] { + const filtered = featured.length > 0 + ? this.projects.filter(p => p.name && featured.includes(p.name)) + : [...this.projects] + + const sorted = filtered.sort((a, b) => { + switch (sortBy) { + case 'name': + return (a.name ?? '').localeCompare(b.name ?? '') + case 'updated': + return new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime() + case 'stars': + return (b.stars ?? 0) - (a.stars ?? 0) + case 'forks': + return (b.forks ?? 0) - (a.forks ?? 0) + default: + return 0 + } + }) + + return itemsToShow > 0 ? sorted.slice(0, itemsToShow) : sorted + }, + getContributors(): Contributor[] { + return this.contributors + }, + getProjectReleaseByName(name: string): Project | undefined { + return this.projects.find(p => p.name === name) + } + } +}) if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useDataStore, import.meta.hot)) diff --git a/content.config.ts b/content.config.ts index 145f362..4babc56 100644 --- a/content.config.ts +++ b/content.config.ts @@ -48,6 +48,22 @@ const createVideoSchema = () => z.object({ volume: z.number().optional() }) +const createDownloadItemSchema = () => z.object({ + icon: z.string().optional(), + label: z.string().nonempty(), + repoName: z.string().nonempty(), + downloadUrl: z.string().nonempty(), + version: z.string().optional(), + releaseDate: z.string().optional() +}) + +const createDownloadPlatformSchema = () => z.object({ + icon: z.string().optional(), + title: z.string().nonempty(), + description: z.string().optional(), + items: z.array(createDownloadItemSchema()).optional() +}) + const collections = { app: defineCollection({ type: 'data', @@ -110,6 +126,37 @@ const collections = { docs: defineCollection({ type: 'page', source: '1.docs/**/*' + }), + download: defineCollection({ + type: 'page', + source: '2.download.yml', + schema: z.object({ + download: createBaseSchema().extend({ + reverse: z.boolean().optional(), + orientation: z.enum(orientationEnum).optional(), + icon: z.string().optional(), + items: z.array(createDownloadPlatformSchema()).optional() + }).optional() + }) + }), + projects: defineCollection({ + type: 'page', + source: '3.projects.yml', + schema: z.object({ + projects: createBaseSchema().extend({ + reverse: z.boolean().optional(), + orientation: z.enum(orientationEnum).optional(), + icon: z.string().optional(), + itemsToShow: z.number().optional(), + sortBy: z.enum(['name', 'updated', 'stars', 'forks']).optional(), + featured: z.array(z.string()).optional() + }).optional() + }) + }), + blog: defineCollection({ + type: 'page', + source: '4.blog/**/*', + schema: z.object({}) }) } diff --git a/content/2.download.yml b/content/2.download.yml new file mode 100644 index 0000000..d1f74b7 --- /dev/null +++ b/content/2.download.yml @@ -0,0 +1,44 @@ +title: +description: +seo: + title: + description: +navigation.icon: i-lucide-download + +download: + reverse: false + orientation: vertical + icon: i-lucide-folder-root + title: Download + description: Find the latest releases of our software and tools. + items: + - icon: i-simple-icons:windows + title: Windows + description: Download the latest version for Windows. + items: + - icon: i-lucide-download + label: Application + repoName: REPLACE_ME + - icon: i-lucide-download + label: Library + repoName: projectm + - icon: i-simple-icons:linux + title: Linux + description: Download the latest version for Linux. + items: + - icon: i-lucide-download + label: Application + repoName: REPLACE_ME + - icon: i-lucide-download + label: Library + repoName: projectm + - icon: i-simple-icons:apple + title: macOS + description: Download the latest version for macOS. + items: + - icon: i-lucide-download + label: Application + repoName: REPLACE_ME + - icon: i-lucide-download + label: Library + repoName: projectm diff --git a/content/3.projects.yml b/content/3.projects.yml new file mode 100644 index 0000000..1085dce --- /dev/null +++ b/content/3.projects.yml @@ -0,0 +1,17 @@ +title: +description: +seo: + title: + description: +navigation.icon: i-lucide-folder-root + +projects: + reverse: false + orientation: vertical + icon: i-lucide-folder-root + title: Our Projects + description: Explore our open-source projects that help developers build more inspiring applications. + itemsToShow: 0 + sortBy: stars + # featured: + # - projectm diff --git a/content/4.blog/.navigation.yml b/content/4.blog/.navigation.yml new file mode 100644 index 0000000..ab36760 --- /dev/null +++ b/content/4.blog/.navigation.yml @@ -0,0 +1 @@ +navigation.icon: i-lucide-pen diff --git a/content/app.yml b/content/app.yml index c33189a..8b43ecf 100644 --- a/content/app.yml +++ b/content/app.yml @@ -17,11 +17,11 @@ links: - label: Download icon: i-lucide-download to: /download - disabled: true + disabled: false - label: Projects icon: i-lucide-folder-root to: /projects - disabled: true + disabled: false - label: Blog icon: i-lucide-pencil to: /blog diff --git a/scripts/generate-reports.ts b/scripts/generate-reports.ts index eec8c2a..9a609ab 100644 --- a/scripts/generate-reports.ts +++ b/scripts/generate-reports.ts @@ -170,6 +170,64 @@ async function enrichWithTopics(owner: string, repos: Repository[], headers: Rec return enriched } +// ---------- Releases ---------- + +type Release = { + url: string + tag_name: string + name: string + body: string + draft: boolean + prerelease: boolean + created_at: string + published_at: string + html_url: string + assets: { + name: string + browser_download_url: string + content_type: string + size: number + download_count: number + }[] +} + +async function getLatestRelease(owner: string, repo: string, headers: Record): Promise { + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, { headers }) + if (!response.ok) { + if (response.status === 404) return null + throw new Error(`Failed to fetch latest release for ${repo}: ${response.statusText}`) + } + + return response.json() +} + +async function enrichWithReleases(owner: string, repos: Repository[], headers: Record) { + for (const repo of repos) { + try { + const release = await getLatestRelease(owner, repo.name, headers) + if (release) { + repo.latest_release = { + tag: release.tag_name, + name: release.name, + published_at: release.published_at, + url: release.html_url, + assets: release.assets.map(a => ({ + name: a.name, + url: a.browser_download_url, + type: a.content_type, + size: a.size, + downloads: a.download_count + })) + } + } + } catch (error) { + console.warn(`⚠️ Skipping release for ${repo.name}: ${(error as Error).message}`) + } + } + + return repos +} + // ---------- Encryption ---------- async function encryptContent(encryptionKey: string, content: string): Promise { @@ -214,8 +272,10 @@ async function main() { console.log(`🏷️ Enriching ${repos.length} repositories with topics...`) const enrichedRepos = await enrichWithTopics(owner, repos, headers) + console.log(`🏷️ Enriching ${enrichedRepos.length} repositories with latest releases...`) + const enrichedReleases = await enrichWithReleases(owner, enrichedRepos, headers) const projectsOutput = encrypt - ? await encryptContent(encryptionKey, JSON.stringify(enrichedRepos, null, 2)) + ? await encryptContent(encryptionKey, JSON.stringify(enrichedReleases, null, 2)) : JSON.stringify(enrichedRepos, null, 2) const projectsFile = encrypt ? `${output}/projects.dat` : `${output}/projects.json` await Bun.write(projectsFile, projectsOutput, { createPath: true }) diff --git a/shared/types/contributors.d.ts b/shared/types/contributors.d.ts new file mode 100644 index 0000000..8fd16fb --- /dev/null +++ b/shared/types/contributors.d.ts @@ -0,0 +1,16 @@ +export interface ContributorFetch { + id?: number + avatar_url?: string + login?: string + html_url?: string +} + +export interface Contributor { + id?: number + avatar?: { + src?: string + alt?: string + } + username?: string + to?: string +} diff --git a/shared/types/projects.d.ts b/shared/types/projects.d.ts new file mode 100644 index 0000000..7fca1db --- /dev/null +++ b/shared/types/projects.d.ts @@ -0,0 +1,55 @@ +export interface ProjectFetch { + id?: number + html_url?: string + name?: string + full_name?: string + description?: string + owner?: { + login?: string + avatar_url?: string + } + updated_at?: string + stargazers_count?: number + forks_count?: number + latest_release?: { + tag?: string + name?: string + published_at?: string + url?: string + assets?: { + name?: string + url?: string + type?: string + size?: number + downloads?: number + }[] + } +} + +export interface Project { + id?: number + to?: string + name?: string + fullName?: string + description?: string + owner?: { + src?: string + alt?: string + } + updatedAt?: string + stars?: number + forks?: number + release?: { + tag?: string + name?: string + publishedAt?: string + url?: string + assets?: { + name?: string + url?: string + type?: string + size?: number + downloads?: number + }[] + } +} diff --git a/shared/utils/useContributors.ts b/shared/utils/useContributors.ts deleted file mode 100644 index 550d1df..0000000 --- a/shared/utils/useContributors.ts +++ /dev/null @@ -1,53 +0,0 @@ -export interface ContributorItem { - id: number - avatar_url: string - login: string - html_url: string -} - -export async function useContributors(): Promise { - const keyEnv = useRuntimeConfig().public.assetKey - - if (!keyEnv) { - throw new Error('NUXT_PUBLIC_ASSET_KEY environment variable is not set.') - } - - const rawKey = Uint8Array.from(atob(keyEnv), c => c.charCodeAt(0)) - const key = await crypto.subtle.importKey( - 'raw', - rawKey, - { name: 'AES-GCM' }, - true, - ['decrypt'] - ) - - const url = `${useRuntimeConfig().public.siteUrl}/assets/data/contributors.dat` - - try { - const response = await fetch(url) - - if (!response.ok) { - throw new Error(`Failed to fetch contributors: ${response.status}`) - } - - const buffer = await response.arrayBuffer() - const data = new Uint8Array(buffer) - - const iv = data.slice(0, 12) - const encryptedData = data.slice(12) - - const decryptedBuffer = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - key, - encryptedData - ) - - const json = new TextDecoder().decode(decryptedBuffer) - const contributors: ContributorItem[] = JSON.parse(json) - - return contributors - } catch (error) { - console.error('Error fetching contributors:', error) - return [] - } -} diff --git a/shared/utils/useConverter.ts b/shared/utils/useConverter.ts new file mode 100644 index 0000000..3e1f377 --- /dev/null +++ b/shared/utils/useConverter.ts @@ -0,0 +1,14 @@ +export function replaceNulls(obj: T): T { + if (Array.isArray(obj)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return obj.map(replaceNulls) as any + } else if (obj && typeof obj === 'object') { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [ + key, + value === null ? undefined : replaceNulls(value) + ]) + ) as T + } + return obj +} diff --git a/shared/utils/useProjects.ts b/shared/utils/useProjects.ts deleted file mode 100644 index fc74f57..0000000 --- a/shared/utils/useProjects.ts +++ /dev/null @@ -1,93 +0,0 @@ -export type SortByEnum = 'name' | 'updated' | 'stars' | 'forks' - -export interface ProjectItem { - id: number - html_url: string - name: string - full_name: string - description: string - owner: { - login: string - avatar_url: string - } - updated_at: string - stargazers_count: number - forks_count: number -} - -export async function useProjects({ - itemsToShow, - featured, - sortBy -}: { - featured: string[] - sortBy: SortByEnum - itemsToShow: number -}): Promise { - const keyEnv = useRuntimeConfig().public.assetKey - - if (!keyEnv) { - throw new Error('NUXT_PUBLIC_ASSET_KEY environment variable is not set.') - } - - const rawKey = Uint8Array.from(atob(keyEnv), c => c.charCodeAt(0)) - const key = await crypto.subtle.importKey( - 'raw', - rawKey, - { name: 'AES-GCM' }, - true, - ['decrypt'] - ) - - const url = `${useRuntimeConfig().public.siteUrl}/assets/data/projects.dat` - - let items: ProjectItem[] = [] - - try { - const response = await fetch(url) - - if (!response.ok) { - throw new Error(`Failed to fetch projects: ${response.status}`) - } - - const buffer = await response.arrayBuffer() - const data = new Uint8Array(buffer) - - const iv = data.slice(0, 12) - const encryptedData = data.slice(12) - - const decryptedBuffer = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - key, - encryptedData - ) - - const json = new TextDecoder().decode(decryptedBuffer) - - items = JSON.parse(json) as ProjectItem[] - } catch (error) { - console.error('Error fetching projects:', error) - return [] - } - - const filtered = featured.length > 0 - ? items.filter(item => featured.includes(item.full_name)) - : [...items] - - const sorted = [...filtered].sort((a, b) => { - switch (sortBy) { - case 'name': - return a.name.localeCompare(b.name) - case 'updated': - return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() - case 'stars': - return (b.stargazers_count || 0) - (a.stargazers_count || 0) - case 'forks': - return (b.forks_count || 0) - (a.forks_count || 0) - default: - return 0 - } - }) - - return sorted.slice(0, Math.min(itemsToShow, sorted.length)) -} diff --git a/shared/utils/useStyle.ts b/shared/utils/useStyle.ts index c31e16b..b0900ea 100644 --- a/shared/utils/useStyle.ts +++ b/shared/utils/useStyle.ts @@ -1,15 +1,21 @@ -export function useStyleName(name: string): string { +export function useStyleName(name: string | undefined): string | undefined { + if (!name) return undefined + return name .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' ') }; -export function useStyleCount(number: number): string { +export function useStyleCount(number: number | undefined): string | undefined { + if (!number) return undefined + return number >= 1000 ? `${(number / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${number}` } -export function useStyleDate(dateStr: string): string { +export function useStyleDate(dateStr: string | undefined): string | undefined { + if (!dateStr) return undefined + const date = new Date(dateStr) return date.toLocaleDateString(undefined, { year: 'numeric',