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:
33
.eslintrc.cjs
Normal file
33
.eslintrc.cjs
Normal 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
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
14
.prettierignore
Normal file
14
.prettierignore
Normal 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
15
.prettierrc
Normal 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"
|
||||
}
|
||||
@@ -13,10 +13,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
",*.html",
|
||||
"legacy/**/*.js"
|
||||
],
|
||||
"files": [",*.html", "legacy/**/*.js"],
|
||||
"options": {
|
||||
"tabWidth": 4
|
||||
}
|
||||
|
||||
28
AGENTS.md
Normal file
28
AGENTS.md
Normal 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.
|
||||
38
README.md
38
README.md
@@ -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.
|
||||
|
||||
30
index.html
30
index.html
@@ -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
8563
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
1
src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
137
src/components/ErrorBoundary.vue
Normal file
137
src/components/ErrorBoundary.vue
Normal 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>
|
||||
@@ -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"
|
||||
>
|
||||
© {{ 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>
|
||||
|
||||
@@ -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%
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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('<', '<')
|
||||
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
|
||||
|
||||
59
src/composables/useComponentCatalog.ts
Normal file
59
src/composables/useComponentCatalog.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
82
src/composables/useErrorHandler.ts
Normal file
82
src/composables/useErrorHandler.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
@@ -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%
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
36
src/services/componentCatalog.ts
Normal file
36
src/services/componentCatalog.ts
Normal 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[]>())
|
||||
}
|
||||
36
src/services/navigation.ts
Normal file
36
src/services/navigation.ts
Normal 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
59
src/services/prism.ts
Normal 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('<', '<')
|
||||
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
34
src/styles/main.scss
Normal 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
4
src/types/prism.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'prismjs/components/*' {
|
||||
const language: unknown
|
||||
export default language
|
||||
}
|
||||
169
src/utils/api.ts
169
src/utils/api.ts
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
40
src/utils/logger.ts
Normal 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()
|
||||
@@ -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" }]
|
||||
}
|
||||
|
||||
@@ -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
31
vitest.config.ts
Normal 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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user