Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Repo-specific only. Global rules live in `~/.config/opencode/AGENTS.md`.
pnpm dev # dev server (port 3000)
pnpm build # production build → .output/
pnpm generate # static generation
pnpm preview # preview .output/
pnpm preview # preview .output/ on port 3001 (separate origin from dev — prevents SW from leaking into dev mode)

pnpm lint # eslint (exit 0 on warnings-only)
pnpm lint:fix # eslint --fix
Expand Down Expand Up @@ -92,13 +92,12 @@ app/
tanstack-table.ts # valueUpdater helper for @tanstack/vue-table
tw-helper.ts # tw() tagged template for Tailwind autocomplete
plugins/
nurekit.ts # provides $nurekit (dev: /api proxy; prod: sh.mindenit.org/api)
nurekit.ts # provides $nurekit — SDK default baseUrl: https://sh.mindenit.org/api
vue-query.ts # TanStack Query client + IndexedDB persistence
assets/css/
tailwind.css # single canonical CSS — Tailwind v4 + UI Thing base + full token/palette set
server/
api/[...any].ts # dev-only reverse proxy → https://sh.mindenit.org/api (404 in prod)
routes/ # server routes (currently empty)
routes/ # server routes (health, ready)
```

---
Expand All @@ -117,7 +116,7 @@ Beyond Nuxt's defaults, `nuxt.config` adds:
```
@nuxt/scripts # third-party script loading (includes useScriptGoogleAnalytics)
@nuxt/eslint # ESLint with generated .nuxt/eslint.config.mjs
@nuxt/icon # icon component, iconify provider, lucide bundled
@nuxt/icon # icon component; client bundle (18 KB) holds all 59 used icons inline
@nuxtjs/color-mode # dark/light/system mode via .dark class
@vueuse/nuxt # VueUse composables auto-imported
@pinia/nuxt # Pinia state management
Expand All @@ -133,6 +132,14 @@ vue-sonner/nuxt # toast notifications

`@nuxtjs/seo` bundles: sitemap (`/sitemap.xml`), robots, nuxt-schema-org, og-image (1200×630, 7-day cache). All configured in `nuxt.config`.

### Icon strategy

All 59 used icons are inlined in the client JS bundle (18 KB, scan-generated at build time). Rendering order: **client bundle → same-origin `/_icons` server route (SW SWR-cached) → api.iconify.design CDN (online fallback only)**.

`serverBundle: false` — no full collection JSONs in the Nitro server bundle (logos + ph alone would add 12 MB). Icons render client-side after hydration; no SSR flash for these small icons.

**Adding an icon from a new collection:** install its `@iconify-json/<name>` package as a devDep (`pnpm add -D @iconify-json/<name>`) and add the collection name to `icon.serverBundle.collections` in `nuxt.config.ts` only if you want SSR rendering for that collection.

---

## Component system — UI Thing (migrated from shadcn-nuxt)
Expand Down Expand Up @@ -163,7 +170,7 @@ vue-sonner/nuxt # toast notifications

### API client — `$nurekit`

Injected by `app/plugins/nurekit.ts`. In dev it points at `window.location.origin/api` (proxied by `server/api/[...any].ts` to `https://sh.mindenit.org/api`); in prod it uses the SDK's default. Always access via `useNuxtApp().$nurekit` inside query factories.
Injected by `app/plugins/nurekit.ts`. Uses the SDK default baseUrl (`https://sh.mindenit.org/api`) in both dev and prod — no server-side proxy. Always access via `useNuxtApp().$nurekit` inside query factories.

### Server state — TanStack Vue Query

Expand Down
4 changes: 2 additions & 2 deletions app/app.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
import { defineAsyncComponent } from "vue"

/**
Expand All @@ -7,7 +7,7 @@ import { defineAsyncComponent } from "vue"
* toggle the setting pay nothing for it. Wrapped in <ClientOnly> already, so
* SSR doesn't try to resolve the dynamic import.
*/
const SnowEffect = defineAsyncComponent(() => import("~/components/SnowEffect.vue"))
const SnowEffect = defineAsyncComponent(() => import("~/components/backgrounds/SnowEffect.vue"))

const { isSnowEnabled, isShortcutsOpen } = useAppShell()
</script>
Expand Down
2 changes: 1 addition & 1 deletion app/components/AppShortcutsDialog.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
const open = defineModel<boolean>("open", { required: true })
</script>

Expand Down
2 changes: 1 addition & 1 deletion app/components/DialogManager.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
const { currentDialog, isDialogOpen, closeDialog, initializeDialogs } = useDialogManager()

