Particles
Some checks failed
Build and Deploy Nuxt / build (push) Failing after 1m23s

This commit is contained in:
2026-06-10 20:18:47 +02:00
parent dd68472f38
commit d4976fab69
13 changed files with 5266 additions and 18 deletions

1
frontend/.nuxtrc Normal file
View File

@@ -0,0 +1 @@
setups.@nuxt/test-utils="3.23.0"

View File

@@ -2,6 +2,7 @@
<template> <template>
<div class="app-root" :class="{ 'zh': locale === 'zh' }" :data-lang="locale"> <div class="app-root" :class="{ 'zh': locale === 'zh' }" :data-lang="locale">
<AnimatedBackground />
<NuxtRouteAnnouncer /> <NuxtRouteAnnouncer />
<div class="container"> <div class="container">
<NuxtPage /> <NuxtPage />
@@ -10,6 +11,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AnimatedBackground from './components/parts/AnimatedBackground.vue';
const { locale } = useI18n() const { locale } = useI18n()
// Set global html lang attribute based on current locale // Set global html lang attribute based on current locale

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvas = ref<HTMLCanvasElement | null>(null)
let ctx: CanvasRenderingContext2D | null = null
let animId: number
let particles: Particle[] = []
let accentColor: string = ''
interface Particle {
x: number
y: number
vx: number
vy: number
size: number
}
function parseAccent(hex: string): [number, number, number] {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return [r, g, b]
}
function createParticles(w: number, h: number): Particle[] {
const count = Math.floor((w * h) / 40000)
const capped = Math.min(count, 80)
const particlesArr: Particle[] = []
for (let i = 0; i < capped; i++) {
particlesArr.push({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
size: Math.random() * 2 + 1,
})
}
return particlesArr
}
function drawConnections(w: number, h: number) {
if (!ctx) return
const [r, g, b] = parseAccent(accentColor)
for (let i = 0; i < particles.length; i++) {
const a = particles[i]
for (let j = i + 1; j < particles.length; j++) {
const b2 = particles[j]
const dx = a.x - b2.x
const dy = a.y - b2.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 100) {
const connAlpha = (1 - dist / 100) * 0.25
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(b2.x, b2.y)
ctx.strokeStyle = `rgba(${r},${g},${b},${connAlpha})`
ctx.lineWidth = 1
ctx.stroke()
}
}
}
}
function drawTuiParticle(x: number, y: number, size: number, r: number, g: number, b: number) {
if (!ctx) return
const half = Math.ceil(size)
// Draw plus/cross shape (TUI cursor style)
ctx.fillStyle = `rgba(${r},${g},${b},0.85)`
ctx.fillRect(Math.round(x - half), y - 1, size, 1)
ctx.fillRect(x - 1, Math.round(y - half), 1, size)
// Center dot
ctx.fillRect(Math.round(x) - 1, Math.round(y) - 1, 2, 2)
}
function animate(): void {
if (!ctx || !canvas.value) return
const w = canvas.value.width
const h = canvas.value.height
const [r, g, b] = parseAccent(accentColor)
ctx.clearRect(0, 0, w, h)
for (const p of particles) {
p.x += p.vx
p.y += p.vy
if (p.x < -20) p.x = w + 20
if (p.x > w + 20) p.x = -20
if (p.y < -20) p.y = h + 20
if (p.y > h + 20) p.y = -20
drawTuiParticle(p.x, p.y, p.size, r, g, b)
}
drawConnections(w, h)
animId = requestAnimationFrame(animate)
}
function initCanvas() {
const c = canvas.value
if (!c) return
accentColor = getComputedStyle(document.documentElement)
.getPropertyValue('--color-link')
.trim()
if (!accentColor || !accentColor.startsWith('#')) return
const w = window.innerWidth
const h = window.innerHeight
const dpr = window.devicePixelRatio || 1
c.width = w * dpr
c.height = h * dpr
c.style.width = `${w}px`
c.style.height = `${h}px`
if (ctx) ctx.scale(dpr, dpr)
particles = createParticles(w, h)
}
onMounted(() => {
if (!canvas.value) return
initCanvas()
ctx = canvas.value.getContext('2d')
const observer = new MutationObserver(() => {
accentColor = getComputedStyle(document.documentElement)
.getPropertyValue('--color-link')
.trim()
if (canvas.value) {
const w = window.innerWidth
const h = window.innerHeight
canvas.value.style.width = `${w}px`
canvas.value.style.height = `${h}px`
}
})
observer.observe(document.documentElement, { attributes: true })
window.addEventListener('resize', () => {
if (!canvas.value || !ctx) return
const w = window.innerWidth
const h = window.innerHeight
const dpr = window.devicePixelRatio || 1
canvas.value.width = w * dpr
canvas.value.height = h * dpr
canvas.value.style.width = `${w}px`
canvas.value.style.height = `${h}px`
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
particles = createParticles(w, h)
})
if (ctx && canvas.value) {
animate()
}
})
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
})
</script>
<template>
<canvas ref="canvas" class="animated-bg" aria-hidden="true" />
</template>
<style lang="scss">
.animated-bg {
position: fixed !important;
top: 0 !important;
right: 0 !important;
bottom: 0 !important;
left: 0 !important;
z-index: -1 !important;
pointer-events: none !important;
width: 100vw !important;
height: 100vh !important;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,9 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"test": "vitest run --reporter=verbose",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@nuxt/content": "^3.12.0", "@nuxt/content": "^3.12.0",
@@ -18,5 +20,13 @@
"sass": "^1.98.0", "sass": "^1.98.0",
"vue": "^3.5.30", "vue": "^3.5.30",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxt/test-utils": "^3.15.4",
"@testing-library/vue": "^8.1.0",
"@vue/test-utils": "^2.4.11",
"happy-dom": "^20.10.2",
"jsdom": "^29.1.1",
"vitest": "^3.2.4"
} }
} }

