diff --git a/apps/api/app/controllers/api/downloads_controller.rb b/apps/api/app/controllers/api/downloads_controller.rb index 3b259ee..a9150a2 100644 --- a/apps/api/app/controllers/api/downloads_controller.rb +++ b/apps/api/app/controllers/api/downloads_controller.rb @@ -21,9 +21,7 @@ class Api::DownloadsController < ApiController ) if full_path - ext = File.extname(full_path.to_s).delete_prefix(".") - mime = Mime::Type.lookup_by_extension(ext) || "application/octet-stream" - send_file full_path, disposition: "attachment", type: mime + send_file full_path, disposition: "attachment", type: resolve_mime(full_path) else head :not_found end diff --git a/apps/api/app/controllers/api/images_controller.rb b/apps/api/app/controllers/api/images_controller.rb index ae4a8ad..e694c2f 100644 --- a/apps/api/app/controllers/api/images_controller.rb +++ b/apps/api/app/controllers/api/images_controller.rb @@ -1,4 +1,4 @@ -class Api::ImagesController < ApplicationController +class Api::ImagesController < ApiController resource_description do short "Images" formats [ "binary" ] @@ -11,9 +11,7 @@ class Api::ImagesController < ApplicationController def show image = ImageService.new.show(ImageShowInputDto.new(id: params[:id])) send_file image.file_path, type: image.content_type, disposition: "inline" - rescue ActiveRecord::RecordNotFound - render plain: "Not Found", status: :not_found rescue Errno::ENOENT - render plain: "Not Found", status: :not_found + head :not_found end end diff --git a/apps/api/app/controllers/api/swagger_controller.rb b/apps/api/app/controllers/api/swagger_controller.rb index 0be1e38..73f3036 100644 --- a/apps/api/app/controllers/api/swagger_controller.rb +++ b/apps/api/app/controllers/api/swagger_controller.rb @@ -2,42 +2,6 @@ class Api::SwaggerController < ActionController::Base layout false def index - render html: swagger_html.html_safe - end - - private - - def swagger_html - <<~HTML - - - - - - Teletype Games API - - - - -
- - - - - HTML + render template: "api/swagger/index", formats: [:html] end end diff --git a/apps/api/app/controllers/api_controller.rb b/apps/api/app/controllers/api_controller.rb index 5038606..e30a493 100644 --- a/apps/api/app/controllers/api_controller.rb +++ b/apps/api/app/controllers/api_controller.rb @@ -3,4 +3,20 @@ class ApiController < ActionController::API api_version "1.0" formats [ "json" ] end + + rescue_from StandardError do |e| + Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}") + render json: { error: "Internal server error" }, status: :internal_server_error + end + + rescue_from ActiveRecord::RecordNotFound do |e| + render json: { error: "Not found" }, status: :not_found + end + + private + + def resolve_mime(path) + ext = File.extname(path.to_s).delete_prefix(".") + Mime::Type.lookup_by_extension(ext) || "application/octet-stream" + end end diff --git a/apps/api/app/controllers/files_controller.rb b/apps/api/app/controllers/files_controller.rb index fb023ac..841760d 100644 --- a/apps/api/app/controllers/files_controller.rb +++ b/apps/api/app/controllers/files_controller.rb @@ -1,6 +1,4 @@ -class FilesController < ApplicationController - skip_forgery_protection - +class FilesController < ApiController resource_description do short "Static files" formats [ "binary" ] @@ -15,10 +13,7 @@ class FilesController < ApplicationController result = FileService.new.show(FileShowInputDto.new(path: params[:path])) case result.type when :redirect then redirect_to result.url, status: :moved_permanently - when :file - ext = File.extname(result.path.to_s).delete_prefix(".") - mime = Mime::Type.lookup_by_extension(ext) || "application/octet-stream" - send_file result.path, disposition: "inline", type: mime + when :file then send_file result.path, disposition: "inline", type: resolve_mime(result.path) when :not_found then head :not_found end end diff --git a/apps/api/app/controllers/update_controller.rb b/apps/api/app/controllers/update_controller.rb index b80d94a..f60735e 100644 --- a/apps/api/app/controllers/update_controller.rb +++ b/apps/api/app/controllers/update_controller.rb @@ -29,12 +29,13 @@ class UpdateController < ApiController render plain: e.message, status: :bad_request rescue => e Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}") - render plain: e.message, status: :internal_server_error + render plain: "Internal server error", status: :internal_server_error end private def authorized? - params[:secret] == ENV.fetch("UPDATE_SECRET", "") + secret = request.headers["X-Update-Secret"].presence || params[:secret] + secret == ENV.fetch("UPDATE_SECRET", "") end end diff --git a/apps/api/app/models/event.rb b/apps/api/app/models/event.rb index 4da5432..421090c 100644 --- a/apps/api/app/models/event.rb +++ b/apps/api/app/models/event.rb @@ -1,4 +1,7 @@ class Event < ApplicationRecord + validates :name, presence: true + validates :date, presence: true + default_scope { where(deleted_at: nil) } scope :upcoming, -> { where("date > ?", Time.current).order(:date) } diff --git a/apps/api/app/models/external_link.rb b/apps/api/app/models/external_link.rb index d8818b2..fbdc178 100644 --- a/apps/api/app/models/external_link.rb +++ b/apps/api/app/models/external_link.rb @@ -3,6 +3,9 @@ class ExternalLink < ApplicationRecord belongs_to :software + validates :label, presence: true + validates :url, presence: true + default_scope { where(deleted_at: nil) } def self.ransackable_attributes(auth_object = nil) diff --git a/apps/api/app/models/member.rb b/apps/api/app/models/member.rb index 89f2e0e..953d2ad 100644 --- a/apps/api/app/models/member.rb +++ b/apps/api/app/models/member.rb @@ -2,6 +2,8 @@ class Member < ApplicationRecord belongs_to :image, optional: true has_one :admin_user + validates :nick, presence: true, uniqueness: true + def self.ransackable_attributes(auth_object = nil) %w[avatar_filename created_at id image_id motto nick real_nick updated_at] end diff --git a/apps/api/app/models/release.rb b/apps/api/app/models/release.rb index fc6ba3a..6fc1565 100644 --- a/apps/api/app/models/release.rb +++ b/apps/api/app/models/release.rb @@ -4,6 +4,9 @@ class Release < ApplicationRecord belongs_to :software has_many :downloads + validates :version, presence: true + validates :version, uniqueness: { scope: :software_id } + default_scope { where(deleted_at: nil) } validate :c64_cannot_be_web_playable diff --git a/apps/api/app/models/software.rb b/apps/api/app/models/software.rb index 87fec1c..326f02b 100644 --- a/apps/api/app/models/software.rb +++ b/apps/api/app/models/software.rb @@ -11,6 +11,10 @@ class Software < ApplicationRecord accepts_nested_attributes_for :external_links, allow_destroy: true accepts_nested_attributes_for :releases, allow_destroy: true + validates :name, presence: true, uniqueness: true + validates :title, presence: true + validates :platform, presence: true + default_scope { where(deleted_at: nil) } def self.ransackable_attributes(auth_object = nil) diff --git a/apps/api/app/serializers/release_serializer.rb b/apps/api/app/serializers/release_serializer.rb index 1bb9ee4..213dcd6 100644 --- a/apps/api/app/serializers/release_serializer.rb +++ b/apps/api/app/serializers/release_serializer.rb @@ -14,5 +14,5 @@ class ReleaseSerializer < Blueprinter::Base field(:sourcePath) { |r| r.source_path.blank? ? "" : r.source_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } field(:htmlFolderPath) { |r| r.html_folder_path.blank? ? "" : r.html_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } field(:docsFolderPath) { |r| r.docs_folder_path.blank? ? "" : r.docs_folder_path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } - field(:downloadCount) { |r| r.downloads.size } + field(:downloadCount) { |r| r.association(:downloads).loaded? ? r.downloads.size : r.downloads.count } end diff --git a/apps/api/app/serializers/software_detail_serializer.rb b/apps/api/app/serializers/software_detail_serializer.rb index 45b4fe7..ded44e6 100644 --- a/apps/api/app/serializers/software_detail_serializer.rb +++ b/apps/api/app/serializers/software_detail_serializer.rb @@ -3,5 +3,5 @@ class SoftwareDetailSerializer < Blueprinter::Base field(:releases) { |_, opts| ReleaseSerializer.render_as_hash(opts[:releases]) } field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest]) : nil } field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable]) : nil } - field(:totalDownloads) { |sw, _| Download.where(release: sw.releases).count } + field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 } end diff --git a/apps/api/app/services/download_service.rb b/apps/api/app/services/download_service.rb index bbc9bf6..fe00f97 100644 --- a/apps/api/app/services/download_service.rb +++ b/apps/api/app/services/download_service.rb @@ -2,14 +2,17 @@ class DownloadService BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath def call(path:, ip:, user_agent:, referer:) - full_path = BASE_PATH.join(path.to_s) + sanitized = path.to_s + full_path = BASE_PATH.join(sanitized).realpath + return nil unless full_path.to_s.start_with?(BASE_PATH.to_s) return nil unless File.file?(full_path) + escaped = sanitized.gsub("%", "\\%").gsub("_", "\\_") release = Release.find_by("cartridge_path LIKE ? OR source_path LIKE ?", - "%#{path}%", "%#{path}%") + "%#{escaped}%", "%#{escaped}%") Download.create!( - file_path: path, + file_path: sanitized, release: release, ip_address: ip, user_agent: user_agent&.truncate(500), diff --git a/apps/api/app/services/file_service.rb b/apps/api/app/services/file_service.rb index 57ac62a..9f6628a 100644 --- a/apps/api/app/services/file_service.rb +++ b/apps/api/app/services/file_service.rb @@ -3,6 +3,7 @@ class FileService def show(input) full_path = BASE_PATH.join(input.path.to_s) + return FileResultDto.not_found unless safe_path?(full_path) if File.directory?(full_path) index_path = full_path.join("index.html") @@ -16,4 +17,10 @@ class FileService FileResultDto.not_found end end + + private + + def safe_path?(path) + File.exist?(path) && Pathname.new(path).realpath.to_s.start_with?(BASE_PATH.to_s) + end end diff --git a/apps/api/app/services/software_response_builder.rb b/apps/api/app/services/software_response_builder.rb index 184131e..f4e6046 100644 --- a/apps/api/app/services/software_response_builder.rb +++ b/apps/api/app/services/software_response_builder.rb @@ -5,11 +5,13 @@ module SoftwareResponseBuilder sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first web_playable = sorted.find { |r| r.html_folder_path.present? } + total_downloads = releases.sum { |r| r.association(:downloads).loaded? ? r.downloads.size : 0 } SoftwareDetailSerializer.render_as_hash(software, - releases: sorted, - latest: latest, - web_playable: web_playable + releases: sorted, + latest: latest, + web_playable: web_playable, + total_downloads: total_downloads ) end end diff --git a/apps/api/app/views/api/swagger/index.html.erb b/apps/api/app/views/api/swagger/index.html.erb new file mode 100644 index 0000000..83720ea --- /dev/null +++ b/apps/api/app/views/api/swagger/index.html.erb @@ -0,0 +1,29 @@ + + + + + + Teletype Games API + + + + +
+ + + + diff --git a/apps/frontend/src/api/git.api.ts b/apps/frontend/src/api/git.api.ts index c92c432..b4f0ccb 100644 --- a/apps/frontend/src/api/git.api.ts +++ b/apps/frontend/src/api/git.api.ts @@ -21,7 +21,7 @@ const commits = async (owner: string, name: string, htmlUrl: string): Promise ({ + return data.map((c: Record): Commit => ({ sha: c.sha, message: c.commit?.message ?? '', author: { name: c.commit?.author?.name ?? 'Unknown', email: c.commit?.author?.email ?? '' }, diff --git a/apps/frontend/src/api/wiki.api.ts b/apps/frontend/src/api/wiki.api.ts index 43f18a2..7472eba 100644 --- a/apps/frontend/src/api/wiki.api.ts +++ b/apps/frontend/src/api/wiki.api.ts @@ -4,25 +4,35 @@ import type { WikiPage, WikiPageWithContent, WikiPageContent } from '../lib/inte // Points at Grav (which is taking over from wiki.teletypegames.org). const WIKI_BASE = import.meta.env.VITE_WIKI_BASE || 'https://wiki.teletypegames.org' -// Pages come from our own Rails API (same-origin), which proxies the Grav -// content backend: GET /api/wiki/pages?tag=[&limit=][&body=1] +interface RawWikiPage { + id: number + path: string + title?: string + description?: string + content?: string + render?: string + updatedAt: string + createdAt: string + locale: string +} + async function fetchPages( tag: string, opts: { body?: boolean; limit?: number } = {}, -): Promise { +): Promise { const params = new URLSearchParams({ tag }) if (opts.body) params.set('body', '1') if (opts.limit) params.set('limit', String(opts.limit)) const res = await fetch(`/api/wiki/pages?${params.toString()}`) - if (!res.ok) return [] + if (!res.ok) throw new Error(`Wiki API error: ${res.status}`) const json = await res.json() return json?.pages ?? [] } const listBlogPages = async (): Promise => { const pages = await fetchPages('blog', { body: true }) - return pages.map((p: any): WikiPageWithContent => ({ + return pages.map((p): WikiPageWithContent => ({ id: p.id, path: p.path, title: p.title || p.path, @@ -37,7 +47,7 @@ const listBlogPages = async (): Promise => { const getBlogPage = async (slug: string): Promise => { const pages = await fetchPages('blog', { body: true }) - const matched = pages.find((p: any) => { + const matched = pages.find((p) => { const pageSlug = p.path.startsWith('blog/') ? p.path.replace('blog/', '') : p.path return pageSlug === slug }) @@ -58,7 +68,7 @@ const getBlogPage = async (slug: string): Promise => { const listHowtoPages = async (): Promise => { const pages = await fetchPages('howto', { limit: 30 }) - return pages.map((p: any): WikiPage => ({ + return pages.map((p): WikiPage => ({ id: p.id, path: p.path, title: p.title || p.path, diff --git a/apps/frontend/src/lib/softwareUtils.ts b/apps/frontend/src/lib/softwareUtils.ts new file mode 100644 index 0000000..17f5b32 --- /dev/null +++ b/apps/frontend/src/lib/softwareUtils.ts @@ -0,0 +1,31 @@ +import type { Release, Software } from './interfaces/software.interface' + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 + +export function isNew(createdAt: string): boolean { + return (Date.now() - new Date(createdAt).getTime()) < SEVEN_DAYS_MS +} + +export function getDefaultImageUrl(sw: Software): string | null { + if (sw.images?.length) { + const def = sw.images.find(i => i.isDefault) + return def?.url ?? sw.images[0]?.url ?? null + } + return sw.imageUrl ?? null +} + +export function filterStableReleases(releases: Release[]): Release[] { + return releases + .filter(r => !r.version.startsWith('dev-')) + .sort((a, b) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime()) +} + +export function filterDevReleases(releases: Release[]): Release[] { + return releases + .filter(r => r.version.startsWith('dev-')) + .sort((a, b) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime()) +} + +export function getLatestStable(releases: Release[]): Release | undefined { + return filterStableReleases(releases)[0] +} diff --git a/apps/frontend/src/page/catalog/CatalogIndexPage.vue b/apps/frontend/src/page/catalog/CatalogIndexPage.vue index dcbd722..446dbf2 100644 --- a/apps/frontend/src/page/catalog/CatalogIndexPage.vue +++ b/apps/frontend/src/page/catalog/CatalogIndexPage.vue @@ -69,6 +69,7 @@ import { storeToRefs } from 'pinia' import { RouterLink } from 'vue-router' import { useI18n } from 'vue-i18n' import { useSoftwareStore } from '../../stores/software.store' +import { getDefaultImageUrl, getLatestStable } from '../../lib/softwareUtils' import type { Software, SoftwareEntry } from '../../lib/interfaces/software.interface' const { t } = useI18n() @@ -78,21 +79,12 @@ const activeFilter = ref('released') const store = useSoftwareStore() const { items: softwares } = storeToRefs(store) -const { getLatestStable } = store function getTotalDownloads(name: string): number | null { const entry = softwares.value.find((e: SoftwareEntry) => e.software.name === name) return entry?.totalDownloads || null } -function getDefaultImageUrl(sw: Software): string | null { - if (sw.images?.length) { - const def = sw.images.find(i => i.isDefault) - return def?.url ?? sw.images[0]?.url ?? null - } - return sw.imageUrl ?? null -} - onMounted(() => store.fetchAll()) diff --git a/apps/frontend/src/page/catalog/CatalogShowPage.vue b/apps/frontend/src/page/catalog/CatalogShowPage.vue index ff06593..14da263 100644 --- a/apps/frontend/src/page/catalog/CatalogShowPage.vue +++ b/apps/frontend/src/page/catalog/CatalogShowPage.vue @@ -200,6 +200,7 @@ import { RouterLink, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { formatDateTime } from '../../lib/dateFormat' import { useSoftwareStore } from '../../stores/software.store' +import { filterStableReleases, filterDevReleases } from '../../lib/softwareUtils' import type { Release, Software, SoftwareImage } from '../../lib/interfaces/software.interface' import ImageLightbox from './ImageLightbox.vue' @@ -238,17 +239,8 @@ function openLightbox(index: number) { lightboxOpen.value = true } -const stableReleases = computed(() => - releases.value - .filter(r => !r.version.startsWith('dev-')) - .sort((a, b) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime()) -) - -const devReleases = computed(() => - releases.value - .filter(r => r.version.startsWith('dev-')) - .sort((a, b) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime()) -) +const stableReleases = computed(() => filterStableReleases(releases.value)) +const devReleases = computed(() => filterDevReleases(releases.value)) const allReleases = computed(() => [...stableReleases.value, ...devReleases.value]) const latestStable = computed(() => stableReleases.value[0] ?? null) diff --git a/apps/frontend/src/page/home/HomePage.vue b/apps/frontend/src/page/home/HomePage.vue index 686a025..fb4faa6 100644 --- a/apps/frontend/src/page/home/HomePage.vue +++ b/apps/frontend/src/page/home/HomePage.vue @@ -227,6 +227,7 @@ import { useI18n } from 'vue-i18n' import { useEventStore } from '../../stores/event.store' import { useSoftwareStore } from '../../stores/software.store' import { useYoutubeStore } from '../../stores/youtube.store' +import { getDefaultImageUrl } from '../../lib/softwareUtils' const { t } = useI18n() @@ -239,11 +240,7 @@ const { highlighted: highlightedSoftware, highlightedStableRelease } = storeToRe const highlightedImageUrl = computed(() => { const sw = highlightedSoftware.value?.software if (!sw) return null - if (sw.images?.length) { - const def = sw.images.find(i => i.isDefault) - return def?.url ?? sw.images[0]?.url ?? null - } - return sw.imageUrl ?? null + return getDefaultImageUrl(sw) }) const ytStore = useYoutubeStore() diff --git a/apps/frontend/src/stores/blog.store.ts b/apps/frontend/src/stores/blog.store.ts index d61d5bf..01e1fab 100644 --- a/apps/frontend/src/stores/blog.store.ts +++ b/apps/frontend/src/stores/blog.store.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import wikiApi from '../api/wiki.api' +import { isNew } from '../lib/softwareUtils' import type { WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface' export const useBlogStore = defineStore('blog', () => { @@ -45,10 +46,6 @@ export const useBlogStore = defineStore('blog', () => { } } - function isNew(createdAt: string): boolean { - return (new Date().getTime() - new Date(createdAt).getTime()) < 7 * 24 * 60 * 60 * 1000 - } - function getPermalink(path: string): string { const slug = path.startsWith('blog/') ? path.replace('blog/', '') : path return `/blog/${slug}` diff --git a/apps/frontend/src/stores/event.store.ts b/apps/frontend/src/stores/event.store.ts index 24d80c7..2a68539 100644 --- a/apps/frontend/src/stores/event.store.ts +++ b/apps/frontend/src/stores/event.store.ts @@ -11,10 +11,15 @@ export const useEventStore = defineStore('event', () => { const nextEvent = ref(null) const followingEvents = ref([]) const countdownText = ref('-- : -- : -- : --') + const loading = ref(false) + const error = ref(null) let countdownInterval: ReturnType | null = null + let _loaded = false function startCountdown(targetISO: string) { + if (countdownInterval) clearInterval(countdownInterval) + const targetDate = new Date(targetISO).getTime() function update() { @@ -22,6 +27,7 @@ export const useEventStore = defineStore('event', () => { if (distance < 0) { countdownText.value = 'LIVE NOW!' if (countdownInterval) clearInterval(countdownInterval) + countdownInterval = null return } const d = String(Math.floor(distance / 86400000)).padStart(2, '0') @@ -36,6 +42,9 @@ export const useEventStore = defineStore('event', () => { } async function fetch() { + if (_loaded) return + loading.value = true + error.value = null try { const events = await eventApi.index() if (events.length > 0) { @@ -45,8 +54,11 @@ export const useEventStore = defineStore('event', () => { followingEvents.value = events.slice(1).map(e => ({ name: e.name, date: e.date, dateText: formatDateTime(new Date(e.date)) })) startCountdown(firstDate.toISOString()) } - } catch (e) { - console.error('Failed to load events:', e) + _loaded = true + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false } } @@ -57,5 +69,5 @@ export const useEventStore = defineStore('event', () => { } } - return { nextEvent, followingEvents, countdownText, fetch, cleanup } + return { nextEvent, followingEvents, countdownText, loading, error, fetch, cleanup } }) diff --git a/apps/frontend/src/stores/git.store.ts b/apps/frontend/src/stores/git.store.ts index 5ec28ae..79e027b 100644 --- a/apps/frontend/src/stores/git.store.ts +++ b/apps/frontend/src/stores/git.store.ts @@ -6,9 +6,14 @@ import type { GiteaRepo, Commit } from '../lib/interfaces/git.interface' export const useGitStore = defineStore('git', () => { const repos = ref([]) const commits = ref([]) + const loading = ref(false) const error = ref(null) + let _loaded = false async function fetchAll() { + if (_loaded) return + loading.value = true + error.value = null try { repos.value = await gitApi.repos() @@ -19,10 +24,13 @@ export const useGitStore = defineStore('git', () => { const all = (await Promise.all(commitPromises)).flat() all.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) commits.value = all.slice(0, 20) + _loaded = true } catch (e: any) { error.value = e.message + } finally { + loading.value = false } } - return { repos, commits, error, fetchAll } + return { repos, commits, loading, error, fetchAll } }) diff --git a/apps/frontend/src/stores/howtos.store.ts b/apps/frontend/src/stores/howtos.store.ts index 1dede72..9fc121a 100644 --- a/apps/frontend/src/stores/howtos.store.ts +++ b/apps/frontend/src/stores/howtos.store.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import wikiApi from '../api/wiki.api' +import { isNew } from '../lib/softwareUtils' import type { WikiPage } from '../lib/interfaces/wiki.interface' export const useHowtosStore = defineStore('howtos', () => { @@ -22,9 +23,5 @@ export const useHowtosStore = defineStore('howtos', () => { } } - function isNew(createdAt: string): boolean { - return (new Date().getTime() - new Date(createdAt).getTime()) < 7 * 24 * 60 * 60 * 1000 - } - return { pages, loading, error, fetchPages, isNew } }) diff --git a/apps/frontend/src/stores/member.store.ts b/apps/frontend/src/stores/member.store.ts index 4f690a6..0eb77cd 100644 --- a/apps/frontend/src/stores/member.store.ts +++ b/apps/frontend/src/stores/member.store.ts @@ -5,15 +5,21 @@ import type { Member } from '../lib/interfaces/member.interface' export const useMemberStore = defineStore('member', () => { const members = ref([]) + const loading = ref(false) + const error = ref(null) let _loaded = false async function fetch() { if (_loaded) return + loading.value = true + error.value = null try { members.value = await memberApi.index() _loaded = true - } catch (e) { - console.error('Failed to load team members:', e) + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false } } @@ -22,5 +28,5 @@ export const useMemberStore = defineStore('member', () => { return new URL(`../assets/team/${member.avatar_filename}`, import.meta.url).href } - return { members, fetch, resolvedAvatarUrl } + return { members, loading, error, fetch, resolvedAvatarUrl } }) diff --git a/apps/frontend/src/stores/software.store.ts b/apps/frontend/src/stores/software.store.ts index e506704..f02432e 100644 --- a/apps/frontend/src/stores/software.store.ts +++ b/apps/frontend/src/stores/software.store.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import softwareApi from '../api/software.api' +import { getLatestStable } from '../lib/softwareUtils' import type { SoftwareEntry, Release } from '../lib/interfaces/software.interface' export const useSoftwareStore = defineStore('software', () => { @@ -10,17 +11,15 @@ export const useSoftwareStore = defineStore('software', () => { const error = ref(null) let _loaded = false - const highlightedStableRelease = computed(() => { + const highlightedStableRelease = computed((): Release | null => { if (!highlighted.value?.releases) return null - const stable = highlighted.value.releases - .filter((r: Release) => !r.version.startsWith('dev-')) - .sort((a: Release, b: Release) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime()) - return stable[0] ?? null + return getLatestStable(highlighted.value.releases) ?? null }) async function fetchAll() { if (_loaded) return loading.value = true + error.value = null try { items.value = await softwareApi.index() _loaded = true @@ -34,8 +33,8 @@ export const useSoftwareStore = defineStore('software', () => { async function fetchHighlighted() { try { highlighted.value = await softwareApi.highlighted() - } catch (e) { - console.error('Failed to fetch highlighted software:', e) + } catch (e: any) { + error.value = e.message } } @@ -43,11 +42,5 @@ export const useSoftwareStore = defineStore('software', () => { return items.value.find(s => s.software.name === name) } - function getLatestStable(releases: Release[]): Release { - return releases - .filter(r => !r.version.startsWith('dev-')) - .sort((a, b) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime())[0] - } - - return { items, highlighted, loading, error, highlightedStableRelease, fetchAll, fetchHighlighted, findByName, getLatestStable } + return { items, highlighted, loading, error, highlightedStableRelease, fetchAll, fetchHighlighted, findByName } }) diff --git a/apps/frontend/src/stores/youtube.store.ts b/apps/frontend/src/stores/youtube.store.ts index e1246c5..ec8f336 100644 --- a/apps/frontend/src/stores/youtube.store.ts +++ b/apps/frontend/src/stores/youtube.store.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import youtubeApi from '../api/youtube.api' +import { formatDateTime } from '../lib/dateFormat' export const useYoutubeStore = defineStore('youtube', () => { const state = ref<'loading' | 'loaded' | 'error' | 'no-config'>('loading') @@ -10,11 +11,6 @@ export const useYoutubeStore = defineStore('youtube', () => { const viewCount = ref('') const error = ref('') - function formatDate(iso: string): string { - const d = new Date(iso) - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` - } - function formatViews(n: number): string { if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M views' if (n >= 1_000) return Math.round(n / 1_000) + 'K views' @@ -31,7 +27,7 @@ export const useYoutubeStore = defineStore('youtube', () => { } videoId.value = video.id title.value = video.title - publishDate.value = formatDate(video.publishDate) + publishDate.value = formatDateTime(video.publishDate) viewCount.value = video.viewCount ? formatViews(Number(video.viewCount)) : '' state.value = 'loaded' } catch (err: any) {