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.
This commit is contained in:
Rafał Tyński
2026-10-04 04:04:35 +02:00
parent 92a904b8a9
commit 804ad617ad
18 changed files with 728 additions and 239 deletions
+24 -3
View File
@@ -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 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. Aplikacja SPA: **Vue 3 + TypeScript + Vite + Vuetify 4**, z routingiem (`vue-router`), stanem (`pinia`) i i18n (`vue-i18n`). Fundament dostępności WCAG 2.1 AA (senior-friendly).
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup). ## Komendy
```bash
npm install
npm run dev # serwer dev (Vite)
npm run build # typecheck (vue-tsc) + build produkcyjny
npm run preview # podgląd builda
npm run lint # ESLint
npm run format # Prettier (zapis)
npm run format:check # Prettier (weryfikacja)
npm run test # Vitest
npm run test:a11y # Vitest (testy dostępności)
```
## Konfiguracja
Skopiuj `.env.example` do `.env` i ustaw w razie potrzeby:
- `VITE_API_BASE_URL` — bazowy adres API (pusty = ten sam origin; w dev proxy Vite),
- `VITE_SURREAL_*` — lokalna (WASM/IndexedDB) i zdalna baza SurrealDB.
Szczegóły architektury i konwencji: `10_ZalozeniaProjektowe/todo18/FRONTEND.md`.
+4 -93
View File
@@ -1,100 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import AppAnnouncer from './components/AppAnnouncer.vue'
import MainView from './pages/MainView.vue' import DefaultLayout from './layouts/DefaultLayout.vue'
import { useThemeMode } from './composables/useThemeMode'
interface NavItem {
title: string
href: string
icon: string
}
const drawer = ref(false)
const { mode, themeOptions } = useThemeMode()
const activeTheme = computed(
() => themeOptions.find((option) => option.value === mode.value) ?? themeOptions[2]!,
)
const navItems: NavItem[] = [
{ title: 'Misja', href: '#misja', icon: 'mdi-compass-outline' },
{ title: 'Jak to działa', href: '#jak-to-dziala', icon: 'mdi-sitemap-outline' },
{ title: 'Dla kogo', href: '#dla-kogo', icon: 'mdi-account-group-outline' },
]
</script> </script>
<template> <template>
<v-app> <v-app>
<a class="skip-link" href="#main">Przejdź do treści głównej</a> <AppAnnouncer />
<DefaultLayout />
<v-app-bar color="primary" density="comfortable" scroll-behavior="elevate">
<v-app-bar-nav-icon
aria-label="Otwórz menu główne"
color="on-primary"
@click="drawer = !drawer"
/>
<v-app-bar-title>TarninaHub</v-app-bar-title>
<v-spacer />
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn
v-bind="props"
:prepend-icon="activeTheme.icon"
:aria-label="`Motyw: ${activeTheme.title}. Zmień motyw`"
color="on-primary"
variant="text"
class="me-1"
>
{{ activeTheme.title }}
</v-btn>
</template>
<v-list aria-label="Wybór motywu">
<v-list-item
v-for="option in themeOptions"
:key="option.value"
:prepend-icon="option.icon"
:title="option.title"
:active="mode === option.value"
@click="mode = option.value"
/>
</v-list>
</v-menu>
<nav aria-label="Menu główne" class="d-none d-md-block">
<v-btn
v-for="item in navItems"
:key="item.href"
:href="item.href"
color="on-primary"
variant="text"
>
{{ item.title }}
</v-btn>
</nav>
</v-app-bar>
<v-navigation-drawer v-model="drawer" aria-label="Menu główne" temporary>
<v-list nav>
<v-list-item
v-for="item in navItems"
:key="item.href"
:href="item.href"
:prepend-icon="item.icon"
:title="item.title"
@click="drawer = false"
/>
</v-list>
</v-navigation-drawer>
<v-main id="main" tabindex="-1">
<MainView />
</v-main>
<v-footer class="d-block pa-4 text-center">
<p class="ma-0">
TarninaHub — Małopolski Hub Innowacji Społecznych · Projekt hackathonowy
</p>
</v-footer>
</v-app> </v-app>
</template> </template>
+22
View File
@@ -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
}
+33
View File
@@ -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()
}
})
})
+95
View File
@@ -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<HTMLElement>('#main h1')
if (heading) {
heading.setAttribute('tabindex', '-1')
heading.focus()
}
})
export default router
+31
View File
@@ -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<string | null>('tarninahub-token', null)
const user = ref<SessionUser | null>(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 }
})
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useA11yPrefs } from '../composables/useA11yPrefs'
import { useThemeMode } from '../composables/useThemeMode'
interface NavItem {
name: string
labelKey: string
icon: string
}
const { t } = useI18n()
const drawer = ref(false)
const { mode, themeOptions } = useThemeMode()
const activeTheme = computed(
() => themeOptions.find((option) => option.value === mode.value) ?? themeOptions[3]!,
)
const { fontScale, canIncrease, canDecrease, increase, decrease, reset } = useA11yPrefs()
const navItems: NavItem[] = [
{ name: 'home', labelKey: 'nav.home', icon: 'mdi-home-outline' },
{ name: 'submissions', labelKey: 'nav.submissions', icon: 'mdi-bullhorn-outline' },
{ name: 'matchmaking', labelKey: 'nav.matchmaking', icon: 'mdi-connection' },
{ name: 'knowledge', labelKey: 'nav.knowledge', icon: 'mdi-book-open-page-variant-outline' },
{ name: 'ideas', labelKey: 'nav.ideas', icon: 'mdi-lightbulb-on-outline' },
{ name: 'tester', labelKey: 'nav.tester', icon: 'mdi-flask-outline' },
{ name: 'communication', labelKey: 'nav.communication', icon: 'mdi-forum-outline' },
{ name: 'partners', labelKey: 'nav.partners', icon: 'mdi-handshake-outline' },
{ name: 'panel', labelKey: 'nav.panel', icon: 'mdi-view-dashboard-outline' },
]
</script>
<template>
<a class="skip-link" href="#main">{{ t('app.skipLink') }}</a>
<v-app-bar color="primary" density="comfortable" scroll-behavior="elevate">
<v-app-bar-nav-icon
:aria-label="t('nav.openMenu')"
color="on-primary"
@click="drawer = !drawer"
/>
<v-app-bar-title>
<router-link to="/" class="app-bar-title">{{ t('app.name') }}</router-link>
</v-app-bar-title>
<v-spacer />
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn
v-bind="props"
:prepend-icon="activeTheme.icon"
:aria-label="`${t('theme.label')}: ${t(activeTheme.labelKey)}. ${t('theme.change')}`"
color="on-primary"
variant="text"
class="me-1"
>
<span class="d-none d-sm-inline">{{ t(activeTheme.labelKey) }}</span>
</v-btn>
</template>
<v-list :aria-label="t('theme.change')">
<v-list-item
v-for="option in themeOptions"
:key="option.value"
:prepend-icon="option.icon"
:title="t(option.labelKey)"
:active="mode === option.value"
@click="mode = option.value"
/>
</v-list>
</v-menu>
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-text-box-plus-outline"
:aria-label="t('a11y.prefsLabel')"
color="on-primary"
variant="text"
/>
</template>
<v-list :aria-label="t('a11y.prefsLabel')">
<v-list-item :title="`${t('a11y.fontScale')}: ${Math.round(fontScale * 100)}%`" disabled />
<v-list-item
prepend-icon="mdi-magnify-plus-outline"
:title="t('a11y.fontIncrease')"
:disabled="!canIncrease"
@click="increase"
/>
<v-list-item
prepend-icon="mdi-magnify-minus-outline"
:title="t('a11y.fontDecrease')"
:disabled="!canDecrease"
@click="decrease"
/>
<v-list-item prepend-icon="mdi-restore" :title="t('a11y.fontReset')" @click="reset" />
</v-list>
</v-menu>
</v-app-bar>
<v-navigation-drawer v-model="drawer" :aria-label="t('nav.label')" temporary>
<v-list nav>
<v-list-item
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
:prepend-icon="item.icon"
:title="t(item.labelKey)"
@click="drawer = false"
/>
</v-list>
</v-navigation-drawer>
<v-main id="main" tabindex="-1">
<v-container class="py-6 py-md-8">
<router-view />
</v-container>
</v-main>
<v-footer class="d-block pa-4 text-center">
<p class="ma-0">{{ t('app.footer') }}</p>
</v-footer>
</template>
<style scoped>
.app-bar-title {
color: inherit;
text-decoration: none;
font-weight: 600;
}
.app-bar-title:hover {
text-decoration: underline;
}
</style>
+94
View File
@@ -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.',
},
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { en } from './en'
import { pl } from './pl'
function flatKeys(source: Record<string, unknown>, prefix = ''): string[] {
return Object.entries(source).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key
return value && typeof value === 'object'
? flatKeys(value as Record<string, unknown>, path)
: [path]
})
}
describe('i18n messages', () => {
it('defines the same keys in Polish and English', () => {
expect(flatKeys(en).sort()).toEqual(flatKeys(pl).sort())
})
})
+94
View File
@@ -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
+20 -2
View File
@@ -1,12 +1,30 @@
import { createApp } from 'vue' 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 vuetify from './plugins/vuetify'
import { setAuthTokenProvider } from './services/api'
import { connectLocalSurreal } from './services/surreal' import { connectLocalSurreal } from './services/surreal'
import './style.css' import './style.css'
import './design/tokens.css'
import './styles/a11y.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) => { void connectLocalSurreal().catch((error: unknown) => {
console.error('Nie udało się połączyć z lokalną bazą SurrealDB', error) console.error('Nie udało się połączyć z lokalną bazą SurrealDB', error)
}) })
createApp(App).use(vuetify).mount('#app') app.mount('#app')
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import AppSection from '../components/AppSection.vue'
const { t } = useI18n()
interface Feature {
icon: string
titleKey: string
textKey: string
}
interface Step {
number: string
titleKey: string
textKey: string
}
const features: Feature[] = [
{
icon: 'mdi-home-heart-outline',
titleKey: 'home.features.residents.title',
textKey: 'home.features.residents.text',
},
{
icon: 'mdi-lightbulb-on-outline',
titleKey: 'home.features.solutions.title',
textKey: 'home.features.solutions.text',
},
{
icon: 'mdi-account-multiple-check-outline',
titleKey: 'home.features.people.title',
textKey: 'home.features.people.text',
},
]
const steps: Step[] = [
{ number: '1', titleKey: 'home.steps.submit.title', textKey: 'home.steps.submit.text' },
{ number: '2', titleKey: 'home.steps.solve.title', textKey: 'home.steps.solve.text' },
{ number: '3', titleKey: 'home.steps.act.title', textKey: 'home.steps.act.text' },
]
</script>
<template>
<section aria-labelledby="hero-title" class="mb-12">
<v-row align="center" justify="center">
<v-col cols="12" md="10" lg="8" class="text-center">
<h1 id="hero-title" class="text-h4 text-md-h3 mb-6">{{ t('home.heroTitle') }}</h1>
<p class="text-body-1 text-md-h6 mb-8">{{ t('home.heroLead') }}</p>
<div class="th-cluster justify-center">
<v-btn :to="{ name: 'submissions' }" size="x-large">{{ t('home.ctaHow') }}</v-btn>
<v-btn :to="{ name: 'knowledge' }" size="x-large" variant="outlined">
{{ t('home.ctaAudience') }}
</v-btn>
</div>
</v-col>
</v-row>
</section>
<AppSection :title="t('home.missionTitle')">
<v-card variant="tonal" color="primary" class="pa-6 pa-md-8">
<p class="text-body-1 mb-0">{{ t('home.missionText') }}</p>
</v-card>
</AppSection>
<AppSection :title="t('home.howTitle')">
<v-row>
<v-col v-for="step in steps" :key="step.number" cols="12" md="4">
<v-card class="h-100 pa-2" variant="outlined">
<v-card-item>
<v-avatar color="primary" size="large" class="mb-3">
<span class="text-h6" aria-hidden="true">{{ step.number }}</span>
</v-avatar>
<h3 class="text-h6 mb-2">{{ t(step.titleKey) }}</h3>
<p class="text-body-2 mb-0">{{ t(step.textKey) }}</p>
</v-card-item>
</v-card>
</v-col>
</v-row>
</AppSection>
<AppSection :title="t('home.audienceTitle')">
<v-row>
<v-col v-for="feature in features" :key="feature.titleKey" cols="12" md="4">
<v-card class="h-100 pa-2" variant="outlined">
<v-card-item>
<v-icon
:icon="feature.icon"
size="40"
color="primary"
class="mb-3"
aria-hidden="true"
/>
<h3 class="text-h6 mb-2">{{ t(feature.titleKey) }}</h3>
<p class="text-body-2 mb-0">{{ t(feature.textKey) }}</p>
</v-card-item>
</v-card>
</v-col>
</v-row>
</AppSection>
</template>
-129
View File
@@ -1,129 +0,0 @@
<script setup lang="ts">
interface Feature {
icon: string
title: string
text: string
}
interface Step {
number: string
title: string
text: string
}
const features: Feature[] = [
{
icon: 'mdi-home-heart-outline',
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.',
},
{
icon: 'mdi-lightbulb-on-outline',
title: 'Sprawdzone rozwiązania',
text: 'Baza wiedzy i gotowych pomysłów, które zadziałały w innych gminach, instytucjach i organizacjach społecznych.',
},
{
icon: 'mdi-account-multiple-check-outline',
title: 'Ludzie gotowi do działania',
text: 'Łączymy wolontariuszy, ekspertów, NGOs i samorządy, aby wspólnie wdrażać wartościowe inicjatywy.',
},
]
const steps: Step[] = [
{
number: '1',
title: 'Zgłoś potrzebę',
text: 'Opisz problem społeczny krótkim zgłoszeniem — bez formalności, prostym językiem.',
},
{
number: '2',
title: 'Znajdź rozwiązanie',
text: 'Zobacz sprawdzone inicjatywy i wiedzę dopasowaną do Twojej lokalizacji i tematu.',
},
{
number: '3',
title: 'Połącz siły',
text: 'Zaproś mieszkańców, instytucje i organizacje, a następnie wspólnie zrealizujcie projekt.',
},
]
</script>
<template>
<v-container class="py-8 py-md-12">
<section aria-labelledby="hero-title" class="mb-12">
<v-row align="center" justify="center">
<v-col cols="12" md="9" lg="8" class="text-center">
<h1 id="hero-title" class="text-h3 text-md-h2 mb-6">
Dobre pomysły nie mogą pozostać niezauważone
</h1>
<p class="text-body-1 text-md-h6 mb-8">
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.
</p>
<div class="d-flex flex-wrap justify-center ga-3">
<v-btn href="#jak-to-dziala" size="x-large">Zobacz, jak to działa</v-btn>
<v-btn href="#dla-kogo" size="x-large" variant="outlined">
Dla kogo jest TarninaHub?
</v-btn>
</div>
</v-col>
</v-row>
</section>
<section id="misja" aria-labelledby="misja-title" class="mb-12">
<v-row justify="center">
<v-col cols="12" md="10" lg="8">
<v-card variant="tonal" color="primary" class="pa-6 pa-md-8">
<h2 id="misja-title" class="text-h5 mb-4">Nasza misja</h2>
<p class="text-body-1 mb-0">
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.
</p>
</v-card>
</v-col>
</v-row>
</section>
<section id="jak-to-dziala" aria-labelledby="jak-title" class="mb-12">
<h2 id="jak-title" class="text-h4 text-center mb-8">Jak to działa?</h2>
<v-row>
<v-col v-for="step in steps" :key="step.number" cols="12" md="4">
<v-card class="h-100 pa-2" variant="outlined">
<v-card-item>
<v-avatar color="primary" size="large" class="mb-3">
<span class="text-h6" aria-hidden="true">{{ step.number }}</span>
</v-avatar>
<h3 class="text-h6 mb-2">{{ step.title }}</h3>
<p class="text-body-2 mb-0">{{ step.text }}</p>
</v-card-item>
</v-card>
</v-col>
</v-row>
</section>
<section id="dla-kogo" aria-labelledby="dla-kogo-title" class="mb-8">
<h2 id="dla-kogo-title" class="text-h4 text-center mb-8">Dla kogo?</h2>
<v-row>
<v-col v-for="feature in features" :key="feature.title" cols="12" md="4">
<v-card class="h-100 pa-2" variant="outlined">
<v-card-item>
<v-icon
:icon="feature.icon"
size="40"
color="primary"
class="mb-3"
aria-hidden="true"
/>
<h3 class="text-h6 mb-2">{{ feature.title }}</h3>
<p class="text-body-2 mb-0">{{ feature.text }}</p>
</v-card-item>
</v-card>
</v-col>
</v-row>
</section>
</v-container>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import AppEmptyState from '../components/AppEmptyState.vue'
import AppPageHeader from '../components/AppPageHeader.vue'
const { t } = useI18n()
</script>
<template>
<AppPageHeader :title="t('notFound.title')" :lead="t('notFound.text')" />
<AppEmptyState
:title="t('notFound.title')"
:text="t('notFound.text')"
icon="mdi-map-marker-question-outline"
>
<v-btn :to="{ name: 'home' }" size="large">{{ t('common.backHome') }}</v-btn>
</AppEmptyState>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import AppEmptyState from '../components/AppEmptyState.vue'
import AppPageHeader from '../components/AppPageHeader.vue'
const { t } = useI18n()
const route = useRoute()
const title = computed(() => {
const key = route.meta.titleKey as string | undefined
return key ? t(key) : t('app.name')
})
</script>
<template>
<AppPageHeader :title="title" />
<AppEmptyState :title="title" :text="t('placeholder.text')" icon="mdi-hammer-wrench" />
</template>
+3 -8
View File
@@ -22,9 +22,7 @@ export const localSurreal = new Surreal({
let localConnection: Promise<void> | null = null let localConnection: Promise<void> | null = null
export function connectLocalSurreal(): Promise<void> { export function connectLocalSurreal(): Promise<void> {
localConnection ??= localSurreal localConnection ??= localSurreal.connect(localUrl, { namespace, database }).then(() => undefined)
.connect(localUrl, { namespace, database })
.then(() => undefined)
return localConnection return localConnection
} }
@@ -33,8 +31,7 @@ export function connectLocalSurreal(): Promise<void> {
* B) Zdalna baza backendu na dedykowanym porcie (tylko dla SurrealDB), * B) Zdalna baza backendu na dedykowanym porcie (tylko dla SurrealDB),
* używana do synchronizacji danych uznanych za nadające się do wysłania. * używana do synchronizacji danych uznanych za nadające się do wysłania.
*/ */
const remoteUrl = const remoteUrl = import.meta.env.VITE_SURREAL_REMOTE_URL ?? 'ws://127.0.0.1:8010/rpc'
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 username = import.meta.env.VITE_SURREAL_USERNAME as string | undefined
const password = import.meta.env.VITE_SURREAL_PASSWORD as string | undefined const password = import.meta.env.VITE_SURREAL_PASSWORD as string | undefined
@@ -47,9 +44,7 @@ export function connectRemoteSurreal(): Promise<void> {
.connect(remoteUrl, { .connect(remoteUrl, {
namespace, namespace,
database, database,
...(username && password ...(username && password ? { authentication: { username, password } } : {}),
? { authentication: { username, password } }
: {}),
}) })
.then(() => undefined) .then(() => undefined)
+13
View File
@@ -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
}
+1 -4
View File
@@ -1,7 +1,4 @@
{ {
"files": [], "files": [],
"references": [ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
} }