213
frontend/test/e2e.test.ts Normal file
View File

@@ -0,0 +1,213 @@
import { describe, it, expect, vi, beforeAll } from 'vitest'
// --- Mock queryCollection globally ---
beforeAll(() => {
const mockFixedContent = [
{ path: '/fixed/en/root', locale: 'en', collection: 'fixed' },
{ path: '/fixed/es/root', locale: 'es', collection: 'fixed' },
{ path: '/fixed/ca/root', locale: 'ca', collection: 'fixed' },
{ path: '/fixed/en/contact', locale: 'en', collection: 'fixed' },
]
const mockBlogItems = [
{ path: '/blog/en/test', slug: 'test', title: 'First Post', date: '2026-03-18', description: 'Testing testing' },
{ path: '/blog/en/upgrade', slug: 'upgrade', title: 'Upgrade Post', date: '2026-04-01', description: 'System upgraded' },
{ path: '/blog/en/translator', slug: 'translator', title: 'Translator Post', date: '2026-05-15', description: 'Auto translation' },
]
const mockArtItems = [
{ path: '/art/en/nozt', slug: 'nozt', title: 'Nozt Art', date: '2026-04-14', thumb: '/art/nozt.jpg' },
{ path: '/art/en/silang-3d', slug: 'silang-3d', title: 'Silang 3D', date: '2026-05-20', thumb: '/art/silang.jpg' },
]
const mockProjectItems = [
{ path: '/projects/en/dragonroll', slug: 'dragonroll', title: 'Dragonroll', description: 'RPG helper', date: '2026-01-01', tech: ['Nuxt', 'Vue'] },
]
global.queryCollection = function queryCollection(collectionName: string) {
const items = {
fixed: mockFixedContent,
blog: mockBlogItems,
art: mockArtItems,
projects: mockProjectItems,
}[collectionName] || []
return {
path: (pathStr: string) => ({
first: () => Promise.resolve(items.find((i: any) => i.path === pathStr) || null),
all: () => Promise.resolve([items.find((i: any) => i.path === pathStr) || null].filter(Boolean)),
}),
where: (field: string, op: string, value: any) => {
let filtered = [...items]
if (op === 'LIKE') {
const pattern = value.replace(/\*/g, '').replace(/%/g, '')
filtered = items.filter((i: any) => i[field] && typeof i[field] === 'string' && i[field].startsWith(pattern))
}
return {
order: () => ({
all: () => Promise.resolve(filtered.sort((a: any, b: any) => {
if (a.date && b.date) return new Date(b.date).getTime() - new Date(a.date).getTime()
return 0
})),
}),
all: () => Promise.resolve(filtered),
}
},
}
}
global.useState = vi.fn(() => ({ value: [] as any[] }))
})
// --- Page components ---
import { renderSuspended } from '@nuxt/test-utils/runtime'
import IndexPage from '../app/pages/index.vue'
import BlogListPage from '../app/pages/blog/index.vue'
import ArtListPage from '../app/pages/art/index.vue'
import ContactPage from '../app/pages/contact/index.vue'
function getHtml(wrapper: any) {
return typeof wrapper.html === 'function' ? wrapper.html() : ''
}
// ======================
// Home page tests
// ======================
describe('Home page (index)', () => {
it('renders table header with site navigation', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('ARANROIG')
})
it('renders hidden H1 title for accessibility', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('Aran Roig')
})
it('renders fixed layout wrapper', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('fixed-layout')
})
it('renders intro section', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('intro-section')
})
it('renders projects section with project cards', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
// Verify Container component renders properly
expect(html).toContain('Container')
})
it('renders blog section', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('Blog Entries')
})
it('renders art gallery section', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('Art Gallery')
})
it('renders contact section', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('Contact')
})
it('renders footer component', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('tui-statusbar')
})
it('has scroll target anchors', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('scroll-blog')
expect(html).toContain('scroll-art')
expect(html).toContain('scroll-contact')
})
it('renders footer copyright text', async () => {
const wrapper = await renderSuspended(IndexPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('2026 Aran Roig')
})
})
// ======================
// Blog listing tests
// ======================
describe('Blog listing page', () => {
it('renders blog heading', async () => {
const wrapper = await renderSuspended(BlogListPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('Blog Entries')
})
it('renders tui-list structure', async () => {
const wrapper = await renderSuspended(BlogListPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('tui-list')
})
it('renders layout', async () => {
const wrapper = await renderSuspended(BlogListPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('fixed-layout')
})
})
// ======================
// Art listing tests
// ======================
describe('Art listing page', () => {
it('renders art gallery heading', async () => {
const wrapper = await renderSuspended(ArtListPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('Art Gallery')
})
it('renders grid layout', async () => {
const wrapper = await renderSuspended(ArtListPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('grid')
})
it('renders layout', async () => {
const wrapper = await renderSuspended(ArtListPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('fixed-layout')
})
})
// ======================
// Contact page tests
// ======================
describe('Contact page', () => {
it('renders contact heading', async () => {
const wrapper = await renderSuspended(ContactPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('Contact')
})
it('renders layout', async () => {
const wrapper = await renderSuspended(ContactPage, { shallow: false })
const html = getHtml(wrapper)
expect(html).toContain('fixed-layout')
expect(html).toContain('tui-statusbar')
})
})

1
frontend/test/setup.ts Normal file
View File

@@ -0,0 +1 @@
// Test setup - global mocks for queryCollection defined per-test-file

View File

@@ -0,0 +1,8 @@
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
setupFiles: ['./test/setup.ts'],
},
})

2581
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
{ {
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1" "@nuxt/test-utils": "^3.15.4",
"concurrently": "^9.2.1",
"vitest": "^3.2.4"
}, },
"scripts": { "scripts": {
"dev": "concurrently -k -n BACKEND,FRONTEND -c blue,green \"npm --prefix backend run dev\" \"npm --prefix frontend run dev\"" "dev": "concurrently -k -n BACKEND,FRONTEND -c blue,green \"npm --prefix backend run dev\" \"npm --prefix frontend run dev\""