more tweaks

This commit is contained in:
2026-07-29 08:10:59 +02:00
parent e25464af9c
commit b8e3c64868
56 changed files with 3123 additions and 212 deletions
+3
View File
@@ -0,0 +1,3 @@
--require spec_helper
--format documentation
--color
+3
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :download do
file_path { "/test/file.tic" }
ip_address { "127.0.0.1" }
end
end
+6
View File
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :event do
name { "Test Event" }
date { 1.week.from_now }
end
end
+6
View File
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :member do
sequence(:nick) { |n| "member#{n}" }
real_nick { "Real Name" }
end
end
+6
View File
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :release do
software
sequence(:version) { |n| "1.0.#{n}" }
end
end
+9
View File
@@ -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
+6
View File
@@ -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
+15
View File
@@ -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
+6
View File
@@ -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
+23
View File
@@ -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
+21
View File
@@ -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
+28
View File
@@ -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
+14
View File
@@ -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
+2383 -26
View File
File diff suppressed because it is too large Load Diff
+14 -6
View File
@@ -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"
}
}
+2 -40
View File
@@ -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>
+5 -7
View File
@@ -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 []
+3 -2
View File
@@ -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('')
})
})
+10
View File
@@ -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
+9
View File
@@ -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=/`
}
+5
View File
@@ -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()
+2 -1
View File
@@ -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)
})
})
+5 -14
View File
@@ -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 }
})
+5 -14
View File
@@ -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 }
})
+5 -14
View File
@@ -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 }
})
+5 -13
View File
@@ -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 }
})
+5 -14
View File
@@ -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 }
})
+5 -14
View File
@@ -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 -10
View File
@@ -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')
+21 -1
View File
@@ -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: [],
}
+10
View File
@@ -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,
},
})