refact round
This commit is contained in:
@@ -13,7 +13,7 @@ class Api::DownloadsController < ApiController
|
||||
path = params[:path]
|
||||
return render(json: { error: "Path is required" }, status: :bad_request) if path.blank?
|
||||
|
||||
full_path = DownloadService.new.call(
|
||||
full_path = DownloadService.new.create(
|
||||
path: path,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent,
|
||||
|
||||
@@ -27,7 +27,7 @@ class Api::WikiController < ApiController
|
||||
end
|
||||
# GET /api/wiki/pages?tag=blog|howto[&limit=30][&body=1]
|
||||
def index
|
||||
render json: WikiService.new.pages(
|
||||
render json: WikiService.new.index(
|
||||
tag: params[:tag],
|
||||
limit: params[:limit],
|
||||
body: params[:body]
|
||||
|
||||
@@ -4,6 +4,15 @@ class UpdateController < ApiController
|
||||
formats [ "text" ]
|
||||
end
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render plain: e.message, status: :bad_request
|
||||
end
|
||||
|
||||
rescue_from StandardError do |e|
|
||||
Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
||||
render plain: "Internal server error", status: :internal_server_error
|
||||
end
|
||||
|
||||
api :GET, "/update", "Update software version in database"
|
||||
param :secret, String, required: true, desc: "Authorization secret"
|
||||
param :platform, String, required: false, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
|
||||
@@ -25,11 +34,6 @@ class UpdateController < ApiController
|
||||
|
||||
UpdateService.new.update(input)
|
||||
render plain: "Updated"
|
||||
rescue ArgumentError => e
|
||||
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: "Internal server error", status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -4,8 +4,10 @@ class AdminUser < ApplicationRecord
|
||||
|
||||
belongs_to :member, optional: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[created_at email id member_id updated_at]
|
||||
%w[created_at deleted_at email id member_id updated_at]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
|
||||
@@ -3,8 +3,10 @@ class Download < ApplicationRecord
|
||||
|
||||
validates :file_path, presence: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[created_at file_path id ip_address referer release_id updated_at user_agent]
|
||||
%w[created_at deleted_at file_path id ip_address referer release_id updated_at user_agent]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
|
||||
@@ -3,12 +3,14 @@ class Image < ApplicationRecord
|
||||
|
||||
has_many :software_images, dependent: :restrict_with_error
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
attr_accessor :file_upload
|
||||
|
||||
before_save :process_upload, if: -> { file_upload.present? }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[content_type created_at filename id original_filename updated_at]
|
||||
%w[content_type created_at deleted_at filename id original_filename updated_at]
|
||||
end
|
||||
|
||||
def file_path
|
||||
|
||||
@@ -4,8 +4,10 @@ class Member < ApplicationRecord
|
||||
|
||||
validates :nick, presence: true, uniqueness: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[avatar_filename created_at id image_id motto nick real_nick updated_at]
|
||||
%w[avatar_filename created_at deleted_at id image_id motto nick real_nick updated_at]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
module TimestampFields
|
||||
GO_ZERO_TIME = "0001-01-01T00:00:00Z"
|
||||
TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%3NZ"
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
class EventSerializer < Blueprinter::Base
|
||||
DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
||||
include TimestampFields
|
||||
|
||||
field :name
|
||||
field(:date) { |event| event.date.utc.strftime(DATE_FORMAT) }
|
||||
field(:date) { |event| event.date.utc.strftime(TS_FORMAT) }
|
||||
end
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
class ExternalLinkSerializer < Blueprinter::Base
|
||||
GO_ZERO_TIME = "0001-01-01T00:00:00Z"
|
||||
TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%3NZ"
|
||||
include TimestampFields
|
||||
|
||||
field(:ID) { |el| el.id }
|
||||
field(:CreatedAt) { |el| el.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:UpdatedAt) { |el| el.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:DeletedAt) { |el| el.deleted_at&.utc&.strftime(TS_FORMAT) }
|
||||
field(:id) { |el| el.id }
|
||||
field(:createdAt) { |el| el.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:updatedAt) { |el| el.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:deletedAt) { |el| el.deleted_at&.utc&.strftime(TS_FORMAT) }
|
||||
field(:softwareId) { |el| el.software_id }
|
||||
field :label
|
||||
field :url
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
class MemberSerializer < Blueprinter::Base
|
||||
fields :nick, :real_nick, :motto, :avatar_filename
|
||||
fields :nick, :motto
|
||||
|
||||
field(:image_url) { |member| member.image_id ? "/api/image/#{member.image_id}" : nil }
|
||||
field(:realNick) { |m| m.real_nick }
|
||||
field(:avatarFilename) { |m| m.avatar_filename }
|
||||
field(:imageUrl) { |m| m.image_id ? "/api/image/#{m.image_id}" : nil }
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class ReleaseSerializer < Blueprinter::Base
|
||||
GO_ZERO_TIME = "0001-01-01T00:00:00Z"
|
||||
TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%3NZ"
|
||||
include TimestampFields
|
||||
|
||||
FILE_PATH_FROM = "/softwares/"
|
||||
FILE_PATH_TO = "/file/"
|
||||
|
||||
@@ -13,10 +13,10 @@ class ReleaseSerializer < Blueprinter::Base
|
||||
asset ? asset.path.gsub(FILE_PATH_FROM, FILE_PATH_TO) : ""
|
||||
end
|
||||
|
||||
field(:ID) { |r| r.id }
|
||||
field(:CreatedAt) { |r| r.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:UpdatedAt) { |r| r.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:DeletedAt) { |r| r.deleted_at&.utc&.strftime(TS_FORMAT) }
|
||||
field(:id) { |r| r.id }
|
||||
field(:createdAt) { |r| r.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:updatedAt) { |r| r.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:deletedAt) { |r| r.deleted_at&.utc&.strftime(TS_FORMAT) }
|
||||
field(:softwareId) { |r| r.software_id }
|
||||
field :version
|
||||
field(:cartridgePath) { |r| asset_path(r, "cartridge") }
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
class SoftwareSerializer < Blueprinter::Base
|
||||
GO_ZERO_TIME = "0001-01-01T00:00:00Z"
|
||||
TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%3NZ"
|
||||
include TimestampFields
|
||||
|
||||
field(:ID) { |sw| sw.id }
|
||||
field(:CreatedAt) { |sw| sw.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:UpdatedAt) { |sw| sw.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:DeletedAt) { |sw| sw.deleted_at&.utc&.strftime(TS_FORMAT) }
|
||||
field(:id) { |sw| sw.id }
|
||||
field(:createdAt) { |sw| sw.created_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:updatedAt) { |sw| sw.updated_at&.utc&.strftime(TS_FORMAT) || GO_ZERO_TIME }
|
||||
field(:deletedAt) { |sw| sw.deleted_at&.utc&.strftime(TS_FORMAT) }
|
||||
field :name
|
||||
field :title
|
||||
field :author
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class BuildsService
|
||||
def index
|
||||
platforms = UpdateService::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
|
||||
platforms = PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
|
||||
service_class = "SoftwareUpdater::#{platform.camelize}Service".constantize
|
||||
hash[platform] = {
|
||||
label: service_class.label,
|
||||
|
||||
@@ -2,7 +2,7 @@ class DownloadService
|
||||
CONTAINER_BASE = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
||||
BASE_PATH = Pathname.new(CONTAINER_BASE).realpath
|
||||
|
||||
def call(path:, ip:, user_agent:, referer:)
|
||||
def create(path:, ip:, user_agent:, referer:)
|
||||
sanitized = path.to_s
|
||||
full_path = BASE_PATH.join(sanitized).realpath
|
||||
return nil unless full_path.to_s.start_with?(BASE_PATH.to_s)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class UpdateService
|
||||
SUPPORTED_PLATFORMS = %w[tic80 ebitengine love c64 godot bevy phaser].freeze
|
||||
|
||||
def update(input)
|
||||
unless SUPPORTED_PLATFORMS.include?(input.platform)
|
||||
unless PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
|
||||
raise ArgumentError, "Unsupported platform: #{input.platform}"
|
||||
end
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ require "json"
|
||||
class WikiService
|
||||
GRAV_URL = ENV.fetch("WIKI_GRAV_URL", "http://localhost:8080").freeze
|
||||
|
||||
def pages(tag:, limit: nil, body: nil)
|
||||
def index(tag:, limit: nil, body: nil)
|
||||
query = { tag: tag }
|
||||
query[:limit] = limit if limit.present?
|
||||
query[:body] = body if body.present?
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
class AddDeletedAtToMissingModels < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
%i[images members downloads admin_users].each do |table|
|
||||
add_column table, :deleted_at, :datetime, precision: 3
|
||||
add_index table, :deleted_at, name: "idx_#{table}_deleted_at"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ const index = async (): Promise<SoftwareEntry[]> => {
|
||||
|
||||
const highlighted = async (): Promise<SoftwareEntry | null> => {
|
||||
const res = await fetch('/api/software/highlighted')
|
||||
if (!res.ok) return null
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface Member {
|
||||
nick: string
|
||||
real_nick: string
|
||||
realNick: string
|
||||
motto: string
|
||||
avatar_filename: string
|
||||
image_url?: string | null
|
||||
avatarFilename: string
|
||||
imageUrl?: string | null
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface Release {
|
||||
docsFolderPath?: string
|
||||
assets?: ReleaseAsset[]
|
||||
downloadCount?: number
|
||||
UpdatedAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SoftwareImage {
|
||||
|
||||
@@ -17,13 +17,13 @@ export function getDefaultImageUrl(sw: Software): string | 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())
|
||||
.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())
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
}
|
||||
|
||||
export function getLatestStable(releases: Release[]): Release | undefined {
|
||||
|
||||
@@ -86,7 +86,7 @@ const store = useBlogStore()
|
||||
const { pages: blogPages, loading, error } = storeToRefs(store)
|
||||
const { isNew, getPermalink, getCleanPreview } = store
|
||||
|
||||
onMounted(() => store.fetchPages())
|
||||
onMounted(() => store.fetch())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -32,9 +32,7 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-else-if="loading" class="blog-post-card">
|
||||
<div class="blog-post-content text-center text-gray-400 py-12">Loading...</div>
|
||||
</div>
|
||||
<SkeletonCard v-else-if="loading" :count="1" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -46,10 +44,11 @@ import { RouterLink, useRoute } from 'vue-router'
|
||||
import { formatDateTime } from '../../lib/dateFormat'
|
||||
import { sanitize } from '../../lib/sanitize'
|
||||
import { useBlogStore } from '../../stores/blog.store'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useBlogStore()
|
||||
const { currentPage: pageContent, currentPageLoading: loading, currentPageError: error } = storeToRefs(store)
|
||||
const { currentPage: pageContent, pageLoading: loading, pageError: error } = storeToRefs(store)
|
||||
|
||||
onMounted(() => store.fetchPage(route.params.slug as string))
|
||||
</script>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<main class="main-container py-12 px-4 mt-0">
|
||||
<div v-if="error" class="builds-error">{{ error }}</div>
|
||||
|
||||
<div v-else-if="!data" class="builds-loading">{{ t('common.loading') }}</div>
|
||||
<SkeletonCard v-else-if="!data" :count="3" />
|
||||
|
||||
<!-- Desktop table -->
|
||||
<div v-else class="builds-desktop">
|
||||
@@ -86,6 +86,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBuildsStore } from '../../stores/builds.store'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="software-list">
|
||||
<SkeletonCard v-if="loading" :count="4" />
|
||||
|
||||
<div v-else class="software-list">
|
||||
<template v-for="{ software, releases } in softwares" :key="software.name">
|
||||
<div v-show="activeFilter === 'all' || software.status === activeFilter" class="software-card group">
|
||||
<div class="software-card-content">
|
||||
@@ -76,6 +78,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useSoftwareStore } from '../../stores/software.store'
|
||||
import { getDefaultImageUrl, getLatestStable } from '../../lib/softwareUtils'
|
||||
import PlatformBadge from '../../components/PlatformBadge.vue'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
import type { SoftwareEntry } from '../../lib/interfaces/software.interface'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -84,7 +87,7 @@ const allFilters = ['all', 'released', 'demo', 'development', 'archived']
|
||||
const activeFilter = ref('released')
|
||||
|
||||
const store = useSoftwareStore()
|
||||
const { items: softwares } = storeToRefs(store)
|
||||
const { items: softwares, loading } = storeToRefs(store)
|
||||
|
||||
const filters = computed(() => {
|
||||
const present = new Set(softwares.value.map((e: SoftwareEntry) => e.software.status))
|
||||
@@ -100,7 +103,7 @@ function getTotalDownloads(name: string): number | null {
|
||||
return entry?.totalDownloads || null
|
||||
}
|
||||
|
||||
onMounted(() => store.fetchAll())
|
||||
onMounted(() => store.fetch())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -336,7 +336,7 @@ const assetKindIcon = (kind: string) =>
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.fetchAll()
|
||||
await store.fetch()
|
||||
const item = store.findByName(route.params.name as string)
|
||||
if (item) {
|
||||
software.value = item.software
|
||||
|
||||
@@ -71,7 +71,7 @@ const { t } = useI18n()
|
||||
const store = useGitStore()
|
||||
const { repos: publicRepos, commits: recentCommits, error } = storeToRefs(store)
|
||||
|
||||
onMounted(() => store.fetchAll())
|
||||
onMounted(() => store.fetch())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -86,14 +86,14 @@
|
||||
<div class="yt-content-grid">
|
||||
<div class="yt-player-container">
|
||||
<div class="yt-player-wrap">
|
||||
<div v-if="ytState === 'loading'" class="yt-state-overlay">
|
||||
<div v-if="ytLoading" class="yt-state-overlay">
|
||||
<div class="yt-loading-spinner"></div>
|
||||
</div>
|
||||
<div v-else-if="ytState === 'error'" class="yt-state-overlay flex-col gap-2">
|
||||
<div v-else-if="ytError" class="yt-state-overlay flex-col gap-2">
|
||||
<div class="yt-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
||||
<div class="yt-error-text">{{ ytError }}</div>
|
||||
</div>
|
||||
<div v-else-if="ytState === 'no-config'" class="yt-state-overlay">
|
||||
<div v-else-if="ytNoConfig" class="yt-state-overlay">
|
||||
<div class="yt-noconfig-text">{{ t('common.missingApiConfig') }}</div>
|
||||
</div>
|
||||
<iframe
|
||||
@@ -244,12 +244,12 @@ const highlightedImageUrl = computed(() => {
|
||||
})
|
||||
|
||||
const ytStore = useYoutubeStore()
|
||||
const { state: ytState, videoId: ytVideoId, title: ytTitle, publishDate: ytPublishDate, viewCount: ytViewCount, error: ytError } = storeToRefs(ytStore)
|
||||
const { loading: ytLoading, error: ytError, noConfig: ytNoConfig, videoId: ytVideoId, title: ytTitle, publishDate: ytPublishDate, viewCount: ytViewCount } = storeToRefs(ytStore)
|
||||
|
||||
onMounted(() => {
|
||||
eventStore.fetch()
|
||||
softwareStore.fetchHighlighted()
|
||||
ytStore.fetchLatest()
|
||||
ytStore.fetch()
|
||||
})
|
||||
|
||||
onUnmounted(() => eventStore.cleanup())
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading && recentPages.length === 0" class="empty-state">
|
||||
<SkeletonCard v-else-if="loading" :count="4" />
|
||||
|
||||
<div v-else-if="recentPages.length === 0" class="empty-state">
|
||||
<div class="empty-state-icon"><i class="fa-solid fa-inbox"></i></div>
|
||||
<h2 class="empty-state-title">{{ t('howtos.noPagesTitle') }}</h2>
|
||||
<p class="empty-state-desc">{{ t('howtos.noPagesDesc') }}</p>
|
||||
@@ -80,6 +82,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime } from '../../lib/dateFormat'
|
||||
import { WIKI_BASE } from '../../api/wiki.api'
|
||||
import { useHowtosStore } from '../../stores/howtos.store'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -87,7 +90,7 @@ const store = useHowtosStore()
|
||||
const { pages: recentPages, loading, error } = storeToRefs(store)
|
||||
const { isNew } = store
|
||||
|
||||
onMounted(() => store.fetchPages())
|
||||
onMounted(() => store.fetch())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
</header>
|
||||
|
||||
<main class="main-container py-16 px-4">
|
||||
<div class="team-grid">
|
||||
<SkeletonCard v-if="loading" :count="4" />
|
||||
|
||||
<div v-else class="team-grid">
|
||||
<div v-for="member in members" :key="member.nick" class="team-card">
|
||||
<div class="team-card-image-container">
|
||||
<img :src="resolvedAvatarUrl(member)" :alt="member.nick" class="team-card-image" />
|
||||
</div>
|
||||
<div class="team-card-content">
|
||||
<h2 class="team-card-title">{{ member.nick }}: {{ member.real_nick }}</h2>
|
||||
<h2 class="team-card-title">{{ member.nick }}: {{ member.realNick }}</h2>
|
||||
<p class="team-card-desc">{{ member.motto }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,11 +28,12 @@ import { onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMemberStore } from '../../stores/member.store'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const store = useMemberStore()
|
||||
const { members } = storeToRefs(store)
|
||||
const { members, loading } = storeToRefs(store)
|
||||
const { resolvedAvatarUrl } = store
|
||||
|
||||
onMounted(() => store.fetch())
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('useSoftwareStore', () => {
|
||||
|
||||
it('fetches software items', async () => {
|
||||
const store = useSoftwareStore()
|
||||
await store.fetchAll()
|
||||
await store.fetch()
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0].software.name).toBe('test-game')
|
||||
expect(store.loading).toBe(false)
|
||||
@@ -29,14 +29,14 @@ describe('useSoftwareStore', () => {
|
||||
|
||||
it('caches after first fetch', async () => {
|
||||
const store = useSoftwareStore()
|
||||
await store.fetchAll()
|
||||
await store.fetchAll()
|
||||
await store.fetch()
|
||||
await store.fetch()
|
||||
expect(mockIndex).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('findByName returns correct entry', async () => {
|
||||
const store = useSoftwareStore()
|
||||
await store.fetchAll()
|
||||
await store.fetch()
|
||||
expect(store.findByName('test-game')).toBeDefined()
|
||||
expect(store.findByName('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -10,10 +10,9 @@ export const useBlogStore = defineStore('blog', () => {
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
const currentPage = ref<WikiPageContent | null>(null)
|
||||
const currentPageLoading = ref(false)
|
||||
const currentPageError = ref<string | null>(null)
|
||||
const { loading: pageLoading, error: pageError, withCache: withPageCache } = useLoadable(0)
|
||||
|
||||
async function fetchPages() {
|
||||
async function fetch() {
|
||||
await withCache(async () => {
|
||||
pages.value = await wikiApi.listBlogPages()
|
||||
})
|
||||
@@ -21,20 +20,11 @@ export const useBlogStore = defineStore('blog', () => {
|
||||
|
||||
async function fetchPage(slug: string) {
|
||||
currentPage.value = null
|
||||
currentPageError.value = null
|
||||
currentPageLoading.value = true
|
||||
try {
|
||||
await withPageCache(async () => {
|
||||
const result = await wikiApi.getBlogPage(slug)
|
||||
if (result) {
|
||||
currentPage.value = result
|
||||
} else {
|
||||
currentPageError.value = 'Post not found'
|
||||
}
|
||||
} catch (e) {
|
||||
currentPageError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
currentPageLoading.value = false
|
||||
}
|
||||
if (!result) throw new Error('Post not found')
|
||||
currentPage.value = result
|
||||
})
|
||||
}
|
||||
|
||||
function getPermalink(path: string): string {
|
||||
@@ -47,5 +37,5 @@ export const useBlogStore = defineStore('blog', () => {
|
||||
return content.replace(/[#*`_[\]()]/g, '').trim().slice(0, 300) + '...'
|
||||
}
|
||||
|
||||
return { pages, loading, error, currentPage, currentPageLoading, currentPageError, fetchPages, fetchPage, isNew, getPermalink, getCleanPreview, invalidate }
|
||||
return { pages, loading, error, currentPage, pageLoading, pageError, fetch, fetchPage, isNew, getPermalink, getCleanPreview, invalidate }
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ export const useGitStore = defineStore('git', () => {
|
||||
const commits = ref<Commit[]>([])
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
async function fetchAll() {
|
||||
async function fetch() {
|
||||
await withCache(async () => {
|
||||
repos.value = await gitApi.repos()
|
||||
|
||||
@@ -23,5 +23,5 @@ export const useGitStore = defineStore('git', () => {
|
||||
})
|
||||
}
|
||||
|
||||
return { repos, commits, loading, error, fetchAll, invalidate }
|
||||
return { repos, commits, loading, error, fetch, invalidate }
|
||||
})
|
||||
|
||||
@@ -9,11 +9,11 @@ export const useHowtosStore = defineStore('howtos', () => {
|
||||
const pages = ref<WikiPage[]>([])
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
async function fetchPages() {
|
||||
async function fetch() {
|
||||
await withCache(async () => {
|
||||
pages.value = await wikiApi.listHowtoPages()
|
||||
})
|
||||
}
|
||||
|
||||
return { pages, loading, error, fetchPages, isNew, invalidate }
|
||||
return { pages, loading, error, fetch, isNew, invalidate }
|
||||
})
|
||||
|
||||
@@ -15,8 +15,8 @@ export const useMemberStore = defineStore('member', () => {
|
||||
}
|
||||
|
||||
function resolvedAvatarUrl(member: Member): string {
|
||||
if (member.image_url) return member.image_url
|
||||
return new URL(`../assets/team/${member.avatar_filename}`, import.meta.url).href
|
||||
if (member.imageUrl) return member.imageUrl
|
||||
return new URL(`../assets/team/${member.avatarFilename}`, import.meta.url).href
|
||||
}
|
||||
|
||||
return { members, loading, error, fetch, resolvedAvatarUrl, invalidate }
|
||||
|
||||
@@ -9,29 +9,28 @@ export const useSoftwareStore = defineStore('software', () => {
|
||||
const items = ref<SoftwareEntry[]>([])
|
||||
const highlighted = ref<SoftwareEntry | null>(null)
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
const { loading: highlightedLoading, error: highlightedError, withCache: withHighlightedCache } = useLoadable(0)
|
||||
|
||||
const highlightedStableRelease = computed((): Release | null => {
|
||||
if (!highlighted.value?.releases) return null
|
||||
return getLatestStable(highlighted.value.releases) ?? null
|
||||
})
|
||||
|
||||
async function fetchAll() {
|
||||
async function fetch() {
|
||||
await withCache(async () => {
|
||||
items.value = await softwareApi.index()
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchHighlighted() {
|
||||
try {
|
||||
await withHighlightedCache(async () => {
|
||||
highlighted.value = await softwareApi.highlighted()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function findByName(name: string): SoftwareEntry | undefined {
|
||||
return items.value.find(s => s.software.name === name)
|
||||
}
|
||||
|
||||
return { items, highlighted, loading, error, highlightedStableRelease, fetchAll, fetchHighlighted, findByName, invalidate }
|
||||
return { items, highlighted, loading, error, highlightedLoading, highlightedError, highlightedStableRelease, fetch, fetchHighlighted, findByName, invalidate }
|
||||
})
|
||||
|
||||
@@ -2,14 +2,15 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import youtubeApi from '../api/youtube.api'
|
||||
import { formatDateTime } from '../lib/dateFormat'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
|
||||
export const useYoutubeStore = defineStore('youtube', () => {
|
||||
const state = ref<'loading' | 'loaded' | 'error' | 'no-config'>('loading')
|
||||
const { loading, error, withCache } = useLoadable(0)
|
||||
const noConfig = ref(false)
|
||||
const videoId = ref('')
|
||||
const title = ref('')
|
||||
const publishDate = ref('')
|
||||
const viewCount = ref('')
|
||||
const error = ref('')
|
||||
|
||||
function formatViews(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M views'
|
||||
@@ -17,24 +18,20 @@ export const useYoutubeStore = defineStore('youtube', () => {
|
||||
return n + ' views'
|
||||
}
|
||||
|
||||
async function fetchLatest() {
|
||||
state.value = 'loading'
|
||||
try {
|
||||
async function fetch() {
|
||||
noConfig.value = false
|
||||
await withCache(async () => {
|
||||
const video = await youtubeApi.latestVideo()
|
||||
if (!video) {
|
||||
state.value = 'no-config'
|
||||
noConfig.value = true
|
||||
return
|
||||
}
|
||||
videoId.value = video.id
|
||||
title.value = video.title
|
||||
publishDate.value = formatDateTime(video.publishDate)
|
||||
viewCount.value = video.viewCount ? formatViews(Number(video.viewCount)) : ''
|
||||
state.value = 'loaded'
|
||||
} catch (err) {
|
||||
error.value = 'Error loading video: ' + (err instanceof Error ? err.message : String(err))
|
||||
state.value = 'error'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { state, videoId, title, publishDate, viewCount, error, fetchLatest }
|
||||
return { loading, error, noConfig, videoId, title, publishDate, viewCount, fetch }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user