more tweaks
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
--require spec_helper
|
||||
--format documentation
|
||||
--color
|
||||
@@ -18,4 +18,7 @@ gem "sassc-rails"
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
gem "rspec-rails", "~> 7.0"
|
||||
gem "factory_bot_rails"
|
||||
gem "shoulda-matchers", "~> 6.0"
|
||||
end
|
||||
|
||||
@@ -11,7 +11,7 @@ class Api::DownloadsController < ApiController
|
||||
error code: 404, desc: "File not found"
|
||||
def show
|
||||
path = params[:path]
|
||||
return head :bad_request if path.blank?
|
||||
return render(json: { error: "Path is required" }, status: :bad_request) if path.blank?
|
||||
|
||||
full_path = DownloadService.new.call(
|
||||
path: path,
|
||||
@@ -23,7 +23,7 @@ class Api::DownloadsController < ApiController
|
||||
if full_path
|
||||
send_file full_path, disposition: "attachment", type: resolve_mime(full_path)
|
||||
else
|
||||
head :not_found
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,5 @@ class Api::ImagesController < ApiController
|
||||
def show
|
||||
image = ImageService.new.show(ImageShowInputDto.new(id: params[:id]))
|
||||
send_file image.file_path, type: image.content_type, disposition: "inline"
|
||||
rescue Errno::ENOENT
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,14 @@ class ApiController < ActionController::API
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from Errno::ENOENT do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_mime(path)
|
||||
|
||||
@@ -6,14 +6,16 @@ module SoftwareUpdater
|
||||
metadata = parse_json_metadata(full_path("#{versioned}.metadata.json"))
|
||||
site_url = metadata.delete(:site)
|
||||
|
||||
software = update_or_create_software(metadata.merge(platform: "c64"))
|
||||
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
|
||||
ActiveRecord::Base.transaction do
|
||||
software = update_or_create_software(metadata.merge(platform: "c64"))
|
||||
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
|
||||
|
||||
create_release_if_not_exists(
|
||||
software_id: software.id,
|
||||
version: version,
|
||||
cartridge_path: full_path("#{versioned}.prg")
|
||||
)
|
||||
create_release_if_not_exists(
|
||||
software_id: software.id,
|
||||
version: version,
|
||||
cartridge_path: full_path("#{versioned}.prg")
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,14 +7,16 @@ module SoftwareUpdater
|
||||
metadata = parse_json_metadata(full_path("#{versioned}.metadata.json"))
|
||||
site_url = metadata.delete(:site)
|
||||
|
||||
software = update_or_create_software(metadata.merge(platform: "ebitengine"))
|
||||
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
|
||||
ActiveRecord::Base.transaction do
|
||||
software = update_or_create_software(metadata.merge(platform: "ebitengine"))
|
||||
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
|
||||
|
||||
create_release_if_not_exists(
|
||||
software_id: software.id,
|
||||
version: version,
|
||||
html_folder_path: full_path(versioned)
|
||||
)
|
||||
create_release_if_not_exists(
|
||||
software_id: software.id,
|
||||
version: version,
|
||||
html_folder_path: full_path(versioned)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,14 +7,16 @@ module SoftwareUpdater
|
||||
metadata = parse_json_metadata(full_path("#{versioned}.metadata.json"))
|
||||
site_url = metadata.delete(:site)
|
||||
|
||||
software = update_or_create_software(metadata.merge(platform: "love"))
|
||||
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
|
||||
ActiveRecord::Base.transaction do
|
||||
software = update_or_create_software(metadata.merge(platform: "love"))
|
||||
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
|
||||
|
||||
create_release_if_not_exists(
|
||||
software_id: software.id,
|
||||
version: version,
|
||||
html_folder_path: full_path(versioned)
|
||||
)
|
||||
create_release_if_not_exists(
|
||||
software_id: software.id,
|
||||
version: version,
|
||||
html_folder_path: full_path(versioned)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,17 +10,19 @@ module SoftwareUpdater
|
||||
metadata = parse_lua_metadata(full_path("#{versioned}.lua"))
|
||||
site_url = metadata.delete(:site)
|
||||
|
||||
software = update_or_create_software(metadata.merge(platform: "tic80"))
|
||||
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
|
||||
ActiveRecord::Base.transaction do
|
||||
software = update_or_create_software(metadata.merge(platform: "tic80"))
|
||||
upsert_external_link(software.id, "Source Code", site_url) if site_url.present?
|
||||
|
||||
create_release_if_not_exists(
|
||||
software_id: software.id,
|
||||
version: version,
|
||||
cartridge_path: full_path("#{versioned}.tic"),
|
||||
source_path: full_path("#{versioned}.lua"),
|
||||
html_folder_path: full_path(versioned),
|
||||
docs_folder_path: full_path(docs_dir)
|
||||
)
|
||||
create_release_if_not_exists(
|
||||
software_id: software.id,
|
||||
version: version,
|
||||
cartridge_path: full_path("#{versioned}.tic"),
|
||||
source_path: full_path("#{versioned}.lua"),
|
||||
html_folder_path: full_path(versioned),
|
||||
docs_folder_path: full_path(docs_dir)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class DropArticlesTable < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
drop_table :articles
|
||||
end
|
||||
|
||||
def down
|
||||
create_table :articles, id: :integer, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
|
||||
t.text :content
|
||||
t.timestamp :created_at, default: -> { "CURRENT_TIMESTAMP" }
|
||||
t.string :slug
|
||||
t.string :title
|
||||
t.timestamp :updated_at, default: -> { "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Api::DownloadsController, type: :request do
|
||||
describe "GET /api/download" do
|
||||
it "returns bad_request without path" do
|
||||
get "/api/download"
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["error"]).to eq("Path is required")
|
||||
end
|
||||
|
||||
it "returns not_found for invalid path" do
|
||||
get "/api/download", params: { path: "nonexistent/file.tic" }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["error"]).to eq("Not found")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Api::EventsController, type: :request do
|
||||
describe "GET /api/events" do
|
||||
it "returns upcoming events" do
|
||||
create(:event, name: "Future Jam", date: 1.week.from_now)
|
||||
create(:event, name: "Past Jam", date: 1.week.ago)
|
||||
|
||||
get "/api/events"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
names = json.map { |e| e["name"] }
|
||||
expect(names).to include("Future Jam")
|
||||
expect(names).not_to include("Past Jam")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Api::SoftwareController, type: :request do
|
||||
describe "GET /api/software" do
|
||||
it "returns all software with releases" do
|
||||
create(:software, name: "test-game", title: "Test Game")
|
||||
|
||||
get "/api/software"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["softwares"]).to be_an(Array)
|
||||
expect(json["softwares"].length).to eq(1)
|
||||
end
|
||||
|
||||
it "excludes soft-deleted software" do
|
||||
create(:software, deleted_at: Time.current)
|
||||
|
||||
get "/api/software"
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
expect(json["softwares"]).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
FactoryBot.define do
|
||||
factory :download do
|
||||
file_path { "/test/file.tic" }
|
||||
ip_address { "127.0.0.1" }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
FactoryBot.define do
|
||||
factory :event do
|
||||
name { "Test Event" }
|
||||
date { 1.week.from_now }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
FactoryBot.define do
|
||||
factory :member do
|
||||
sequence(:nick) { |n| "member#{n}" }
|
||||
real_nick { "Real Name" }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
FactoryBot.define do
|
||||
factory :release do
|
||||
software
|
||||
sequence(:version) { |n| "1.0.#{n}" }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
FactoryBot.define do
|
||||
factory :software do
|
||||
sequence(:name) { |n| "game-#{n}" }
|
||||
title { "Test Game" }
|
||||
author { "dev" }
|
||||
platform { "tic80" }
|
||||
status { "development" }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Download, type: :model do
|
||||
it { should validate_presence_of(:file_path) }
|
||||
it { should belong_to(:release).optional }
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Event, type: :model do
|
||||
it { should validate_presence_of(:name) }
|
||||
it { should validate_presence_of(:date) }
|
||||
|
||||
describe ".upcoming" do
|
||||
it "returns only future events" do
|
||||
future = create(:event, date: 1.week.from_now)
|
||||
create(:event, date: 1.week.ago)
|
||||
|
||||
expect(Event.upcoming).to eq([future])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Member, type: :model do
|
||||
it { should validate_presence_of(:nick) }
|
||||
it { should validate_uniqueness_of(:nick) }
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Release, type: :model do
|
||||
it { should belong_to(:software) }
|
||||
it { should have_many(:downloads) }
|
||||
|
||||
describe "version uniqueness" do
|
||||
let(:software) { create(:software) }
|
||||
|
||||
it "prevents duplicate versions for same software" do
|
||||
create(:release, software: software, version: "1.0.0")
|
||||
dup = build(:release, software: software, version: "1.0.0")
|
||||
expect(dup).not_to be_valid
|
||||
end
|
||||
|
||||
it "allows same version across different software" do
|
||||
other_sw = create(:software)
|
||||
create(:release, software: software, version: "1.0.0")
|
||||
other = build(:release, software: other_sw, version: "1.0.0")
|
||||
expect(other).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Software, type: :model do
|
||||
it { should validate_presence_of(:name) }
|
||||
it { should validate_presence_of(:title) }
|
||||
it { should validate_presence_of(:platform) }
|
||||
it { should validate_uniqueness_of(:name) }
|
||||
|
||||
it { should have_many(:releases).dependent(:destroy) }
|
||||
it { should have_many(:external_links).dependent(:destroy) }
|
||||
it { should have_many(:software_images).dependent(:destroy) }
|
||||
|
||||
describe "default scope" do
|
||||
it "excludes soft-deleted records" do
|
||||
active = create(:software)
|
||||
create(:software, deleted_at: Time.current)
|
||||
|
||||
expect(Software.all).to eq([active])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
require "spec_helper"
|
||||
ENV["RAILS_ENV"] ||= "test"
|
||||
require_relative "../config/environment"
|
||||
|
||||
abort("The Rails environment is running in production mode!") if Rails.env.production?
|
||||
require "rspec/rails"
|
||||
|
||||
begin
|
||||
ActiveRecord::Migration.maintain_test_schema!
|
||||
rescue ActiveRecord::PendingMigrationError => e
|
||||
abort e.to_s.strip
|
||||
end
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.fixture_paths = [ Rails.root.join("spec/fixtures") ]
|
||||
config.use_transactional_fixtures = true
|
||||
config.infer_spec_type_from_file_location!
|
||||
config.filter_rails_from_backtrace!
|
||||
|
||||
config.include FactoryBot::Syntax::Methods
|
||||
end
|
||||
|
||||
Shoulda::Matchers.configure do |config|
|
||||
config.integrate do |with|
|
||||
with.test_framework :rspec
|
||||
with.library :rails
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe SoftwareHighlightedService do
|
||||
describe "#index" do
|
||||
it "returns nil when no highlighted software" do
|
||||
create(:software, highlighted: false)
|
||||
|
||||
result = described_class.new.index
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it "returns highlighted software" do
|
||||
sw = create(:software, highlighted: true, title: "Featured")
|
||||
|
||||
result = described_class.new.index
|
||||
expect(result).not_to be_nil
|
||||
expect(result[:software][:title]).to eq("Featured")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe UpdateService do
|
||||
describe "#update" do
|
||||
it "raises ArgumentError for unsupported platform" do
|
||||
input = UpdateInputDto.new(platform: "unknown", name: "game", version: "1.0")
|
||||
|
||||
expect { described_class.new.update(input) }.to raise_error(ArgumentError, /Unsupported platform/)
|
||||
end
|
||||
|
||||
it "routes to correct platform service" do
|
||||
input = UpdateInputDto.new(platform: "tic80", name: "game", version: "1.0")
|
||||
mock_service = instance_double(SoftwareUpdater::Tic80Service)
|
||||
|
||||
allow(SoftwareUpdater::Tic80Service).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:update)
|
||||
|
||||
described_class.new.update(input)
|
||||
|
||||
expect(mock_service).to have_received(:update).with("game", "1.0")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
RSpec.configure do |config|
|
||||
config.expect_with :rspec do |expectations|
|
||||
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
||||
end
|
||||
|
||||
config.mock_with :rspec do |mocks|
|
||||
mocks.verify_partial_doubles = true
|
||||
end
|
||||
|
||||
config.shared_context_metadata_behavior = :apply_to_host_groups
|
||||
config.filter_run_when_matching :focus
|
||||
config.order = :random
|
||||
Kernel.srand config.seed
|
||||
end
|
||||
Generated
+2383
-26
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,12 @@
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix"
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.12",
|
||||
"pinia": "^2.2.0",
|
||||
"tuicss": "^2.1.2",
|
||||
"vue": "^3.5.0",
|
||||
@@ -17,16 +20,21 @@
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"happy-dom": "^20.11.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0",
|
||||
"vue-tsc": "^2.1.0",
|
||||
"eslint": "^9.0.0",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"eslint-plugin-vue": "^9.28.0"
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^2.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,47 +5,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import AppLayout from './layout/AppLayout.vue'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { useMatomo } from './composables/useMatomo'
|
||||
|
||||
declare global {
|
||||
interface Window { _paq?: Array<unknown[]> }
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
const _paq = (window._paq = window._paq || [])
|
||||
_paq.push(['enableLinkTracking'])
|
||||
const u = '//matomo.vps.teletype.hu/'
|
||||
_paq.push(['setTrackerUrl', u + 'matomo.php'])
|
||||
_paq.push(['setSiteId', '1'])
|
||||
_paq.push(['setCustomUrl', window.location.href])
|
||||
_paq.push(['setDocumentTitle', document.title])
|
||||
_paq.push(['trackPageView'])
|
||||
const d = document
|
||||
const g = d.createElement('script')
|
||||
const s = d.getElementsByTagName('script')[0]
|
||||
g.async = true
|
||||
g.src = u + 'matomo.js'
|
||||
s.parentNode!.insertBefore(g, s)
|
||||
})
|
||||
|
||||
router.beforeEach((_to, from) => {
|
||||
const _paq = window._paq
|
||||
if (_paq && from.fullPath) {
|
||||
_paq.push(['setReferrerUrl', window.location.origin + from.fullPath])
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const _paq = window._paq
|
||||
if (!_paq) return
|
||||
_paq.push(['setCustomUrl', window.location.origin + to.fullPath])
|
||||
setTimeout(() => {
|
||||
_paq.push(['setDocumentTitle', document.title])
|
||||
_paq.push(['trackPageView'])
|
||||
}, 0)
|
||||
})
|
||||
useMatomo(useRouter())
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { CONFIG } from '../lib/config'
|
||||
import type { GiteaRepo, Commit } from '../lib/interfaces/git.interface'
|
||||
|
||||
const BASE = import.meta.env.VITE_GIT_BASE || 'https://git.teletypegames.org/api/v1'
|
||||
const TOKEN = import.meta.env.WEBAPP_GITEA_TOKEN
|
||||
|
||||
function buildHeaders() {
|
||||
return { 'Authorization': `token ${TOKEN}`, 'Accept': 'application/json' }
|
||||
return { 'Authorization': `token ${CONFIG.gitToken}`, 'Accept': 'application/json' }
|
||||
}
|
||||
|
||||
const repos = async (): Promise<GiteaRepo[]> => {
|
||||
if (!TOKEN) throw new Error('Gitea API token (WEBAPP_GITEA_TOKEN) is not configured.')
|
||||
const res = await fetch(`${BASE}/repos/search?q=&private=false&limit=50`, { headers: buildHeaders() })
|
||||
if (!CONFIG.gitToken) throw new Error('Gitea API token (WEBAPP_GITEA_TOKEN) is not configured.')
|
||||
const res = await fetch(`${CONFIG.gitBase}/repos/search?q=&private=false&limit=50`, { headers: buildHeaders() })
|
||||
if (!res.ok) throw new Error(`Gitea API responded with status ${res.status}`)
|
||||
const data = await res.json()
|
||||
return data.data || []
|
||||
@@ -17,7 +15,7 @@ const repos = async (): Promise<GiteaRepo[]> => {
|
||||
|
||||
const commits = async (owner: string, name: string, htmlUrl: string): Promise<Commit[]> => {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/repos/${owner}/${name}/commits?limit=10&page=1`, { headers: buildHeaders() })
|
||||
const res = await fetch(`${CONFIG.gitBase}/repos/${owner}/${name}/commits?limit=10&page=1`, { headers: buildHeaders() })
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
if (!Array.isArray(data)) return []
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { WikiPage, WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
|
||||
|
||||
import { CONFIG } from '../lib/config'
|
||||
|
||||
// Public content base — used only for building external links to wiki pages.
|
||||
// Points at Grav (which is taking over from wiki.teletypegames.org).
|
||||
const WIKI_BASE = import.meta.env.VITE_WIKI_BASE || 'https://wiki.teletypegames.org'
|
||||
const WIKI_BASE = CONFIG.wikiBase
|
||||
|
||||
interface RawWikiPage {
|
||||
id: number
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="animate-pulse">
|
||||
<div v-for="n in count" :key="n" class="bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden mb-6">
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div class="h-6 bg-gray-200 rounded w-3/4 mb-3"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ count?: number }>(), { count: 3 })
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { useLoadable } from '../useLoadable'
|
||||
|
||||
describe('useLoadable', () => {
|
||||
it('executes fn on first call', async () => {
|
||||
const { loading, error, withCache } = useLoadable()
|
||||
const fn = vi.fn()
|
||||
|
||||
await withCache(fn)
|
||||
|
||||
expect(fn).toHaveBeenCalledOnce()
|
||||
expect(loading.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
|
||||
it('skips fn on second call within TTL', async () => {
|
||||
const { withCache } = useLoadable()
|
||||
const fn = vi.fn()
|
||||
|
||||
await withCache(fn)
|
||||
await withCache(fn)
|
||||
|
||||
expect(fn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('re-executes after invalidate', async () => {
|
||||
const { withCache, invalidate } = useLoadable()
|
||||
const fn = vi.fn()
|
||||
|
||||
await withCache(fn)
|
||||
invalidate()
|
||||
await withCache(fn)
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('re-executes after TTL expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { withCache } = useLoadable(100) // 100ms TTL
|
||||
|
||||
const fn = vi.fn()
|
||||
await withCache(fn)
|
||||
|
||||
vi.advanceTimersByTime(150)
|
||||
await withCache(fn)
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sets error on fn failure', async () => {
|
||||
const { error, withCache } = useLoadable()
|
||||
|
||||
await withCache(async () => {
|
||||
throw new Error('test error')
|
||||
})
|
||||
|
||||
expect(error.value).toBe('test error')
|
||||
})
|
||||
|
||||
it('sets loading during execution', async () => {
|
||||
const { loading, withCache } = useLoadable()
|
||||
const states: boolean[] = []
|
||||
|
||||
await withCache(async () => {
|
||||
states.push(loading.value)
|
||||
})
|
||||
|
||||
expect(states).toEqual([true])
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const DEFAULT_TTL = 15 * 60 * 1000 // 15 perc
|
||||
|
||||
export function useLoadable(ttlMs?: number) {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let _loaded = false
|
||||
let _loadedAt = 0
|
||||
|
||||
async function withCache(fn: () => Promise<void>) {
|
||||
const effectiveTtl = ttlMs ?? DEFAULT_TTL
|
||||
if (_loaded && Date.now() - _loadedAt < effectiveTtl) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await fn()
|
||||
_loaded = true
|
||||
_loadedAt = Date.now()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
_loaded = false
|
||||
}
|
||||
|
||||
return { loading, error, withCache, invalidate }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { onMounted } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
import { CONFIG } from '../lib/config'
|
||||
|
||||
declare global {
|
||||
interface Window { _paq?: Array<unknown[]> }
|
||||
}
|
||||
|
||||
export function useMatomo(router: Router) {
|
||||
onMounted(() => {
|
||||
const _paq = (window._paq = window._paq || [])
|
||||
_paq.push(['enableLinkTracking'])
|
||||
const u = CONFIG.matomoUrl
|
||||
_paq.push(['setTrackerUrl', u + 'matomo.php'])
|
||||
_paq.push(['setSiteId', CONFIG.matomoSiteId])
|
||||
_paq.push(['setCustomUrl', window.location.href])
|
||||
_paq.push(['setDocumentTitle', document.title])
|
||||
_paq.push(['trackPageView'])
|
||||
const d = document
|
||||
const g = d.createElement('script')
|
||||
const s = d.getElementsByTagName('script')[0]
|
||||
g.async = true
|
||||
g.src = u + 'matomo.js'
|
||||
s.parentNode!.insertBefore(g, s)
|
||||
})
|
||||
|
||||
router.beforeEach((_to, from) => {
|
||||
const _paq = window._paq
|
||||
if (_paq && from.fullPath) {
|
||||
_paq.push(['setReferrerUrl', window.location.origin + from.fullPath])
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const _paq = window._paq
|
||||
if (!_paq) return
|
||||
_paq.push(['setCustomUrl', window.location.origin + to.fullPath])
|
||||
setTimeout(() => {
|
||||
_paq.push(['setDocumentTitle', document.title])
|
||||
_paq.push(['trackPageView'])
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { getCookie, setCookie } from '../cookie'
|
||||
|
||||
describe('cookie utils', () => {
|
||||
beforeEach(() => {
|
||||
document.cookie.split(';').forEach(c => {
|
||||
document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/')
|
||||
})
|
||||
})
|
||||
|
||||
it('getCookie returns null for missing cookie', () => {
|
||||
expect(getCookie('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
it('setCookie + getCookie roundtrip', () => {
|
||||
setCookie('test-key', 'test-value')
|
||||
expect(getCookie('test-key')).toBe('test-value')
|
||||
})
|
||||
|
||||
it('handles encoded values', () => {
|
||||
setCookie('encoded', 'hello world')
|
||||
expect(getCookie('encoded')).toBe('hello world')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sanitize } from '../sanitize'
|
||||
|
||||
describe('sanitize', () => {
|
||||
it('keeps safe HTML content', () => {
|
||||
const result = sanitize('<p>Hello <strong>world</strong></p>')
|
||||
expect(result).toContain('Hello')
|
||||
expect(result).toContain('<strong>world</strong>')
|
||||
})
|
||||
|
||||
it('strips script tags', () => {
|
||||
const result = sanitize('<p>Safe</p><script>alert("xss")</script>')
|
||||
expect(result).toContain('Safe')
|
||||
expect(result).not.toContain('alert')
|
||||
})
|
||||
|
||||
it('strips event handlers', () => {
|
||||
const result = sanitize('<img src="x" onerror="alert(1)">')
|
||||
expect(result).not.toContain('onerror')
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(sanitize('')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
export const CONFIG = {
|
||||
wikiBase: import.meta.env.VITE_WIKI_BASE || 'https://wiki.teletypegames.org',
|
||||
gitBase: import.meta.env.VITE_GIT_BASE || 'https://git.teletypegames.org/api/v1',
|
||||
gitToken: import.meta.env.WEBAPP_GITEA_TOKEN || '',
|
||||
discordInvite: import.meta.env.DISCORD_INVITE_LINK || 'https://discord.gg/TJKxGfG',
|
||||
matomoUrl: import.meta.env.VITE_MATOMO_URL || '//matomo.vps.teletype.hu/',
|
||||
matomoSiteId: import.meta.env.VITE_MATOMO_SITE_ID || '1',
|
||||
youtubeApiKey: import.meta.env.YOUTUBE_API_KEY || '',
|
||||
youtubeChannelId: import.meta.env.YOUTUBE_CHANNEL_ID || '',
|
||||
} as const
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getCookie(name: string): string | null {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'))
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
export function setCookie(name: string, value: string, days = 365) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString()
|
||||
document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires};path=/`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
export function sanitize(html: string): string {
|
||||
return DOMPurify.sanitize(html)
|
||||
}
|
||||
@@ -30,6 +30,8 @@
|
||||
<p class="empty-state-desc">{{ t('blog.noPostsDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<SkeletonCard v-else-if="loading" :count="3" />
|
||||
|
||||
<div v-else class="blog-posts-list">
|
||||
<article v-for="page in blogPages" :key="page.id" class="blog-post-card">
|
||||
<div class="blog-post-content">
|
||||
@@ -78,6 +80,7 @@ import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime } from '../../lib/dateFormat'
|
||||
import { useBlogStore } from '../../stores/blog.store'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<article v-else-if="pageContent" class="blog-post-card">
|
||||
<div class="blog-post-content">
|
||||
<div v-if="pageContent.render" class="wiki-content max-w-none text-gray-700 leading-relaxed" v-html="pageContent.render" />
|
||||
<div v-if="pageContent.render" class="wiki-content max-w-none text-gray-700 leading-relaxed" v-html="sanitize(pageContent.render)" />
|
||||
<div v-else class="blog-post-fallback">No content available for this post.</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -44,6 +44,7 @@ import { onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { formatDateTime } from '../../lib/dateFormat'
|
||||
import { sanitize } from '../../lib/sanitize'
|
||||
import { useBlogStore } from '../../stores/blog.store'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<span class="block sm:inline"> {{ error }}</span>
|
||||
</div>
|
||||
|
||||
<SkeletonCard v-if="!error && publicRepos.length === 0 && !recentCommits.length" :count="4" />
|
||||
|
||||
<div v-if="!error && publicRepos.length > 0" class="card-grid mb-16">
|
||||
<div v-for="repo in publicRepos" :key="repo.name" class="card-base">
|
||||
<h3 class="repo-card-title">
|
||||
@@ -62,6 +64,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime } from '../../lib/dateFormat'
|
||||
import { useGitStore } from '../../stores/git.store'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -130,10 +130,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CONFIG } from '../../lib/config'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const DISCORD_INVITE_LINK = import.meta.env.DISCORD_INVITE_LINK || 'https://discord.gg/your-discord-link'
|
||||
const DISCORD_INVITE_LINK = CONFIG.discordInvite
|
||||
|
||||
function openEmail() {
|
||||
window.location.href = 'mailto:contact@teletype.hu'
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useSoftwareStore } from '../software.store'
|
||||
|
||||
const mockIndex = vi.fn().mockResolvedValue([
|
||||
{ software: { name: 'test-game', title: 'Test Game', status: 'released', platform: 'tic80', author: 'dev' }, releases: [], totalDownloads: 0 },
|
||||
])
|
||||
|
||||
vi.mock('../../api/software.api', () => ({
|
||||
default: {
|
||||
index: (...args: unknown[]) => mockIndex(...args),
|
||||
highlighted: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useSoftwareStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockIndex.mockClear()
|
||||
})
|
||||
|
||||
it('fetches software items', async () => {
|
||||
const store = useSoftwareStore()
|
||||
await store.fetchAll()
|
||||
expect(store.items).toHaveLength(1)
|
||||
expect(store.items[0].software.name).toBe('test-game')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('caches after first fetch', async () => {
|
||||
const store = useSoftwareStore()
|
||||
await store.fetchAll()
|
||||
await store.fetchAll()
|
||||
expect(mockIndex).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('findByName returns correct entry', async () => {
|
||||
const store = useSoftwareStore()
|
||||
await store.fetchAll()
|
||||
expect(store.findByName('test-game')).toBeDefined()
|
||||
expect(store.findByName('nonexistent')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useUiStore } from '../ui.store'
|
||||
|
||||
describe('useUiStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
document.cookie.split(';').forEach(c => {
|
||||
document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/')
|
||||
})
|
||||
})
|
||||
|
||||
it('starts with retro mode off by default', () => {
|
||||
const store = useUiStore()
|
||||
expect(store.isRetroMode).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles retro mode', () => {
|
||||
const store = useUiStore()
|
||||
store.toggleRetroMode()
|
||||
expect(store.isRetroMode).toBe(true)
|
||||
store.toggleRetroMode()
|
||||
expect(store.isRetroMode).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles menu', () => {
|
||||
const store = useUiStore()
|
||||
expect(store.menuOpen).toBe(false)
|
||||
store.toggleMenu()
|
||||
expect(store.menuOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('closes menu', () => {
|
||||
const store = useUiStore()
|
||||
store.toggleMenu()
|
||||
store.closeMenu()
|
||||
expect(store.menuOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,30 +2,21 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import wikiApi from '../api/wiki.api'
|
||||
import { isNew } from '../lib/softwareUtils'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import type { WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
|
||||
|
||||
export const useBlogStore = defineStore('blog', () => {
|
||||
const pages = ref<WikiPageWithContent[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
const currentPage = ref<WikiPageContent | null>(null)
|
||||
const currentPageLoading = ref(false)
|
||||
const currentPageError = ref<string | null>(null)
|
||||
|
||||
let _loaded = false
|
||||
|
||||
async function fetchPages() {
|
||||
if (_loaded) return
|
||||
loading.value = true
|
||||
try {
|
||||
await withCache(async () => {
|
||||
pages.value = await wikiApi.listBlogPages()
|
||||
_loaded = true
|
||||
} catch (e) {
|
||||
error.value = `Failed to fetch wiki data: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchPage(slug: string) {
|
||||
@@ -56,5 +47,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 }
|
||||
return { pages, loading, error, currentPage, currentPageLoading, currentPageError, fetchPages, fetchPage, isNew, getPermalink, getCleanPreview, invalidate }
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import eventApi from '../api/event.api'
|
||||
import { formatDateTime } from '../lib/dateFormat'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import type { Event } from '../lib/interfaces/event.interface'
|
||||
|
||||
type EventWithText = Event & { dateISO: string; dateText: string }
|
||||
@@ -11,11 +12,9 @@ export const useEventStore = defineStore('event', () => {
|
||||
const nextEvent = ref<EventWithText | null>(null)
|
||||
const followingEvents = ref<FollowingEvent[]>([])
|
||||
const countdownText = ref('-- : -- : -- : --')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null
|
||||
let _loaded = false
|
||||
|
||||
function startCountdown(targetISO: string) {
|
||||
if (countdownInterval) clearInterval(countdownInterval)
|
||||
@@ -42,10 +41,7 @@ export const useEventStore = defineStore('event', () => {
|
||||
}
|
||||
|
||||
async function fetch() {
|
||||
if (_loaded) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await withCache(async () => {
|
||||
const events = await eventApi.index()
|
||||
if (events.length > 0) {
|
||||
const first = events[0]
|
||||
@@ -54,12 +50,7 @@ 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())
|
||||
}
|
||||
_loaded = true
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
@@ -69,5 +60,5 @@ export const useEventStore = defineStore('event', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { nextEvent, followingEvents, countdownText, loading, error, fetch, cleanup }
|
||||
return { nextEvent, followingEvents, countdownText, loading, error, fetch, cleanup, invalidate }
|
||||
})
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import gitApi from '../api/git.api'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import type { GiteaRepo, Commit } from '../lib/interfaces/git.interface'
|
||||
|
||||
export const useGitStore = defineStore('git', () => {
|
||||
const repos = ref<GiteaRepo[]>([])
|
||||
const commits = ref<Commit[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let _loaded = false
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
async function fetchAll() {
|
||||
if (_loaded) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await withCache(async () => {
|
||||
repos.value = await gitApi.repos()
|
||||
|
||||
const commitPromises = repos.value.map(repo =>
|
||||
@@ -24,13 +20,8 @@ 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) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { repos, commits, loading, error, fetchAll }
|
||||
return { repos, commits, loading, error, fetchAll, invalidate }
|
||||
})
|
||||
|
||||
@@ -2,26 +2,18 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import wikiApi from '../api/wiki.api'
|
||||
import { isNew } from '../lib/softwareUtils'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import type { WikiPage } from '../lib/interfaces/wiki.interface'
|
||||
|
||||
export const useHowtosStore = defineStore('howtos', () => {
|
||||
const pages = ref<WikiPage[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let _loaded = false
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
async function fetchPages() {
|
||||
if (_loaded) return
|
||||
loading.value = true
|
||||
try {
|
||||
await withCache(async () => {
|
||||
pages.value = await wikiApi.listHowtoPages()
|
||||
_loaded = true
|
||||
} catch (e) {
|
||||
error.value = `Failed to fetch wiki data: ${e instanceof Error ? e.message : String(e)}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { pages, loading, error, fetchPages, isNew }
|
||||
return { pages, loading, error, fetchPages, isNew, invalidate }
|
||||
})
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import memberApi from '../api/member.api'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import type { Member } from '../lib/interfaces/member.interface'
|
||||
|
||||
export const useMemberStore = defineStore('member', () => {
|
||||
const members = ref<Member[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let _loaded = false
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
async function fetch() {
|
||||
if (_loaded) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await withCache(async () => {
|
||||
members.value = await memberApi.index()
|
||||
_loaded = true
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resolvedAvatarUrl(member: Member): string {
|
||||
@@ -28,5 +19,5 @@ export const useMemberStore = defineStore('member', () => {
|
||||
return new URL(`../assets/team/${member.avatar_filename}`, import.meta.url).href
|
||||
}
|
||||
|
||||
return { members, loading, error, fetch, resolvedAvatarUrl }
|
||||
return { members, loading, error, fetch, resolvedAvatarUrl, invalidate }
|
||||
})
|
||||
|
||||
@@ -2,14 +2,13 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import softwareApi from '../api/software.api'
|
||||
import { getLatestStable } from '../lib/softwareUtils'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import type { SoftwareEntry, Release } from '../lib/interfaces/software.interface'
|
||||
|
||||
export const useSoftwareStore = defineStore('software', () => {
|
||||
const items = ref<SoftwareEntry[]>([])
|
||||
const highlighted = ref<SoftwareEntry | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
let _loaded = false
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
const highlightedStableRelease = computed((): Release | null => {
|
||||
if (!highlighted.value?.releases) return null
|
||||
@@ -17,17 +16,9 @@ export const useSoftwareStore = defineStore('software', () => {
|
||||
})
|
||||
|
||||
async function fetchAll() {
|
||||
if (_loaded) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await withCache(async () => {
|
||||
items.value = await softwareApi.index()
|
||||
_loaded = true
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchHighlighted() {
|
||||
@@ -42,5 +33,5 @@ export const useSoftwareStore = defineStore('software', () => {
|
||||
return items.value.find(s => s.software.name === name)
|
||||
}
|
||||
|
||||
return { items, highlighted, loading, error, highlightedStableRelease, fetchAll, fetchHighlighted, findByName }
|
||||
return { items, highlighted, loading, error, highlightedStableRelease, fetchAll, fetchHighlighted, findByName, invalidate }
|
||||
})
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'))
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
function setCookie(name: string, value: string, days = 365) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString()
|
||||
document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires};path=/`
|
||||
}
|
||||
import { getCookie, setCookie } from '../lib/cookie'
|
||||
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const isRetroMode = ref(getCookie('retro-mode') === '1')
|
||||
|
||||
@@ -3,7 +3,27 @@ module.exports = {
|
||||
"./src/**/*.{astro,html,js,ts,jsx,tsx,vue,svelte}"
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
retro: {
|
||||
green: '#33ff33',
|
||||
dark: '#0a0a0a',
|
||||
amber: '#ffb000',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user