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:
+24
-3
@@ -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
@@ -1,100 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import MainView from './pages/MainView.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' },
|
||||
]
|
||||
import AppAnnouncer from './components/AppAnnouncer.vue'
|
||||
import DefaultLayout from './layouts/DefaultLayout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<a class="skip-link" href="#main">Przejdź do treści głównej</a>
|
||||
|
||||
<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>
|
||||
<AppAnnouncer />
|
||||
<DefaultLayout />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -22,9 +22,7 @@ export const localSurreal = new Surreal({
|
||||
let localConnection: Promise<void> | null = null
|
||||
|
||||
export function connectLocalSurreal(): Promise<void> {
|
||||
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<void> {
|
||||
* 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<void> {
|
||||
.connect(remoteUrl, {
|
||||
namespace,
|
||||
database,
|
||||
...(username && password
|
||||
? { authentication: { username, password } }
|
||||
: {}),
|
||||
...(username && password ? { authentication: { username, password } } : {}),
|
||||
})
|
||||
.then(() => undefined)
|
||||
|
||||
|
||||
@@ -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,7 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user