From 804ad617ad9ec07289ac29dccf263885ad3253d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Ty=C5=84ski?= Date: Sun, 4 Oct 2026 04:04:35 +0200 Subject: [PATCH] feat(client): add app skeleton with routing, state and i18n Add vue-router routes with accessible navigation (document title + focus management), Pinia session store, vue-i18n (PL/EN) and the default layout. Replace the landing page with routed views and remove the old MainView. --- client/README.md | 27 +++++- client/src/App.vue | 97 +------------------ client/src/app/i18n.ts | 22 +++++ client/src/app/router.spec.ts | 33 +++++++ client/src/app/router.ts | 95 +++++++++++++++++++ client/src/app/stores/session.ts | 31 ++++++ client/src/layouts/DefaultLayout.vue | 137 +++++++++++++++++++++++++++ client/src/locales/en.ts | 94 ++++++++++++++++++ client/src/locales/messages.spec.ts | 18 ++++ client/src/locales/pl.ts | 94 ++++++++++++++++++ client/src/main.ts | 22 ++++- client/src/pages/HomeView.vue | 101 ++++++++++++++++++++ client/src/pages/MainView.vue | 129 ------------------------- client/src/pages/NotFoundView.vue | 18 ++++ client/src/pages/PlaceholderView.vue | 20 ++++ client/src/services/surreal.ts | 11 +-- client/src/types/session.ts | 13 +++ client/tsconfig.json | 5 +- 18 files changed, 728 insertions(+), 239 deletions(-) create mode 100644 client/src/app/i18n.ts create mode 100644 client/src/app/router.spec.ts create mode 100644 client/src/app/router.ts create mode 100644 client/src/app/stores/session.ts create mode 100644 client/src/layouts/DefaultLayout.vue create mode 100644 client/src/locales/en.ts create mode 100644 client/src/locales/messages.spec.ts create mode 100644 client/src/locales/pl.ts create mode 100644 client/src/pages/HomeView.vue delete mode 100644 client/src/pages/MainView.vue create mode 100644 client/src/pages/NotFoundView.vue create mode 100644 client/src/pages/PlaceholderView.vue create mode 100644 client/src/types/session.ts diff --git a/client/README.md b/client/README.md index 33895ab..9d7dd12 100644 --- a/client/README.md +++ b/client/README.md @@ -1,5 +1,26 @@ -# Vue 3 + TypeScript + Vite +# TarninaHub — frontend (client) -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` diff --git a/client/src/app/i18n.ts b/client/src/app/i18n.ts new file mode 100644 index 0000000..07cd10e --- /dev/null +++ b/client/src/app/i18n.ts @@ -0,0 +1,22 @@ +import { createI18n } from 'vue-i18n' +import { en } from '../locales/en' +import { pl } from '../locales/pl' + +export const SUPPORTED_LOCALES = ['pl', 'en'] as const +export type AppLocale = (typeof SUPPORTED_LOCALES)[number] + +export const i18n = createI18n({ + legacy: false, + locale: 'pl', + fallbackLocale: 'en', + messages: { pl, en }, +}) + +export function currentLocale(): AppLocale { + return i18n.global.locale.value as AppLocale +} + +export function setLocale(locale: AppLocale): void { + i18n.global.locale.value = locale + document.documentElement.lang = locale +} diff --git a/client/src/app/router.spec.ts b/client/src/app/router.spec.ts new file mode 100644 index 0000000..64833b2 --- /dev/null +++ b/client/src/app/router.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import router from './router' + +describe('router', () => { + it('registers the foundation routes', () => { + const names = router.getRoutes().map((route) => route.name) + + expect(names).toEqual( + expect.arrayContaining([ + 'home', + 'submissions', + 'matchmaking', + 'knowledge', + 'ideas', + 'tester', + 'communication', + 'partners', + 'panel', + 'not-found', + ]), + ) + }) + + it('exposes a title key for each route', () => { + for (const route of router.getRoutes()) { + if (route.name === 'not-found' || route.name === 'home') { + continue + } + + expect(route.meta.titleKey, `route ${String(route.name)}`).toBeTruthy() + } + }) +}) diff --git a/client/src/app/router.ts b/client/src/app/router.ts new file mode 100644 index 0000000..7541921 --- /dev/null +++ b/client/src/app/router.ts @@ -0,0 +1,95 @@ +import { nextTick } from 'vue' +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { i18n } from './i18n' + +const t = i18n.global.t + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'home', + component: () => import('../pages/HomeView.vue'), + meta: { titleKey: 'nav.home' }, + }, + { + path: '/zgloszenia', + name: 'submissions', + component: () => import('../pages/PlaceholderView.vue'), + meta: { titleKey: 'nav.submissions' }, + }, + { + path: '/dopasowanie', + name: 'matchmaking', + component: () => import('../pages/PlaceholderView.vue'), + meta: { titleKey: 'nav.matchmaking' }, + }, + { + path: '/wiedza', + name: 'knowledge', + component: () => import('../pages/PlaceholderView.vue'), + meta: { titleKey: 'nav.knowledge' }, + }, + { + path: '/pomysly', + name: 'ideas', + component: () => import('../pages/PlaceholderView.vue'), + meta: { titleKey: 'nav.ideas' }, + }, + { + path: '/tester', + name: 'tester', + component: () => import('../pages/PlaceholderView.vue'), + meta: { titleKey: 'nav.tester' }, + }, + { + path: '/komunikacja', + name: 'communication', + component: () => import('../pages/PlaceholderView.vue'), + meta: { titleKey: 'nav.communication' }, + }, + { + path: '/partnerstwa', + name: 'partners', + component: () => import('../pages/PlaceholderView.vue'), + meta: { titleKey: 'nav.partners' }, + }, + { + path: '/panel', + name: 'panel', + component: () => import('../pages/PlaceholderView.vue'), + meta: { titleKey: 'nav.panel' }, + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: () => import('../pages/NotFoundView.vue'), + meta: { titleKey: 'notFound.title' }, + }, +] + +export const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes, + scrollBehavior(_to, _from, savedPosition) { + return savedPosition ?? { top: 0 } + }, +}) + +/** + * SPA accessibility: announce the new document title and move focus to the page + * heading after each navigation (WCAG 2.4.2, 2.4.3). + */ +router.afterEach(async (to) => { + const titleKey = (to.meta.titleKey as string | undefined) ?? 'app.name' + document.title = `${t(titleKey)} — ${t('app.name')}` + + await nextTick() + + const heading = document.querySelector('#main h1') + if (heading) { + heading.setAttribute('tabindex', '-1') + heading.focus() + } +}) + +export default router diff --git a/client/src/app/stores/session.ts b/client/src/app/stores/session.ts new file mode 100644 index 0000000..efcf049 --- /dev/null +++ b/client/src/app/stores/session.ts @@ -0,0 +1,31 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { useStorage } from '@vueuse/core' +import type { SessionTokens, SessionUser } from '../../types/session' + +/** + * Authentication/session state. Only the bearer token is persisted; the profile + * is kept in memory and can be refreshed from the API. + */ +export const useSessionStore = defineStore('session', () => { + const accessToken = useStorage('tarninahub-token', null) + const user = ref(null) + + const isAuthenticated = computed(() => Boolean(accessToken.value)) + + function setSession(tokens: SessionTokens, profile: SessionUser | null = null): void { + accessToken.value = tokens.accessToken + user.value = profile + } + + function setUser(profile: SessionUser | null): void { + user.value = profile + } + + function clear(): void { + accessToken.value = null + user.value = null + } + + return { accessToken, user, isAuthenticated, setSession, setUser, clear } +}) diff --git a/client/src/layouts/DefaultLayout.vue b/client/src/layouts/DefaultLayout.vue new file mode 100644 index 0000000..e34c8bf --- /dev/null +++ b/client/src/layouts/DefaultLayout.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/client/src/locales/en.ts b/client/src/locales/en.ts new file mode 100644 index 0000000..7235a99 --- /dev/null +++ b/client/src/locales/en.ts @@ -0,0 +1,94 @@ +import type { Messages } from './pl' + +export const en: Messages = { + app: { + name: 'TarninaHub', + tagline: 'Małopolska Social Innovation Hub', + skipLink: 'Skip to main content', + footer: 'TarninaHub — Małopolska Social Innovation Hub · Hackathon project', + }, + nav: { + label: 'Main menu', + home: 'Home', + submissions: 'Report a need', + matchmaking: 'Match a solution', + knowledge: 'Knowledge base', + ideas: 'Idea creator', + tester: 'Innovation tester', + communication: 'Communication', + partners: 'Partnerships', + panel: 'Panel', + login: 'Sign in', + logout: 'Sign out', + openMenu: 'Open main menu', + }, + theme: { + label: 'Theme', + change: 'Change theme', + light: 'Light', + dark: 'Dark', + highContrast: 'High contrast', + system: 'System', + }, + a11y: { + prefsLabel: 'Accessibility settings', + fontScale: 'Text size', + fontIncrease: 'Increase text size', + fontDecrease: 'Decrease text size', + fontReset: 'Reset text size', + }, + common: { + loading: 'Loading…', + close: 'Close', + retry: 'Try again', + backHome: 'Back to home', + errorTitle: 'Something went wrong', + }, + home: { + heroTitle: 'Good ideas cannot go unnoticed', + heroLead: + 'TarninaHub connects the needs of Małopolska residents with knowledge, proven solutions and people ready to act. Together we make sure valuable social initiatives reach the places that need them most.', + ctaHow: 'See how it works', + ctaAudience: 'Who is TarninaHub for?', + missionTitle: 'Our mission', + missionText: + 'We want no valuable social initiative to be lost due to missing information, contact or support. Technology should shorten the path from idea to action and make cooperation between residents, institutions and social organisations easier.', + howTitle: 'How does it work?', + steps: { + submit: { + title: 'Report a need', + text: 'Describe a social problem with a short report — no formalities, in plain language.', + }, + solve: { + title: 'Find a solution', + text: 'See proven initiatives and knowledge matched to your location and topic.', + }, + act: { + title: 'Join forces', + text: 'Invite residents, institutions and organisations, then deliver the project together.', + }, + }, + audienceTitle: 'Who is it for?', + features: { + residents: { + title: 'Residents’ needs', + text: 'Residents and local communities report real problems: access to care, transport, senior support, youth activation.', + }, + solutions: { + title: 'Proven solutions', + text: 'A base of knowledge and ready ideas that worked in other municipalities, institutions and social organisations.', + }, + people: { + title: 'People ready to act', + text: 'We connect volunteers, experts, NGOs and local governments to deliver valuable initiatives together.', + }, + }, + }, + placeholder: { + text: 'This module is under construction. Please come back soon.', + }, + notFound: { + title: 'Page not found', + text: 'The address does not exist or has been moved.', + }, +} diff --git a/client/src/locales/messages.spec.ts b/client/src/locales/messages.spec.ts new file mode 100644 index 0000000..ef82209 --- /dev/null +++ b/client/src/locales/messages.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { en } from './en' +import { pl } from './pl' + +function flatKeys(source: Record, prefix = ''): string[] { + return Object.entries(source).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key + return value && typeof value === 'object' + ? flatKeys(value as Record, path) + : [path] + }) +} + +describe('i18n messages', () => { + it('defines the same keys in Polish and English', () => { + expect(flatKeys(en).sort()).toEqual(flatKeys(pl).sort()) + }) +}) diff --git a/client/src/locales/pl.ts b/client/src/locales/pl.ts new file mode 100644 index 0000000..30a8ab1 --- /dev/null +++ b/client/src/locales/pl.ts @@ -0,0 +1,94 @@ +export const pl = { + app: { + name: 'TarninaHub', + tagline: 'Małopolski Hub Innowacji Społecznych', + skipLink: 'Przejdź do treści głównej', + footer: 'TarninaHub — Małopolski Hub Innowacji Społecznych · Projekt hackathonowy', + }, + nav: { + label: 'Menu główne', + home: 'Strona główna', + submissions: 'Zgłoś potrzebę', + matchmaking: 'Dopasuj rozwiązanie', + knowledge: 'Zasobnik wiedzy', + ideas: 'Kreator pomysłów', + tester: 'Tester innowacji', + communication: 'Komunikacja', + partners: 'Partnerstwa', + panel: 'Panel', + login: 'Zaloguj się', + logout: 'Wyloguj się', + openMenu: 'Otwórz menu główne', + }, + theme: { + label: 'Motyw', + change: 'Zmień motyw', + light: 'Jasny', + dark: 'Ciemny', + highContrast: 'Wysoki kontrast', + system: 'Systemowy', + }, + a11y: { + prefsLabel: 'Ustawienia dostępności', + fontScale: 'Rozmiar tekstu', + fontIncrease: 'Powiększ tekst', + fontDecrease: 'Pomniejsz tekst', + fontReset: 'Przywróć domyślny rozmiar tekstu', + }, + common: { + loading: 'Ładowanie…', + close: 'Zamknij', + retry: 'Spróbuj ponownie', + backHome: 'Wróć na stronę główną', + errorTitle: 'Wystąpił problem', + }, + home: { + heroTitle: 'Dobre pomysły nie mogą pozostać niezauważone', + heroLead: + 'TarninaHub łączy potrzeby mieszkańców Małopolski z wiedzą, sprawdzonymi rozwiązaniami i ludźmi gotowymi do działania. Wspólnie sprawiamy, by wartościowe inicjatywy społeczne trafiały tam, gdzie są najbardziej potrzebne.', + ctaHow: 'Zobacz, jak to działa', + ctaAudience: 'Dla kogo jest TarninaHub?', + missionTitle: 'Nasza misja', + missionText: + 'Chcemy, aby żadna wartościowa inicjatywa społeczna nie przepadła z powodu braku informacji, kontaktu lub wsparcia. Technologia ma skracać drogę od pomysłu do działania i ułatwiać współpracę między mieszkańcami, instytucjami i organizacjami społecznymi.', + howTitle: 'Jak to działa?', + steps: { + submit: { + title: 'Zgłoś potrzebę', + text: 'Opisz problem społeczny krótkim zgłoszeniem — bez formalności, prostym językiem.', + }, + solve: { + title: 'Znajdź rozwiązanie', + text: 'Zobacz sprawdzone inicjatywy i wiedzę dopasowaną do Twojej lokalizacji i tematu.', + }, + act: { + title: 'Połącz siły', + text: 'Zaproś mieszkańców, instytucje i organizacje, a następnie wspólnie zrealizujcie projekt.', + }, + }, + audienceTitle: 'Dla kogo?', + features: { + residents: { + title: 'Potrzeby mieszkańców', + text: 'Mieszkańcy i lokalne społeczności zgłaszają realne problemy: dostęp do opieki, transport, wsparcie seniorów, aktywizację młodzieży.', + }, + solutions: { + title: 'Sprawdzone rozwiązania', + text: 'Baza wiedzy i gotowych pomysłów, które zadziałały w innych gminach, instytucjach i organizacjach społecznych.', + }, + people: { + title: 'Ludzie gotowi do działania', + text: 'Łączymy wolontariuszy, ekspertów, NGOs i samorządy, aby wspólnie wdrażać wartościowe inicjatywy.', + }, + }, + }, + placeholder: { + text: 'Ten moduł jest w przygotowaniu. Wróć wkrótce.', + }, + notFound: { + title: 'Nie znaleziono strony', + text: 'Podany adres nie istnieje lub został przeniesiony.', + }, +} + +export type Messages = typeof pl diff --git a/client/src/main.ts b/client/src/main.ts index b0a8e28..0e0c991 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,12 +1,30 @@ import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import { i18n } from './app/i18n' +import router from './app/router' +import { useSessionStore } from './app/stores/session' import vuetify from './plugins/vuetify' +import { setAuthTokenProvider } from './services/api' import { connectLocalSurreal } from './services/surreal' import './style.css' +import './design/tokens.css' import './styles/a11y.css' -import App from './App.vue' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) + +const session = useSessionStore(pinia) +setAuthTokenProvider(() => session.accessToken) + +app.use(router) +app.use(i18n) +app.use(vuetify) void connectLocalSurreal().catch((error: unknown) => { console.error('Nie udało się połączyć z lokalną bazą SurrealDB', error) }) -createApp(App).use(vuetify).mount('#app') +app.mount('#app') diff --git a/client/src/pages/HomeView.vue b/client/src/pages/HomeView.vue new file mode 100644 index 0000000..b743d99 --- /dev/null +++ b/client/src/pages/HomeView.vue @@ -0,0 +1,101 @@ + + + diff --git a/client/src/pages/MainView.vue b/client/src/pages/MainView.vue deleted file mode 100644 index 9775e41..0000000 --- a/client/src/pages/MainView.vue +++ /dev/null @@ -1,129 +0,0 @@ - - - diff --git a/client/src/pages/NotFoundView.vue b/client/src/pages/NotFoundView.vue new file mode 100644 index 0000000..8f78010 --- /dev/null +++ b/client/src/pages/NotFoundView.vue @@ -0,0 +1,18 @@ + + + diff --git a/client/src/pages/PlaceholderView.vue b/client/src/pages/PlaceholderView.vue new file mode 100644 index 0000000..233dbfd --- /dev/null +++ b/client/src/pages/PlaceholderView.vue @@ -0,0 +1,20 @@ + + + diff --git a/client/src/services/surreal.ts b/client/src/services/surreal.ts index af735db..e1c4690 100644 --- a/client/src/services/surreal.ts +++ b/client/src/services/surreal.ts @@ -22,9 +22,7 @@ export const localSurreal = new Surreal({ let localConnection: Promise | null = null export function connectLocalSurreal(): Promise { - localConnection ??= localSurreal - .connect(localUrl, { namespace, database }) - .then(() => undefined) + localConnection ??= localSurreal.connect(localUrl, { namespace, database }).then(() => undefined) return localConnection } @@ -33,8 +31,7 @@ export function connectLocalSurreal(): Promise { * B) Zdalna baza backendu na dedykowanym porcie (tylko dla SurrealDB), * używana do synchronizacji danych uznanych za nadające się do wysłania. */ -const remoteUrl = - import.meta.env.VITE_SURREAL_REMOTE_URL ?? 'ws://127.0.0.1:8010/rpc' +const remoteUrl = import.meta.env.VITE_SURREAL_REMOTE_URL ?? 'ws://127.0.0.1:8010/rpc' const username = import.meta.env.VITE_SURREAL_USERNAME as string | undefined const password = import.meta.env.VITE_SURREAL_PASSWORD as string | undefined @@ -47,9 +44,7 @@ export function connectRemoteSurreal(): Promise { .connect(remoteUrl, { namespace, database, - ...(username && password - ? { authentication: { username, password } } - : {}), + ...(username && password ? { authentication: { username, password } } : {}), }) .then(() => undefined) diff --git a/client/src/types/session.ts b/client/src/types/session.ts new file mode 100644 index 0000000..f1ba077 --- /dev/null +++ b/client/src/types/session.ts @@ -0,0 +1,13 @@ +export type UserRole = 'user' | 'jst' | 'operator' | 'manager' | 'admin' + +export interface SessionUser { + id: string + role: UserRole + displayName?: string + email?: string +} + +export interface SessionTokens { + accessToken: string + expiresAt?: string +} diff --git a/client/tsconfig.json b/client/tsconfig.json index 1ffef60..d32ff68 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -1,7 +1,4 @@ { "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] }