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) {