onMounted(() => {
Expand Down
2 changes: 1 addition & 1 deletion app/components/OgImage/Mindenit.satori.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
interface Props {
title?: string
description?: string
Expand Down
2 changes: 1 addition & 1 deletion app/components/SidebarTodayEvents.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
import { addDays, subDays, isToday } from "date-fns"
import { formatInTimeZone } from "date-fns-tz"
import { storeToRefs } from "pinia"
Expand Down
2 changes: 1 addition & 1 deletion app/components/TheLoader.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
interface Props {
size?: "lg" | "md" | "sm"
}
Expand Down
2 changes: 1 addition & 1 deletion app/components/ThemeSwitcher.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
const colorMode = useColorMode()
const { trackEvent } = useAnalytics()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup></script>
<script setup lang="ts"></script>
<template>
<div
class="relative mt-24 ml-20 h-15 w-20"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
<script lang="ts" setup>
<script setup lang="ts">
const isOnline = useOnline()
const wasOffline = ref(false)
const showReconnected = ref(false)

const calendarStore = useCalendarStore()
const { lastUpdatedAt } = storeToRefs(calendarStore)
const { tzFormat } = useTimezone()

const lastFetchTime = computed(() =>
lastUpdatedAt.value > 0 ? tzFormat(new Date(lastUpdatedAt.value), "HH:mm") : null
)

watch(isOnline, (online) => {
if (!online) {
wasOffline.value = true
Expand Down Expand Up @@ -34,7 +42,11 @@ watch(isOnline, (online) => {
aria-live="assertive"
>
<AppIcon name="lucide:wifi-off" class="shrink-0" aria-hidden="true" />
<span>Без з'єднання — показано збережені дані</span>
<span>
Без з'єднання —
<template v-if="lastFetchTime">дані від {{ lastFetchTime }}</template>
<template v-else>показано збережені дані</template>
</span>
</div>
</Transition>
</template>
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
interface Props {
variant?: "banner" | "card"
}
Expand Down
16 changes: 14 additions & 2 deletions app/components/calendar/Root.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface Props {
events: Schedule[]
hasActiveSchedule: boolean
isLoading: boolean
isOfflineNoData: boolean
error: Error | null
scheduleName?: string
}
Expand All @@ -14,7 +15,7 @@ const props = defineProps<Props>()
const calendarStore = useCalendarStore()
const { view } = storeToRefs(calendarStore)
const { trackEvent } = useAnalytics()
const isAddDialogOpen = useState("schedule:add-dialog:open", () => false)
const isAddDialogOpen = useState(DIALOG_KEYS.scheduleAdd, () => false)

// SSR renders the calendar with no localStorage access, so hasActiveSchedule is
// always false on the server. This causes a hydration mismatch on blur-sm and the
Expand Down Expand Up @@ -52,13 +53,15 @@ watch(

const showOverlay = computed(() => {
if (isHydrating.value) return true
return !props.hasActiveSchedule || props.isLoading || !!props.error
return !props.hasActiveSchedule || props.isLoading || props.isOfflineNoData || !!props.error
})

const overlayContent = computed(() => {
if (isHydrating.value) return "loading"
if (!props.hasActiveSchedule) return "no-schedule"
if (props.isLoading) return "loading"
// Offline and TanStack query never resolved (no cached data in IDB).
if (props.isOfflineNoData) return "offline-no-data"
if (props.error) return "error"
return null
})
Expand Down Expand Up @@ -163,6 +166,15 @@ const overlayContent = computed(() => {
class="pointer-events-auto"
/>

<AppEmptyState
v-else-if="overlayContent === 'offline-no-data'"
variant="card"
icon="lucide:wifi-off"
title="Немає з'єднання"
description="Збережених даних для цього розкладу немає"
class="pointer-events-auto"
/>

<UiAlert
v-else-if="overlayContent === 'error'"
variant="destructive"
Expand Down
2 changes: 1 addition & 1 deletion app/components/schedule/AddDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const activeTab = ref<ScheduleTabType>("group")

// useState makes the open state globally accessible — e.g. Root.vue's
// "no schedule" CTA can open this dialog without prop drilling.
const isDialogOpen = useState("schedule:add-dialog:open", () => false)
const isDialogOpen = useState(DIALOG_KEYS.scheduleAdd, () => false)

const {
data: groups,
Expand Down
2 changes: 1 addition & 1 deletion app/components/schedule/AddDialogTrigger.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ interface Props {

defineProps<Props>()

const isDialogOpen = useState("schedule:add-dialog:open", () => false)
const isDialogOpen = useState(DIALOG_KEYS.scheduleAdd, () => false)
</script>

<template>
Expand Down
2 changes: 1 addition & 1 deletion app/components/schedule/DialogCard.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
import { SCHEDULE_ICONS } from "~/constants/schedule"
import type { ScheduleTabType } from "~/types/schedule"

Expand Down
4 changes: 2 additions & 2 deletions app/components/settings/ScheduleTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ const { selectedSchedule } = storeToRefs(scheduleStore)
const { exportAcademicYearSchedule, isLoading } = useScheduleIcsExport()

// Shared state — useState keys keep one ref across all call-sites.
const isShortcutsOpen = useState("shortcuts:open", () => false)
const isSettingsOpen = useState("settings:open", () => false)
const isShortcutsOpen = useState(DIALOG_KEYS.shortcuts, () => false)
const isSettingsOpen = useState(DIALOG_KEYS.settings, () => false)

async function openShortcuts() {
// Close settings first; wait a tick so Reka tears down its overlay before
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
const { trackEvent } = useAnalytics()

const isOpen = useState("settings:open", () => false)
const isOpen = useState(DIALOG_KEYS.settings, () => false)

function onOpenChange(open: boolean) {
isOpen.value = open
Expand Down
4 changes: 2 additions & 2 deletions app/composables/useDialogManager.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useLocalStorage } from "@vueuse/core"
import { useStorage } from "@vueuse/core"
import type { DialogConfig } from "~/constants/dialogs"
import { DIALOGS_CONFIG } from "~/constants/dialogs"
import { STORAGE_KEYS } from "~/constants/storage"

export const useDialogManager = () => {
const shownDialogs = useLocalStorage<Record<string, { version: number; lastShown: string }>>(
const shownDialogs = useStorage<Record<string, { version: number; lastShown: string }>>(
STORAGE_KEYS.dialogsShown,
{}
)
Expand Down
6 changes: 1 addition & 5 deletions app/composables/useIcsExport.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type { Schedule } from "@mindenit/nurekit"
import { formatInTimeZone } from "date-fns-tz"
import { storeToRefs } from "pinia"
import { downloadFile } from "~/utils/download"
import { EVENT_TYPE_LABELS } from "~/constants/calendar"
import { resolveTimezone } from "~/constants/timezones"
import { useSettingsStore } from "~/stores/settings"

export interface IcsExportOptions {
academicYearStart?: Date
Expand All @@ -13,8 +10,7 @@ export interface IcsExportOptions {
}

export const useIcsExport = () => {
const { timezone } = storeToRefs(useSettingsStore())
const effectiveTimezone = computed(() => resolveTimezone(timezone.value))
const { effectiveTimezone } = useTimezone()

/**
* Format a Unix seconds timestamp as an ICS datetime string.
Expand Down
4 changes: 3 additions & 1 deletion app/composables/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DIALOG_KEYS } from "~/constants/dialogs"

/**
* Registers global keyboard shortcuts for calendar navigation.
*
Expand All @@ -13,7 +15,7 @@ export function useKeyboardShortcuts() {
const { trackEvent } = useAnalytics()

// useState keeps one shared ref across all composable call-sites (app.vue + settings).
const isShortcutsOpen = useState("shortcuts:open", () => false)
const isShortcutsOpen = useState(DIALOG_KEYS.shortcuts, () => false)

function isInInteractiveContext(e: KeyboardEvent): boolean {
const target = e.target as HTMLElement
Expand Down
18 changes: 10 additions & 8 deletions app/constants/dialogs.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
export interface DialogConfig {
id: string
version: number
priority: number // Lower number means higher priority
enabled: boolean
component: string
minShowInterval?: number
}
import type { DialogConfig } from "~/types/dialogs"

export type { DialogConfig }

export const DIALOGS_CONFIG: DialogConfig[] = [
{
Expand All @@ -16,3 +11,10 @@ export const DIALOGS_CONFIG: DialogConfig[] = [
component: "AppDialog",
},
]

/** useState keys for cross-component dialog open state. */
export const DIALOG_KEYS = {
settings: "settings:open",
shortcuts: "shortcuts:open",
scheduleAdd: "schedule:add-dialog:open",
} as const
3 changes: 3 additions & 0 deletions app/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export const STORAGE_KEYS = {
// Maintenance bypass cookie (set via /maintenance dev access flow)
devAccess: "mindenit:dev-access",

// One-time first-visit tracking (set true after first-ever page load)
firstVisitSeen: "mindenit:first-visit-seen",

// Analytics consent ("accepted" | "declined" | null means not yet decided)
analyticsConsent: "mindenit:analytics-consent",

Expand Down
2 changes: 1 addition & 1 deletion app/pages/blocked.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script setup lang="ts">
useSeo({
title: "Виявлено шкідливий браузер",
noindex: true,
Expand Down
Loading
Loading