feat: enhance project with comprehensive tooling and quality improvements

- Add stricter ESLint configuration with TypeScript and Vue 3 rules
- Configure Prettier for consistent code formatting across the project
- Set up Vitest testing framework with Vue Test Utils and jsdom environment
- Implement pre-commit hooks using Husky and lint-staged for automated quality checks
- Add bundle analyzer with rollup-plugin-visualizer for performance monitoring
- Create global error boundary component for better error handling
- Develop error handling composable with loading state management
- Enhance API utilities with robust fetch wrapper including retries and timeout
- Improve TypeScript interfaces with proper readonly modifiers and better typing
- Add comprehensive npm scripts for development workflow
- Create centralized logging utility to replace console statements
- Update Husky configuration to remove deprecated lines
- Fix all ESLint formatting and syntax issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-19 01:52:56 +02:00
parent 56c82332ae
commit 08237f18e5
40 changed files with 11259 additions and 713 deletions

33
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,33 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'@vue/eslint-config-typescript',
'eslint-config-vuetify',
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
// Vue specific rules
'vue/multi-word-component-names': 'off',
'vue/no-undef-components': 'off', // Vuetify components are auto-imported
// TypeScript specific rules
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
// General code quality rules
'prefer-const': 'error',
'no-var': 'error',
'no-console': 'warn',
'no-debugger': 'error',
},
}

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

14
.prettierignore Normal file
View File

@@ -0,0 +1,14 @@
dist/
node_modules/
coverage/
.nuxt/
.output/
.vite/
*.min.js
*.min.css
pnpm-lock.yaml
yarn.lock
package-lock.json
src/auto-imports.d.ts
src/components.d.ts
src/typed-router.d.ts

15
.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"vueIndentScriptAndStyle": false,
"htmlWhitespaceSensitivity": "css",
"embeddedLanguageFormatting": "auto"
}

View File

@@ -13,10 +13,7 @@
}
},
{
"files": [
",*.html",
"legacy/**/*.js"
],
"files": [",*.html", "legacy/**/*.js"],
"options": {
"tabWidth": 4
}

28
AGENTS.md Normal file
View File

@@ -0,0 +1,28 @@
# AGENTS.md
## Setup & Commands
- Install dependencies: `npm install`
- Start the development server: `npm run dev`
- Lint and format: `npm run lint`
- Type-check & build: `npm run build`
## Current Architecture
- Auto-routed views live in `src/pages/` and use the layouts declared in `src/layouts/`.
- Shared UI elements are located in `src/components/`; global helpers live in `src/composables/` and `src/services/`.
- Navigation metadata and the component catalog are sourced from `src/services/componentCatalog.ts` and exposed through `useComponentCatalog`.
- Ambient type declarations (e.g. Prism.js module shims) are kept in `src/types/`.
## Refactor Highlights
- Deduplicated route and catalog metadata by consolidating on the `services/navigation` + `useComponentCatalog` flow.
- Converted the `Markup` code viewer to lazy-load Prism languages and share hyperlink handling through `services/prism`.
- Removed inline styles in favour of Vuetify utility classes or scoped styles.
- Added an ESLint configuration that enforces Vue 3 + TypeScript + Vuetify best practices under strict TypeScript settings.
## Maintenance Guidelines
- When adding catalog entries, update `src/services/componentCatalog.ts`; navigation drawers and pages consume the same source of truth.
- Prefer Vuetify utility classes; only introduce scoped CSS when utilities do not cover a use-case.
- Run `npm run lint` and `npm run build` before opening a PR to catch style or type regressions early.

View File

@@ -1,8 +1,36 @@
# nachtigall.dev
## Ideas for Portfolio
A Vue 3 + Vuetify playground for prototyping component ideas and documenting reusable UI patterns.
* [ ] Web-Components
* [ ] Component preview
* [ ] Code display
* [ ] Changeable Variables
## Getting Started
- Install dependencies with `npm install`.
- Start the development server with `npm run dev` (Vite).
- Lint the codebase with `npm run lint`.
- Type-check and build the production bundle with `npm run build`.
## Project Layout
- `src/main.ts` bootstraps the Vue application and registers plugins.
- `src/layouts/` contains layout shells that wrap routed pages.
- `src/pages/` defines the routed views, generated automatically by `unplugin-vue-router`.
- `src/components/` holds global UI components, including the reusable `app/Markup` code viewer.
- `src/composables/` exposes Composition API helpers, such as the component catalog utilities.
- `src/services/` centralises domain logic (navigation metadata, Prism highlighting helpers, etc.).
- `src/stores/` defines Pinia stores.
- `src/types/` contains ambient TypeScript declarations.
## Component Catalog
The component catalog data and navigation structure live in `src/services/componentCatalog.ts` and are consumed via the `useComponentCatalog` composable. This keeps page views and navigation drawers in sync while supporting lazy loading.
## Styling Guidelines
- Prefer Vuetify utility classes for spacing, colour and typography.
- Where custom rules are required, add them in the scoped `<style>` blocks of the relevant components.
- Inline styles are intentionally avoided.
## Tooling
- ESLint + Prettier enforce code style via `npm run lint`.
- TypeScript runs in strict mode. Fix type errors surfaced by `npm run build` before committing.

View File

@@ -1,19 +1,21 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link href="#" rel="icon"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<head>
<meta charset="UTF-8" />
<link href="#" rel="icon" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>nachtigall.dev</title>
<link crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/css/fork-awesome.min.css"
integrity="sha256-XoaMnoYC5TH6/+ihMEnospgm0J1PM/nioxbOUdnM8HY=" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script src="/src/main.ts" type="module"></script>
</body>
<link
crossorigin="anonymous"
href="https://cdn.jsdelivr.net/npm/fork-awesome@1.2.0/css/fork-awesome.min.css"
integrity="sha256-XoaMnoYC5TH6/+ihMEnospgm0J1PM/nioxbOUdnM8HY="
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script src="/src/main.ts" type="module"></script>
</body>
</html>

8563
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,23 @@
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --fix --ignore-path .gitignore"
"lint": "eslint . --fix --ignore-path .gitignore",
"format": "prettier --write .",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"typecheck": "vue-tsc --noEmit",
"analyze": "ANALYZE=true npm run build",
"prepare": "husky"
},
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,html,css,scss}": [
"prettier --write"
]
},
"dependencies": {
"@mdi/font": "^7.4.47",
@@ -22,7 +38,9 @@
"@types/node": "^20.14.10",
"@types/prismjs": "^1.26.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vitest/ui": "^3.2.4",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.6",
"eslint": "^8.57.0",
"eslint-config-standard": "^17.1.0",
"eslint-config-vuetify": "^1.0.0",
@@ -31,7 +49,11 @@
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.4.0",
"eslint-plugin-vue": "^9.27.0",
"husky": "^9.1.7",
"jsdom": "^27.0.0",
"lint-staged": "^16.1.6",
"pinia": "^2.1.7",
"rollup-plugin-visualizer": "^6.0.3",
"sass": "1.77.6",
"typescript": "^5.4.2",
"unplugin-auto-import": "^0.17.6",
@@ -41,6 +63,7 @@
"vite": "^5.3.3",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "^2.0.3",
"vitest": "^3.2.4",
"vue-router": "^4.4.0",
"vue-tsc": "^2.0.26"
}

View File

@@ -1,7 +1,9 @@
<template>
<v-app>
<v-main>
<router-view />
<ErrorBoundary>
<router-view />
</ErrorBoundary>
</v-main>
</v-app>
</template>

1
src/components.d.ts vendored
View File

@@ -7,6 +7,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ErrorBoundary: typeof import('./components/ErrorBoundary.vue')['default']
Footer: typeof import('./components/Footer.vue')['default']
Markup: typeof import('./components/app/Markup.vue')['default']
Nav: typeof import('./components/Nav.vue')['default']

View File

@@ -0,0 +1,137 @@
<template>
<div v-if="hasError" class="error-boundary">
<v-container class="py-8">
<v-row justify="center">
<v-col cols="12" lg="6" md="8">
<v-card class="pa-6">
<v-card-title class="text-h5 mb-4">
<v-icon class="me-2" color="error">mdi-alert-circle</v-icon>
Something went wrong
</v-card-title>
<v-card-text>
<p class="mb-4">
An unexpected error occurred. We apologize for the
inconvenience.
</p>
<v-expansion-panels v-if="errorDetails" variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon class="me-2">mdi-bug</v-icon>
Error Details
</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="error-details">{{ errorDetails }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-card-actions>
<v-btn color="primary" @click="retry">
<v-icon start>mdi-refresh</v-icon>
Try Again
</v-btn>
<v-btn variant="text" @click="goHome">
<v-icon start>mdi-home</v-icon>
Go Home
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
<slot v-else />
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import { logger } from '@/utils/logger'
interface ErrorInfo {
message: string
stack?: string
componentStack?: string
}
const router = useRouter()
const hasError = ref(false)
const errorDetails = ref<string>('')
const handleError = (error: Error, errorInfo?: ErrorInfo) => {
hasError.value = true
const details = {
message: error.message,
stack: error.stack,
componentStack: errorInfo?.componentStack,
timestamp: new Date().toISOString(),
}
errorDetails.value = JSON.stringify(details, null, 2)
// Log error for debugging
logger.error('Error caught by boundary:', error)
logger.error('Error info:', errorInfo)
// You could send this to an error reporting service here
// reportError(error, details)
}
const retry = () => {
hasError.value = false
errorDetails.value = ''
// Force component re-render
nextTick(() => {
window.location.reload()
})
}
const goHome = () => {
hasError.value = false
errorDetails.value = ''
router.push('/')
}
// Error handling for unhandled errors
onErrorCaptured((error, instance, info) => {
handleError(error, {
message: error.message,
componentStack: info,
})
return false // Prevent error from propagating
})
// Global error handler
onMounted(() => {
window.addEventListener('error', event => {
handleError(event.error)
})
window.addEventListener('unhandledrejection', event => {
handleError(new Error(event.reason))
})
})
</script>
<style lang="scss" scoped>
.error-boundary {
min-height: 100vh;
display: flex;
align-items: center;
}
.error-details {
background-color: rgba(var(--v-theme-surface-variant), 0.1);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.875rem;
line-height: 1.4;
}
</style>

View File

@@ -1,16 +1,17 @@
<template>
<v-footer app height="40">
<div
class="text-caption text-disabled text-center w-100 d-flex justify-center text-center align-center"
style="gap: 5px; font-size: 0.8rem !important">
class="text-caption text-disabled text-center w-100 d-flex justify-center text-center align-center footer-content"
>
&copy; {{ new Date().getFullYear() }} |
<span class="d-none d-sm-inline-block">nachtigall.dev</span> |
<a
class="text-decoration-none text-caption text-disabled"
href="https://somegit.dev/nachtigall.dev/nachtigall.dev"
rel="noopener noreferrer"
target="_blank">
<i aria-hidden="true" class="fa fa-gitea" style="color: #609926; font-size: 1rem"></i>
target="_blank"
>
<i aria-hidden="true" class="fa fa-gitea footer-icon" />
</a>
</div>
</v-footer>
@@ -18,4 +19,12 @@
<script lang="ts" setup></script>
<style lang="sass" scoped></style>
<style lang="sass" scoped>
.footer-content
gap: 5px
font-size: 0.8rem
.footer-icon
color: #609926
font-size: 1rem
</style>

View File

@@ -1,16 +1,20 @@
<template>
<v-app-bar :elevation="5" scroll-behavior="hide">
<template v-if="mobile" v-slot:prepend>
<v-app-bar-nav-icon></v-app-bar-nav-icon>
<template v-if="mobile" #prepend>
<v-app-bar-nav-icon />
</template>
<v-list id="top-nav" class="d-flex ms-2" nav>
<v-list-item v-for="(route, index) in routesData" :key="index" class="mx-n1">
<v-list-item
v-for="(route, index) in navigationLinks"
:key="index"
class="mx-n1"
>
<RouterLink
class="text-white text-decoration-none font-weight-black text-uppercase nav-link"
:to="{ path: route.path }"
class="text-white text-decoration-none font-weight-black text-uppercase"
style="letter-spacing: 1px">
{{ route.name === '/' ? route.name : route.name.replace('/', '') }}
>
{{ route.displayLabel }}
</RouterLink>
</v-list-item>
</v-list>
@@ -20,12 +24,22 @@
<script lang="ts" setup>
import { useDisplay } from 'vuetify'
import { routes as routesData } from '@/data/routes'
import { mainNavigation } from '@/services/navigation'
const { mobile } = useDisplay()
const navigationLinks = computed(() =>
mainNavigation.map(item => ({
...item,
displayLabel: item.path === '/' ? item.path : item.path.replace('/', ''),
}))
)
</script>
<style lang="sass" scoped>
.nav-link
letter-spacing: 1px
.router-link-active
background: linear-gradient(270deg, #ff14e1, #1443ff, #ff1465)
-webkit-background-clip: text
@@ -37,7 +51,6 @@ const { mobile } = useDisplay()
-o-animation: AnimationName 4s ease infinite
animation: AnimationName 4s ease infinite
@-webkit-keyframes AnimationName
0%
background-position: 0% 50%

View File

@@ -16,7 +16,7 @@ The following example assumes a component located at `src/components/MyComponent
</template>
<script lang="ts" setup>
//
//
</script>
```
@@ -30,6 +30,6 @@ When your template is rendered, the component's import will automatically be inl
</template>
<script lang="ts" setup>
import MyComponent from '@/components/MyComponent.vue'
import MyComponent from '@/components/MyComponent.vue'
</script>
```

View File

@@ -1,18 +1,20 @@
<template>
<v-sheet
ref="root"
class="app-markup overflow-hidden"
:color="theme.name.value === 'light' ? 'surface-bright' : undefined"
dir="ltr"
:rounded="rounded"
:theme="theme.name.value === 'light' ? 'dark' : theme.name.value"
class="app-markup overflow-hidden"
dir="ltr">
>
<v-toolbar v-if="resource" class="px-1" height="44">
<v-sheet
v-if="resource"
class="text-body-2 px-3 pt-3 text-medium-emphasis"
color="transparent"
height="44"
rounded="tl">
rounded="tl"
>
<v-icon icon="mdi-file-tree" />
{{ resource }}
@@ -24,13 +26,13 @@
<v-fade-transition hide-on-leave>
<v-btn
:key="icon"
:icon="icon"
class="text-disabled me-3 mt-1 app-markup-btn"
density="comfortable"
style="position: absolute; right: 0; top: 0"
:icon="icon"
v-bind="activatorProps"
variant="text"
@click="copy" />
@click="copy"
/>
</v-fade-transition>
</template>
@@ -40,7 +42,7 @@
<div class="pa-4 pe-12">
<slot>
<pre v-if="inline" :class="className">
<code :class="className" v-html="highlighted"/>
<code :class="className" v-html="highlighted" />
</pre>
<code v-else :class="className" v-html="highlighted" />
@@ -52,66 +54,74 @@
<script lang="ts" setup>
import { useTheme } from 'vuetify'
// Styles
import Prism from 'prismjs'
import 'prismjs/themes/prism.css'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-css'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-json'
import 'prismjs/components/prism-sass'
import 'prismjs/components/prism-scss'
import 'prismjs/components/prism-typescript'
// Services
import { highlightCode } from '@/services/prism'
import { logger } from '@/utils/logger'
// Types
import type { ComponentPublicInstance } from 'vue'
const props = defineProps({
resource: String,
code: null,
inline: Boolean,
language: {
type: String,
default: 'markup'
},
rounded: {
type: Boolean,
default: true
}
})
interface MarkupProps {
readonly resource?: string
readonly code?: string | null
readonly inline?: boolean
readonly language?: string
readonly rounded?: boolean
}
// Transform inline links in typescript into actual links
Prism.languages.insertBefore('typescript', 'string', {
hyperlink: /<a.*?>(.*?)<\/a>/g
})
Prism.hooks.add('wrap', (env) => {
if (env.type === 'hyperlink' && env.tag !== 'a') {
env.tag = 'a'
env.content = env.content.replaceAll('&lt;', '<')
env.attributes.href = /href="(.*?)"/.exec(env.content)?.[1] || ''
env.attributes.target = '_blank'
env.content = stripLinks(env.content)[0]
}
const props = withDefaults(defineProps<MarkupProps>(), {
inline: false,
language: 'markup',
rounded: true,
})
const theme = useTheme()
const clicked = ref(false)
const root = ref<ComponentPublicInstance>()
const root = ref<ComponentPublicInstance | null>(null)
const highlighted = ref('')
let highlightRequestId = 0
watchEffect(async () => {
highlighted.value =
props.code &&
props.language &&
Prism.highlight(await props.code, Prism.languages[props.language], props.language)
highlightRequestId += 1
const currentRequestId = highlightRequestId
if (!props.code) {
highlighted.value = ''
return
}
try {
const result = await highlightCode(props.code, props.language)
if (currentRequestId === highlightRequestId) {
highlighted.value = result
}
} catch (error) {
logger.error('Failed to highlight code snippet', error)
if (currentRequestId === highlightRequestId) {
highlighted.value = props.code
}
}
})
const className = computed(() => `language-${props.language}`)
const icon = computed(() => (clicked.value ? 'mdi-check' : 'mdi-clipboard-text-outline'))
const icon = computed(() =>
clicked.value ? 'mdi-check' : 'mdi-clipboard-text-outline'
)
async function copy() {
async function copy(): Promise<void> {
const el = root.value?.$el.querySelector('code')
navigator.clipboard.writeText(props.code || el.textContent || '')
try {
await navigator.clipboard.writeText(props.code ?? el?.textContent ?? '')
} catch (error) {
logger.error('Failed to copy code snippet', error)
}
clicked.value = true
@@ -125,6 +135,11 @@ async function copy() {
.v-sheet.app-markup
position: relative
.app-markup-btn
position: absolute
right: 0
top: 0
&:not(:hover)
.app-markup-btn
opacity: 0 !important

View File

@@ -0,0 +1,59 @@
import { computed } from 'vue'
import {
componentCatalog,
createComponentTopicMap,
getComponentTopics,
} from '@/services/componentCatalog'
import {
componentNavigation,
groupNavigationByTopic,
type NavItem,
} from '@/services/navigation'
export interface ComponentSection<T> {
readonly topic: string
readonly label: string
readonly items: readonly T[]
}
const toTitleCase = (value: string) =>
value
.split(/[-_\s]/)
.filter(Boolean)
.map(segment => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ')
export function useComponentCatalog() {
const topics = computed(() => getComponentTopics())
// eslint-disable-next-line no-extra-parens
const groupedComponents = computed<
ComponentSection<(typeof componentCatalog)[number]>[]
>(() => {
const map = createComponentTopicMap()
return Array.from(map.entries()).map(([topic, items]) => ({
topic,
label: toTitleCase(topic),
items,
}))
})
const navigationSections = computed<ComponentSection<NavItem>[]>(() => {
const grouped = groupNavigationByTopic(componentNavigation)
return Object.entries(grouped).map(([topic, items]) => ({
topic,
label: toTitleCase(topic === 'overview' ? 'all components' : topic),
items,
}))
})
return {
catalog: computed(() => componentCatalog),
topics,
groupedComponents,
navigationSections,
}
}

View File

@@ -0,0 +1,82 @@
import { ref } from 'vue'
import { logger } from '@/utils/logger'
export interface ErrorState {
hasError: boolean
message: string
details?: string
timestamp?: Date
}
export interface UseErrorHandlerReturn {
error: Ref<ErrorState | null>
handleError: (error: Error, context?: string) => void
clearError: () => void
isLoading: Ref<boolean>
setLoading: (loading: boolean) => void
}
/**
* Composable for handling errors and loading states
*/
export function useErrorHandler(): UseErrorHandlerReturn {
const error = ref<ErrorState | null>(null)
const isLoading = ref(false)
const handleError = (err: Error, context?: string) => {
logger.error('Error occurred:', err, context ? `Context: ${context}` : '')
error.value = {
hasError: true,
message: err.message || 'An unexpected error occurred',
details: err.stack,
timestamp: new Date(),
}
isLoading.value = false
// You could integrate with error reporting service here
// reportError(err, context)
}
const clearError = () => {
error.value = null
}
const setLoading = (loading: boolean) => {
isLoading.value = loading
if (loading) {
clearError()
}
}
return {
error,
handleError,
clearError,
isLoading,
setLoading,
}
}
/**
* Global error handler for async operations
*/
export async function withErrorHandling<T>(
operation: () => Promise<T>,
errorHandler?: (error: Error) => void
): Promise<T | null> {
try {
return await operation()
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
if (errorHandler) {
errorHandler(err)
} else {
logger.error('Unhandled async error:', err)
}
return null
}
}

View File

@@ -1,16 +0,0 @@
export type Component = {
name: string
description: string
path: string
topic: string
}
export const componentsOverview = [
{
name: 'Tester',
description:
'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusamus, aspernatur dicta earum excepturi maxime officiis placeat provident quos repudiandae totam.',
path: '/components/tester',
topic: 'testing'
}
]

View File

@@ -1,29 +0,0 @@
export type Route = {
name: string
path: string
topic?: string
}
export const routes: Array<Route> = [
{
name: '/',
path: '/'
},
{
name: 'components/',
path: '/components'
}
]
export const componentRoutes: Array<Route> = [
{
name: 'All Components',
path: '/components',
topic: 'all-components'
},
{
name: 'Tester',
path: '/components/tester',
topic: 'testing'
}
]

View File

@@ -2,35 +2,41 @@
<v-app>
<Nav />
<v-navigation-drawer color="grey-darken-4" name="components-nav" permanent width="300">
<v-navigation-drawer
color="grey-darken-4"
name="components-nav"
permanent
width="300"
>
<div class="d-flex justify-start h-100">
<v-list id="components-nav" class="ms-4" width="100%">
<v-list-item
v-for="(route, index) in componentRoutes.filter(
(a) => a.topic && a.topic === 'all-components'
)"
:key="index">
<RouterLink
:to="{ path: route.path }"
class="text-white text-decoration-none font-weight-black"
style="letter-spacing: 1px; font-size: 1rem !important">
{{ route.name }}
</RouterLink>
</v-list-item>
<template v-if="overviewSection">
<v-list-item
v-for="route in overviewSection.items"
:key="route.path"
>
<RouterLink
class="text-white text-decoration-none font-weight-black components-nav-link"
:to="{ path: route.path }"
>
{{ route.label }}
</RouterLink>
</v-list-item>
</template>
<h4 class="text-uppercase ms-4" style="font-size: 0.8rem">Testing</h4>
<v-list-item
v-for="(route, index) in componentRoutes.filter(
(a) => a.topic && a.topic === 'testing'
)"
:key="index">
<RouterLink
:to="{ path: route.path }"
class="text-white text-decoration-none font-weight-black"
style="letter-spacing: 1px; font-size: 1rem !important">
{{ route.name }}
</RouterLink>
</v-list-item>
<template v-for="section in componentSections" :key="section.topic">
<h4 class="text-uppercase ms-4 components-nav-heading">
{{ section.label }}
</h4>
<v-list-item v-for="route in section.items" :key="route.path">
<RouterLink
class="text-white text-decoration-none font-weight-black components-nav-link"
:to="{ path: route.path }"
>
{{ route.label }}
</RouterLink>
</v-list-item>
</template>
<v-divider class="ms-n4" />
</v-list>
@@ -46,10 +52,27 @@
</template>
<script lang="ts" setup>
import { componentRoutes } from '@/data/routes'
import { useComponentCatalog } from '@/composables/useComponentCatalog'
const { navigationSections } = useComponentCatalog()
const overviewSection = computed(() =>
navigationSections.value.find(section => section.topic === 'overview')
)
const componentSections = computed(() =>
navigationSections.value.filter(section => section.topic !== 'overview')
)
</script>
<style lang="sass" scoped>
.components-nav-link
font-size: 1rem !important
letter-spacing: 1px
.components-nav-heading
font-size: 0.8rem
.router-link-active
background: linear-gradient(270deg, #ff14e1, #1443ff, #ff1465)
-webkit-background-clip: text
@@ -61,7 +84,6 @@ import { componentRoutes } from '@/data/routes'
-o-animation: AnimationName 4s ease infinite
animation: AnimationName 4s ease infinite
@-webkit-keyframes AnimationName
0%
background-position: 0% 50%

View File

@@ -5,13 +5,15 @@
*/
// Plugins
import {registerPlugins} from '@/plugins'
import { createApp } from 'vue'
// Components
import App from './App.vue'
import { registerPlugins } from '@/plugins'
// Components
// Composables
import {createApp} from 'vue'
const app = createApp(App)

View File

@@ -8,21 +8,23 @@
<template>
<v-container class="pa-12">
<v-row v-for="(topic, index) in getTopics()" :key="index" class="d-flex flex-column">
<h2 class="mb-4 text-uppercase">{{ topic }}</h2>
<v-col class="d-flex flex-wrap" style="gap: 2rem">
<v-hover
v-for="(component, index) in componentsOverview.filter((a) => a.topic === topic)"
:key="index">
<template v-slot:default="{ isHovering, props }">
<v-row
v-for="(section, index) in groupedComponents"
:key="section.topic"
class="d-flex flex-column"
>
<h2 class="mb-4 text-uppercase">{{ section.label }}</h2>
<v-col class="d-flex flex-wrap components-grid">
<v-hover v-for="component in section.items" :key="component.path">
<template #default="{ isHovering, props }">
<v-card
:class="isHovering ? 'hover-effect' : ''"
:to="component.path"
:class="['component-card', { 'hover-effect': isHovering }]"
rounded="shaped"
style="border: 2px solid rgba(255, 255, 255, 0.7); aspect-ratio: 3/2"
:to="component.path"
v-bind="props"
variant="outlined"
width="300">
width="300"
>
<v-card-title>
{{ component.name }}
</v-card-title>
@@ -37,20 +39,25 @@
</v-hover>
</v-col>
<v-divider v-if="index < getTopics().size - 1" class="my-6" />
<v-divider v-if="index < groupedComponents.length - 1" class="my-6" />
</v-row>
</v-container>
</template>
<script lang="ts" setup>
import { componentsOverview } from '@/data/componentsOverview'
import { useComponentCatalog } from '@/composables/useComponentCatalog'
const getTopics = (): Set<string> => {
return new Set(componentsOverview.map((comp) => comp.topic))
}
const { groupedComponents } = useComponentCatalog()
</script>
<style lang="sass" scoped>
.components-grid
gap: 2rem
.component-card
aspect-ratio: 3 / 2
border: 2px solid rgba(255, 255, 255, 0.7)
.hover-effect
box-shadow: 0 0 4px cyan, 0 0 8px cyan, 0 0 12px cyan, 0 0 16px cyan, 0 0 20px cyan
transition: box-shadow 0.1s ease

View File

@@ -17,7 +17,7 @@
<v-row>
<v-col>
<h5 class="text-h5">Beispiel</h5>
<div style="color: red" v-html="htmlCode" />
<div class="text-error" v-html="htmlCode" />
</v-col>
</v-row>

View File

@@ -5,12 +5,14 @@
*/
// Plugins
import vuetify from './vuetify'
import pinia from '../stores'
import type { App } from 'vue'
import router from '../router'
import pinia from '../stores'
import vuetify from './vuetify'
// Types
import type {App} from 'vue'
export function registerPlugins(app: App) {
app.use(vuetify).use(router).use(pinia)

View File

@@ -9,19 +9,19 @@ import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
// Composables
import {createVuetify} from 'vuetify'
import {aliases, mdi} from 'vuetify/iconsets/mdi'
import { createVuetify } from 'vuetify'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
defaultTheme: 'dark'
defaultTheme: 'dark',
},
icons: {
defaultSet: 'mdi',
aliases,
sets: {
mdi
}
}
mdi,
},
},
})

View File

@@ -5,9 +5,10 @@
*/
// Composables
import { createRouter, createWebHistory } from 'vue-router/auto'
import { setupLayouts } from 'virtual:generated-layouts'
import { createRouter, createWebHistory } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'
import { logger } from '@/utils/logger'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -18,14 +19,14 @@ const router = createRouter({
router.onError((err, to) => {
if (err?.message?.includes?.('Failed to fetch dynamically imported module')) {
if (!localStorage.getItem('vuetify:dynamic-reload')) {
console.log('Reloading page to fix dynamic import error')
logger.info('Reloading page to fix dynamic import error')
localStorage.setItem('vuetify:dynamic-reload', 'true')
location.assign(to.fullPath)
} else {
console.error('Dynamic import error, reloading page did not fix it', err)
logger.error('Dynamic import error, reloading page did not fix it', err)
}
} else {
console.error(err)
logger.error('Router error', err)
}
})

View File

@@ -0,0 +1,36 @@
export type ComponentTopic = 'testing'
export interface ComponentMeta {
readonly name: string
readonly description: string
readonly path: string
readonly topic: ComponentTopic
}
export const componentCatalog: readonly ComponentMeta[] = [
{
name: 'Tester',
description:
'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusamus, aspernatur dicta earum excepturi maxime officiis placeat provident quos repudiandae totam.',
path: '/components/tester',
topic: 'testing',
},
]
export function getComponentTopics(
catalog: readonly ComponentMeta[] = componentCatalog
) {
return Array.from(new Set(catalog.map(component => component.topic)))
}
export function createComponentTopicMap(
catalog: readonly ComponentMeta[] = componentCatalog
): Map<ComponentTopic, ComponentMeta[]> {
return catalog.reduce((map, component) => {
const entries = map.get(component.topic) ?? []
entries.push(component)
map.set(component.topic, entries)
return map
}, new Map<ComponentTopic, ComponentMeta[]>())
}

View File

@@ -0,0 +1,36 @@
import type { ComponentMeta, ComponentTopic } from './componentCatalog'
import { componentCatalog } from './componentCatalog'
export interface NavItem {
readonly label: string
readonly path: string
readonly topic?: ComponentTopic | 'overview'
}
export const mainNavigation: readonly NavItem[] = [
{ label: 'Home', path: '/' },
{ label: 'Components', path: '/components' },
]
export const componentNavigation: readonly NavItem[] = [
{ label: 'All Components', path: '/components', topic: 'overview' },
...componentCatalog.map<NavItem>((component: ComponentMeta) => ({
label: component.name,
path: component.path,
topic: component.topic,
})),
]
export function groupNavigationByTopic(items: readonly NavItem[]) {
return items.reduce<Record<string, NavItem[]>>((accumulator, item) => {
if (!item.topic) {
return accumulator
}
const existing = accumulator[item.topic] ?? []
existing.push(item)
accumulator[item.topic] = existing
return accumulator
}, {})
}

59
src/services/prism.ts Normal file
View File

@@ -0,0 +1,59 @@
import type PrismType from 'prismjs'
import { stripLinks } from '@/utils/api'
let prismPromise: Promise<typeof PrismType> | null = null
let hyperlinkHookRegistered = false
const languageLoaders: ReadonlyArray<() => Promise<unknown>> = [
() => import('prismjs/components/prism-bash'),
() => import('prismjs/components/prism-css'),
() => import('prismjs/components/prism-javascript'),
() => import('prismjs/components/prism-json'),
() => import('prismjs/components/prism-sass'),
() => import('prismjs/components/prism-scss'),
() => import('prismjs/components/prism-typescript'),
]
async function loadPrism(): Promise<typeof PrismType> {
if (!prismPromise) {
prismPromise = import('prismjs').then(async module => {
await Promise.all(languageLoaders.map(loader => loader()))
const prism: typeof PrismType =
module.default ?? (module as unknown as typeof PrismType)
if (!hyperlinkHookRegistered) {
registerHyperlinkHook(prism)
hyperlinkHookRegistered = true
}
return prism
})
}
return prismPromise
}
function registerHyperlinkHook(prism: typeof PrismType) {
prism.languages.insertBefore('typescript', 'string', {
hyperlink: /<a.*?>(.*?)<\/a>/g,
})
prism.hooks.add('wrap', env => {
if (env.type === 'hyperlink' && env.tag !== 'a') {
env.tag = 'a'
env.content = env.content.replaceAll('&lt;', '<')
env.attributes.href = /href="(.*?)"/.exec(env.content)?.[1] ?? ''
env.attributes.target = '_blank'
env.content = stripLinks(env.content)[0]
}
})
}
export async function highlightCode(code: string, language: string) {
const prism = await loadPrism()
const grammar = prism.languages[language] ?? prism.languages.markup
return prism.highlight(code, grammar, language)
}

34
src/styles/main.scss Normal file
View File

@@ -0,0 +1,34 @@
.app-link {
letter-spacing: 1px;
transition: color 0.2s ease;
}
.router-link-active.app-link {
background: linear-gradient(270deg, #ff14e1, #1443ff, #ff1465);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 200% 200%;
animation: app-link-gradient 4s ease infinite;
}
@keyframes app-link-gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.app-icon {
color: #609926;
font-size: 1rem;
}
.footer-meta {
gap: 5px;
font-size: 0.8rem;
}

4
src/types/prism.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module 'prismjs/components/*' {
const language: unknown
export default language
}

View File

@@ -1,26 +1,137 @@
export type Item = {
name: string
source: string
type?: string | string[]
anyOf: Item[]
enum?: string[]
parameters?: Item[]
returnType?: Item
default: any
description: Record<string, string>
descriptionSource?: Record<string, string>
snippet: string
value: any
example: string
props: unknown
$ref?: string
properties?: Record<string, Item>
items?: Item | Item[]
minItems?: number
maxItems?: number
allOf?: Item[]
export interface Item {
readonly name: string
readonly source: string
readonly type?: string | readonly string[]
readonly anyOf: readonly Item[]
readonly enum?: readonly string[]
readonly parameters?: readonly Item[]
readonly returnType?: Item
readonly default: unknown
readonly description: Record<string, string>
readonly descriptionSource?: Record<string, string>
readonly snippet: string
readonly value: unknown
readonly example: string
readonly props: unknown
readonly $ref?: string
readonly properties?: Record<string, Item>
readonly items?: Item | readonly Item[]
readonly minItems?: number
readonly maxItems?: number
readonly allOf?: readonly Item[]
}
export interface ApiResponse<T = unknown> {
readonly data: T
readonly status: number
readonly statusText: string
readonly headers: Record<string, string>
}
export interface ApiError {
readonly message: string
readonly status?: number
readonly statusText?: string
readonly code?: string
}
export class ApiException extends Error implements ApiError {
readonly status?: number
readonly statusText?: string
readonly code?: string
constructor(
message: string,
status?: number,
statusText?: string,
code?: string
) {
super(message)
this.name = 'ApiException'
this.status = status
this.statusText = statusText
this.code = code
}
}
export interface FetchOptions extends RequestInit {
readonly baseURL?: string
readonly timeout?: number
readonly retries?: number
readonly retryDelay?: number
}
/**
* Enhanced fetch utility with error handling, retries, and timeout
*/
export async function apiRequest<T = unknown>(
url: string,
options: FetchOptions = {}
): Promise<ApiResponse<T>> {
const {
baseURL = '',
timeout = 10000,
retries = 3,
retryDelay = 1000,
...fetchOptions
} = options
const fullUrl = baseURL ? `${baseURL}${url}` : url
let lastError: Error = new Error('Unknown error')
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
const response = await fetch(fullUrl, {
...fetchOptions,
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new ApiException(
`Request failed: ${response.statusText}`,
response.status,
response.statusText
)
}
const data = await response.json()
const headers: Record<string, string> = {}
response.headers.forEach((value, key) => {
headers[key] = value
})
return {
data,
status: response.status,
statusText: response.statusText,
headers,
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
if (attempt < retries) {
await new Promise(resolve => setTimeout(resolve, retryDelay))
continue
}
throw lastError instanceof ApiException
? lastError
: new ApiException(lastError.message)
}
}
throw lastError
}
/**
* Utility function to strip HTML links from string
*/
export function stripLinks(str: string): [string, Record<string, string>] {
let out = str.slice()
const obj: Record<string, string> = {}
@@ -38,9 +149,19 @@ export function stripLinks(str: string): [string, Record<string, string>] {
return [out, obj]
}
export function insertLinks(str: string, stripped: Record<string, string>) {
/**
* Utility function to insert HTML links back into string
*/
export function insertLinks(
str: string,
stripped: Record<string, string>
): string {
let result = str
for (const [key, value] of Object.entries(stripped)) {
str = str.replaceAll(new RegExp(`(^|\\W)(${key})(\\W|$)`, 'g'), `$1${value}$3`)
result = result.replaceAll(
new RegExp(`(^|\\W)(${key})(\\W|$)`, 'g'),
`$1${value}$3`
)
}
return str
return result
}

View File

@@ -1,3 +1,5 @@
export const wait = (timeout: number) => {
return new Promise((resolve) => setTimeout(resolve, timeout))
export const wait = (timeout: number): Promise<void> => {
return new Promise(resolve => {
window.setTimeout(() => resolve(), timeout)
})
}

40
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,40 @@
export interface Logger {
error: (message: string, ...args: unknown[]) => void
warn: (message: string, ...args: unknown[]) => void
info: (message: string, ...args: unknown[]) => void
debug: (message: string, ...args: unknown[]) => void
}
const createLogger = (): Logger => {
const isDevelopment = import.meta.env.DEV
return {
error: (message: string, ...args: unknown[]) => {
if (isDevelopment) {
// eslint-disable-next-line no-console
console.error(`[ERROR] ${message}`, ...args)
}
// In production, you could send to error reporting service
},
warn: (message: string, ...args: unknown[]) => {
if (isDevelopment) {
// eslint-disable-next-line no-console
console.warn(`[WARN] ${message}`, ...args)
}
},
info: (message: string, ...args: unknown[]) => {
if (isDevelopment) {
// eslint-disable-next-line no-console
console.info(`[INFO] ${message}`, ...args)
}
},
debug: (message: string, ...args: unknown[]) => {
if (isDevelopment) {
// eslint-disable-next-line no-console
console.log(`[DEBUG] ${message}`, ...args)
}
},
}
}
export const logger = createLogger()

View File

@@ -24,10 +24,7 @@
"isolatedModules": true,
"skipLibCheck": true
},
"include": [
"src/**/*",
"src/**/*.vue"
],
"include": ["src/**/*", "src/**/*.vue", "vitest.config.ts"],
"exclude": ["dist", "node_modules", "cypress"],
"references": [{ "path": "./tsconfig.node.json" }],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,72 +1,82 @@
// Plugins
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import Fonts from 'unplugin-fonts/vite'
import Layouts from 'vite-plugin-vue-layouts'
import { fileURLToPath, URL } from 'node:url'
import Vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
import AutoImport from 'unplugin-auto-import/vite'
import Fonts from 'unplugin-fonts/vite'
import Components from 'unplugin-vue-components/vite'
import VueRouter from 'unplugin-vue-router/vite'
import { defineConfig } from 'vite'
import Layouts from 'vite-plugin-vue-layouts'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
// Utilities
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
VueRouter({
dts: 'src/typed-router.d.ts'
dts: 'src/typed-router.d.ts',
}),
Layouts({
layoutsDirs: 'src/layouts',
pagesDirs: 'src/pages',
defaultLayout: 'default'
defaultLayout: 'default',
}),
AutoImport({
imports: [
'vue',
{
'vue-router/auto': ['useRoute', 'useRouter']
}
'vue-router/auto': ['useRoute', 'useRouter'],
},
],
dts: 'src/auto-imports.d.ts',
eslintrc: {
enabled: true
enabled: true,
},
vueTemplate: true
vueTemplate: true,
}),
Components({
dts: 'src/components.d.ts'
dts: 'src/components.d.ts',
}),
Vue({
template: { transformAssetUrls }
template: { transformAssetUrls },
}),
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
Vuetify({
autoImport: true,
styles: {
configFile: 'src/styles/settings.scss'
}
configFile: 'src/styles/settings.scss',
},
}),
Fonts({
google: {
families: [
{
name: 'Roboto',
styles: 'wght@100;300;400;500;700;900'
}
]
}
})
],
styles: 'wght@100;300;400;500;700;900',
},
],
},
}),
// Bundle analyzer - only run in analyze mode
process.env.ANALYZE &&
visualizer({
filename: 'dist/stats.html',
open: true,
brotliSize: true,
gzipSize: true,
}),
].filter(Boolean),
define: { 'process.env': {} },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
},
server: {
port: 3000
}
port: 3000,
},
})

31
vitest.config.ts Normal file
View File

@@ -0,0 +1,31 @@
/// <reference types="vitest" />
import { fileURLToPath, URL } from 'node:url'
import Vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [Vue()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'src/auto-imports.d.ts',
'src/components.d.ts',
'src/typed-router.d.ts',
],
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})

2089
yarn.lock

File diff suppressed because it is too large Load Diff