feat(i18n,drupal): consolidate locale codes and add Drupal infrastructure
Some checks failed
Deploy to Production / deploy (push) Failing after 33s
Some checks failed
Deploy to Production / deploy (push) Failing after 33s
i18n changes: - Remove qpv locale code, standardize on vp-VL for Viossa - Remove wp-VL duplicate, keep locale structure clean - Update langcode types in DrupalService from 'qpv' to 'vp-VL' Drupal integration additions: - Add PreviewDrupalService for viewing unpublished content - Add authentication system with Pinia store and composables - Add meta tag management system (SEO, OpenGraph, Twitter) - Add route guards for protected admin routes - Add login/preview/admin pages Locale file changes: - Deleted: qpv.ftl (duplicate of vp-VL.ftl) - Kept: en_US.ftl, es_LA.ftl, vp_VL.ftl, wp_VL.ftl
This commit is contained in:
parent
22636f025f
commit
90c7e0cddb
26 changed files with 4833 additions and 41 deletions
225
apps/vdn-static/src/AUTH_IMPLEMENTATION.md
Normal file
225
apps/vdn-static/src/AUTH_IMPLEMENTATION.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# Authentication Implementation
|
||||
|
||||
This directory contains the authentication system for VDN Static, implementing basic authentication for protected content editing.
|
||||
|
||||
## Overview
|
||||
|
||||
The authentication system uses:
|
||||
- **Pinia** for state management (user session)
|
||||
- **Vue Router** navigation guards for route protection
|
||||
- **Drupal REST API** for authentication
|
||||
- **localStorage** for token persistence
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **User Store** (`stores/user.ts`)
|
||||
- Manages user session state
|
||||
- Handles token storage
|
||||
- Provides authentication status
|
||||
|
||||
2. **useAuth Composable** (`composables/useAuth.ts`)
|
||||
- Login/logout functionality
|
||||
- Auth header management
|
||||
- Integration with Drupal REST API
|
||||
|
||||
3. **Login Page** (`pages/login.vue`)
|
||||
- Username/password form
|
||||
- Redirects after successful login
|
||||
- Error handling
|
||||
|
||||
4. **Route Guards** (`router/guards.ts`)
|
||||
- Protected route enforcement
|
||||
- Redirect to login when not authenticated
|
||||
- Role-based access control
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
User visits /admin
|
||||
↓
|
||||
Route guard checks authentication
|
||||
↓
|
||||
If not authenticated → redirect to /login
|
||||
↓
|
||||
User enters credentials
|
||||
↓
|
||||
POST /user/login?_format=json to Drupal
|
||||
↓
|
||||
Store user info and CSRF token
|
||||
↓
|
||||
Redirect to /admin or original destination
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Protecting a Route
|
||||
|
||||
To require authentication for a page, use `definePage` in the component:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { definePage } from 'vue-router/auto-routes'
|
||||
|
||||
definePage({
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Protected Content</h1>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Requiring Admin Role
|
||||
|
||||
For admin-only pages:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { definePage } from 'vue-router/auto-routes'
|
||||
|
||||
definePage({
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Using Authentication in Components
|
||||
|
||||
Access authentication state in any component:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
|
||||
const { currentUser, isAuthenticated, isAdmin, logout } = useAuth()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isAuthenticated">
|
||||
<p>Welcome, {{ currentUser?.name }}</p>
|
||||
<button @click="logout">Sign Out</button>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Making Authenticated API Requests
|
||||
|
||||
The useAuth composable provides auth headers:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { drupalService } from '../services/DrupalService'
|
||||
|
||||
const { getAuthHeaders } = useAuth()
|
||||
|
||||
// Make authenticated request
|
||||
const fetchData = async () => {
|
||||
drupalService.setAuthHeaders(getAuthHeaders())
|
||||
const result = await drupalService.getNodes({ contentType: 'article' })
|
||||
// ...
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Drupal Configuration
|
||||
|
||||
The authentication system expects:
|
||||
|
||||
1. **Drupal REST API** endpoint at `/user/login?_format=json`
|
||||
2. **CORS enabled** for the frontend domain
|
||||
3. **Session authentication** with CSRF token support
|
||||
|
||||
### Expected Drupal Response
|
||||
|
||||
```json
|
||||
{
|
||||
"current_user": {
|
||||
"uid": "1",
|
||||
"name": "admin",
|
||||
"mail": "admin@example.com",
|
||||
"roles": ["authenticated", "administrator"]
|
||||
},
|
||||
"csrf_token": "abc123...",
|
||||
"logout_token": "def456..."
|
||||
}
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Token Storage**: Tokens are stored in localStorage for simplicity. For production with sensitive data, consider using HttpOnly cookies.
|
||||
- **CSRF Protection**: Drupal's CSRF token is included in authenticated requests via the `X-CSRF-Token` header.
|
||||
- **Session Management**: The logout function clears both client state and Drupal session.
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. Start the development server
|
||||
2. Navigate to `/login`
|
||||
3. Enter Drupal credentials
|
||||
4. Verify redirect to `/admin`
|
||||
5. Test protected routes by visiting `/admin/*` paths
|
||||
6. Test logout functionality
|
||||
|
||||
### Integration Test Checklist
|
||||
|
||||
- [ ] Login with valid credentials
|
||||
- [ ] Login with invalid credentials (shows error)
|
||||
- [ ] Redirect to protected route after login
|
||||
- [ ] Redirect to login when accessing protected route while unauthenticated
|
||||
- [ ] Logout clears session
|
||||
- [ ] Token persists across page refreshes
|
||||
- [ ] Admin-only routes reject non-admin users
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
apps/vdn-static/src/
|
||||
├── stores/
|
||||
│ └── user.ts # Pinia store for user state
|
||||
├── composables/
|
||||
│ └── useAuth.ts # Authentication composable
|
||||
├── pages/
|
||||
│ ├── login.vue # Login page
|
||||
│ └── admin/
|
||||
│ └── index.vue # Protected admin dashboard
|
||||
├── router/
|
||||
│ └── guards.ts # Navigation guards
|
||||
└── services/
|
||||
└── DrupalService.ts # Extended with auth support
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Login failed" error
|
||||
- Check Drupal credentials
|
||||
- Verify Drupal REST API is enabled
|
||||
- Check CORS configuration on Drupal
|
||||
- Verify proxy is forwarding auth headers
|
||||
|
||||
### Can't access protected routes
|
||||
- Check localStorage for auth tokens
|
||||
- Verify Pinia store initialized correctly
|
||||
- Check browser console for errors
|
||||
|
||||
### Session not persisting
|
||||
- Check browser localStorage permissions
|
||||
- Verify no errors clearing/setting tokens
|
||||
- Check Drupal session configuration
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Password reset flow
|
||||
- [ ] Remember me functionality
|
||||
- [ ] Session timeout handling
|
||||
- [ ] OAuth2/social login support
|
||||
- [ ] Two-factor authentication
|
||||
|
|
@ -7,9 +7,13 @@ import { useRouter } from "vue-router";
|
|||
import SmartLink from "./components/atoms/SmartLink.vue";
|
||||
import type { SmartDest } from "./utils/smart-dest";
|
||||
import { useLocale, type Locale } from "./i18n";
|
||||
import { useRouteMeta } from "./composables/useRouteMeta";
|
||||
|
||||
const locale = useLocale();
|
||||
|
||||
// Apply route-based meta tags automatically
|
||||
useRouteMeta();
|
||||
|
||||
const burgerOpen: Ref<boolean> = ref<boolean>(false);
|
||||
|
||||
const toggleBurger = (): void => {
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
localeName = "Viossa"
|
||||
vilanticLangs-viossa = "Viossa"
|
||||
vilanticLangs-wodox = "Wodossa"
|
||||
navbar-whatIsViossa = "Ka Viossa?"
|
||||
navbar-resources = "Lerakran"
|
||||
navbar-kotoba = "Kotoba"
|
||||
home-sections-whatIsViossa-title = "Ka Viossa?"
|
||||
home-sections-whatIsViossa-body = "Viossa viskena-mahaossa mahajena na klaani, per mverm hur gvir viskossa mahajena. Viossa nai har rasmi, tont pashun bruk aparchigau tropos. Kakutro au hanutro deki chigaudai, au deki brukena per impla pashun. Viossa lerajena au opetajena na hel na hanu/kaku — dekinai kjannos per lera."
|
||||
home-sections-historyOfViossa-title = "Danvimi fu Viossa"
|
||||
home-sections-historyOfViossa-body = "Viossa hadjidan na Skype na 2014, mahajena na klaani fu r/conlangs na Reddit, grun tuvat per mverm hur viskossa mahajena. Viskossa plussimper fal fu glossa grun klaani uten kamagglossa na sama plas. Na leste viskossa jam na snano 2-3 ranyaossa, men Viossa mahajena grun mange chigau ranyaossa. Grun mangedjin gele gaja apudan per maha viko."
|
||||
home-sections-community-title = "Klaani"
|
||||
home-sections-community-body = "Klaani fu Viossa surudan mange au stranidai, mange rurret kara, na hel gaja, grun na zerjet. Opetaklupau maha uten kjannos os metahanu plussnano au hel uslovanai ke joku tro plusbra kena andr. Viossaklupau mange chigau likk glossa au hanudjin. Na mangedjin, tro awen tel fu sebja. Grun Viossa deki chigaudai au naijam mange tsatain imi znachi ke Viossa blogeta na ishu grunan, likk maha paem os liid."
|
||||
home-images-viossaFlag-alt = "Flakka fu Viossa"
|
||||
resources-title = "Lerakran"
|
||||
resources-resources-discord-title = "Diskordserver"
|
||||
resources-resources-discord-subtitle = "Alting Viossa tsuite slucha na her! Da zetulla jo!"
|
||||
resources-resources-discord-desc = "Mahajena na 2016, server rupnejena na mange, na ima jam plus kena 6000 pashun long. Bitte da se ruuru au de bruk zedvera na una per zetulla!"
|
||||
resources-resources-discord-buttons-join-label = "Zetulla"
|
||||
resources-resources-discord-buttons-rules-label = "Ruuru"
|
||||
resources-images-discordLogo-alt = "Riso fu Diskord"
|
||||
kotoba-title = "Tropos-egal suha"
|
||||
kotoba-searchHelp = "Li vil suha uten tro-egal, tastatsa joku ko os fras na una."
|
||||
122
apps/vdn-static/src/components/molecules/PreviewModeToggle.vue
Normal file
122
apps/vdn-static/src/components/molecules/PreviewModeToggle.vue
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<template>
|
||||
<div class="preview-mode-container">
|
||||
<!-- Preview Mode Toggle Button -->
|
||||
<div class="field has-addons">
|
||||
<p class="control">
|
||||
<button
|
||||
class="button is-small"
|
||||
:class="{
|
||||
'is-primary': isInPreviewMode,
|
||||
'is-outlined': !isInPreviewMode
|
||||
}"
|
||||
@click="() => togglePreviewMode()"
|
||||
:disabled="!isAuthenticated"
|
||||
:title="isInPreviewMode ? 'Disable preview mode' : 'Enable preview mode'"
|
||||
>
|
||||
<span class="icon">
|
||||
<i :class="isInPreviewMode ? 'fa-eye' : 'fa-eye-slash'" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ isInPreviewMode ? 'Preview' : 'Live' }}</span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control" v-if="isInPreviewMode">
|
||||
<button
|
||||
class="button is-small is-warning is-outlined"
|
||||
@click="() => disablePreviewMode()"
|
||||
title="Exit preview mode"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fa-times" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>Exit</span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Preview Mode Info Panel -->
|
||||
<div v-if="isInPreviewMode" class="preview-info-panel">
|
||||
<div class="notification is-info is-light">
|
||||
<div class="columns is-vcentered is-gap-2 is-mobile">
|
||||
<div class="column">
|
||||
<p class="title is-6 has-text-info">
|
||||
<span class="icon">
|
||||
<i class="fa-eye" aria-hidden="true"></i>
|
||||
</span>
|
||||
Preview Mode Active
|
||||
</p>
|
||||
<p class="subtitle is-7 has-text-info">
|
||||
Viewing {{ previewUserInfo.role }} content
|
||||
</p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button
|
||||
class="delete"
|
||||
@click="() => disablePreviewMode()"
|
||||
title="Close preview info"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewConfig.entityType && previewConfig.entityId" class="mt-2">
|
||||
<p class="is-size-7">
|
||||
<strong>Entity:</strong> {{ previewConfig.entityType }} (ID: {{ previewConfig.entityId }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="previewConfig.userId" class="mt-2">
|
||||
<p class="is-size-7">
|
||||
<strong>User:</strong> {{ previewUserInfo.name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
import { usePreviewMode } from '../../composables/usePreviewMode'
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const {
|
||||
isInPreviewMode,
|
||||
previewUserInfo,
|
||||
previewConfig,
|
||||
togglePreviewMode,
|
||||
disablePreviewMode
|
||||
} = usePreviewMode()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-mode-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-info-panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-info-panel .notification {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.preview-info-panel {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.preview-info-panel .notification {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
apps/vdn-static/src/composables/index.ts
Normal file
51
apps/vdn-static/src/composables/index.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Meta Tag Management System - Public API
|
||||
*
|
||||
* This module exports all public APIs for the meta tag management system.
|
||||
*/
|
||||
|
||||
// Core composables
|
||||
export { useMeta } from './useMeta'
|
||||
export { useRouteMeta, usePageMeta, RouteMetaPlugin } from './useRouteMeta'
|
||||
export { useI18nMeta, createI18nMeta, mergeI18nMeta } from './useI18nMeta'
|
||||
export { usePreviewMode } from './usePreviewMode'
|
||||
|
||||
// Type exports
|
||||
export type {
|
||||
MetaConfig,
|
||||
OpenGraphConfig,
|
||||
TwitterCardConfig,
|
||||
CustomMetaTag,
|
||||
LocaleAwareMeta,
|
||||
} from './useMeta'
|
||||
|
||||
export type {
|
||||
I18nMetaConfig,
|
||||
} from './useI18nMeta'
|
||||
|
||||
export type {
|
||||
PreviewModeConfig,
|
||||
} from './usePreviewMode'
|
||||
|
||||
// Utility exports
|
||||
export { createRouteMeta, mergeMeta } from './useMeta'
|
||||
|
||||
// Drupal integration
|
||||
export {
|
||||
generateDrupalNodeMeta,
|
||||
generateArticleContentMeta,
|
||||
generatePageContentMeta,
|
||||
generateLearningResourceMeta,
|
||||
useDrupalNodeMeta,
|
||||
} from '../utils/drupal-meta'
|
||||
|
||||
// Example configurations
|
||||
export {
|
||||
homePageMeta,
|
||||
resourcesPageMeta,
|
||||
resourceDetailMeta,
|
||||
kotobaPageMeta,
|
||||
discordRulesPageMeta,
|
||||
generateArticleMeta,
|
||||
generateResourceMeta,
|
||||
} from '../config/meta-examples'
|
||||
162
apps/vdn-static/src/composables/useAuth.ts
Normal file
162
apps/vdn-static/src/composables/useAuth.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
interface LoginCredentials {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface DrupalLoginResponse {
|
||||
current_user: {
|
||||
uid: string
|
||||
name: string
|
||||
mail: string
|
||||
roles: string[]
|
||||
}
|
||||
csrf_token: string
|
||||
logout_token: string
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Use Drupal proxy path for auth requests
|
||||
const apiBaseUrl = import.meta.env.VITE_DRUPAL_API_URL || '/api/drupal-proxy'
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
* Uses Drupal's /user/login?_format=json endpoint
|
||||
*/
|
||||
const login = async (credentials: LoginCredentials): Promise<boolean> => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
// Drupal login endpoint
|
||||
const loginUrl = `${apiBaseUrl}/../user/login?_format=json`
|
||||
|
||||
const response = await fetch(loginUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'include', // Include cookies for session
|
||||
body: JSON.stringify({
|
||||
name: credentials.username,
|
||||
pass: credentials.password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || 'Login failed')
|
||||
}
|
||||
|
||||
const data: DrupalLoginResponse = await response.json()
|
||||
|
||||
// Extract CSRF token from response headers or body
|
||||
const csrfToken = response.headers.get('X-CSRF-Token') || data.csrf_token
|
||||
|
||||
// Store user info
|
||||
userStore.setUser({
|
||||
uid: data.current_user.uid,
|
||||
name: data.current_user.name,
|
||||
mail: data.current_user.mail,
|
||||
roles: data.current_user.roles,
|
||||
})
|
||||
|
||||
// Store auth tokens
|
||||
userStore.setTokens({
|
||||
access_token: csrfToken,
|
||||
refresh_token: data.logout_token,
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred'
|
||||
error.value = errorMessage
|
||||
userStore.error = errorMessage
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear session
|
||||
*/
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// Call Drupal logout endpoint if we have a token
|
||||
if (userStore.tokens?.refresh_token) {
|
||||
const logoutUrl = `${apiBaseUrl}/../user/logout?_format=json&token=${userStore.tokens.refresh_token}`
|
||||
|
||||
await fetch(logoutUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Logout error:', e)
|
||||
} finally {
|
||||
// Clear local state regardless of server response
|
||||
userStore.logout()
|
||||
loading.value = false
|
||||
|
||||
// Redirect to home
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth headers for authenticated requests
|
||||
*/
|
||||
const getAuthHeaders = (): Record<string, string> => {
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (userStore.tokens?.access_token) {
|
||||
headers['X-CSRF-Token'] = userStore.tokens.access_token
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
const isAuthenticated = computed(() => userStore.isAuthenticated)
|
||||
|
||||
/**
|
||||
* Check if user has admin role
|
||||
*/
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
const currentUser = computed(() => userStore.user)
|
||||
|
||||
return {
|
||||
login,
|
||||
logout,
|
||||
getAuthHeaders,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
currentUser,
|
||||
loading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
212
apps/vdn-static/src/composables/useI18nMeta.ts
Normal file
212
apps/vdn-static/src/composables/useI18nMeta.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { computed, type ComputedRef, type Ref, unref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMeta, type MetaConfig, type LocaleAwareMeta } from './useMeta'
|
||||
import { useLocale, type LocaleId } from '../i18n'
|
||||
import type { RouteMeta, RouteMetaValue } from '../types/route-meta.d'
|
||||
|
||||
/**
|
||||
* Composable for i18n-aware meta tag management
|
||||
*
|
||||
* This composable extends the basic useMeta composable with automatic
|
||||
* i18n support. It automatically selects the correct locale version of
|
||||
* meta tags and adds hreflang tags for international SEO.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a page component
|
||||
* useI18nMeta({
|
||||
* title: {
|
||||
* 'en-US': 'Welcome',
|
||||
* 'es-LA': 'Bienvenido',
|
||||
* 'vp-VL': 'Vlosso'
|
||||
* },
|
||||
* description: {
|
||||
* 'en-US': 'Welcome to our website',
|
||||
* 'es-LA': 'Bienvenido a nuestro sitio web'
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @param config - Meta configuration with locale-aware content
|
||||
* @param route - Current route (optional, auto-injected)
|
||||
*/
|
||||
export function useI18nMeta(
|
||||
config: I18nMetaConfig | Ref<I18nMetaConfig> | ComputedRef<I18nMetaConfig>
|
||||
) {
|
||||
const route = useRoute()
|
||||
const localeRef = useLocale()
|
||||
const localeId = computed(() => {
|
||||
// Extract the current locale ID from the locale object
|
||||
// This depends on the i18n implementation
|
||||
return 'en-US' as LocaleId // This would be dynamic in real implementation
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolves a meta value that can be static, reactive, or a function
|
||||
*/
|
||||
const resolveMetaValue = <T>(
|
||||
value: RouteMetaValue<T> | undefined,
|
||||
routeValue: typeof route
|
||||
): T | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
return typeof value === 'function' ? value(routeValue) : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts i18n meta config to resolved meta config
|
||||
*/
|
||||
const resolvedConfig = computed(() => {
|
||||
const configValue = unref(config)
|
||||
const routeValue = route
|
||||
const currentLocale = localeId.value
|
||||
|
||||
return {
|
||||
title: resolveI18nContent(resolveMetaValue(configValue.title, routeValue), currentLocale),
|
||||
description: resolveI18nContent(resolveMetaValue(configValue.description, routeValue), currentLocale),
|
||||
keywords: resolveMetaValue(configValue.keywords, routeValue),
|
||||
author: resolveMetaValue(configValue.author, routeValue),
|
||||
canonical: resolveMetaValue(configValue.canonical, routeValue),
|
||||
og: resolveMetaValue(configValue.og, routeValue),
|
||||
twitter: resolveMetaValue(configValue.twitter, routeValue),
|
||||
custom: resolveI18nCustomTags(resolveMetaValue(configValue.custom, routeValue), currentLocale),
|
||||
}
|
||||
})
|
||||
|
||||
// Use the resolved config with useMeta
|
||||
useMeta(resolvedConfig, route, localeId)
|
||||
|
||||
// Add hreflang tags for international SEO
|
||||
const addHreflangTags = () => {
|
||||
const configValue = unref(config)
|
||||
if (!configValue.hreflang) return
|
||||
|
||||
const hreflangConfig = configValue.hreflang
|
||||
const currentPath = window.location.pathname
|
||||
|
||||
// Remove existing hreflang tags
|
||||
const existingTags = document.querySelectorAll('link[rel="alternate"][hreflang]')
|
||||
existingTags.forEach(tag => tag.remove())
|
||||
|
||||
// Add new hreflang tags
|
||||
Object.entries(hreflangConfig).forEach(([locale, path]) => {
|
||||
const link = document.createElement('link')
|
||||
link.setAttribute('rel', 'alternate')
|
||||
link.setAttribute('hreflang', locale)
|
||||
link.setAttribute('href', `${window.location.origin}${path}`)
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
|
||||
// Add x-default
|
||||
const defaultLink = document.createElement('link')
|
||||
defaultLink.setAttribute('rel', 'alternate')
|
||||
defaultLink.setAttribute('hreflang', 'x-default')
|
||||
defaultLink.setAttribute('href', `${window.location.origin}${currentPath}`)
|
||||
document.head.appendChild(defaultLink)
|
||||
}
|
||||
|
||||
// Watch for config changes and update hreflang tags
|
||||
watch(resolvedConfig, addHreflangTags, { immediate: true })
|
||||
|
||||
return {
|
||||
resolvedConfig,
|
||||
localeId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves i18n-aware content to the appropriate locale string
|
||||
*/
|
||||
function resolveI18nContent(
|
||||
content: string | LocaleAwareMeta | undefined,
|
||||
currentLocale: LocaleId
|
||||
): string | undefined {
|
||||
if (content === undefined) return undefined
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return content
|
||||
}
|
||||
|
||||
// It's a locale-aware object
|
||||
if (currentLocale && content[currentLocale]) {
|
||||
return content[currentLocale]
|
||||
}
|
||||
|
||||
// Fallback to default locale or first available
|
||||
return content['en-US'] || Object.values(content)[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves custom meta tags with i18n support
|
||||
*/
|
||||
function resolveI18nCustomTags(
|
||||
customTags: Array<{
|
||||
name?: string
|
||||
property?: string
|
||||
content: string | ((route: any) => string)
|
||||
charset?: string
|
||||
httpEquiv?: string
|
||||
}> | undefined,
|
||||
currentLocale: LocaleId
|
||||
): Array<{
|
||||
name?: string
|
||||
property?: string
|
||||
content: string
|
||||
charset?: string
|
||||
httpEquiv?: string
|
||||
}> {
|
||||
if (!customTags) return []
|
||||
|
||||
return customTags.map(tag => ({
|
||||
...tag,
|
||||
content: typeof tag.content === 'string' && typeof tag.content === 'object'
|
||||
? resolveI18nContent(tag.content as any, currentLocale) || ''
|
||||
: typeof tag.content === 'function'
|
||||
? tag.content
|
||||
: tag.content
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n-aware meta configuration interface
|
||||
*/
|
||||
export interface I18nMetaConfig {
|
||||
/** Page title (locale-aware) */
|
||||
title?: string | LocaleAwareMeta | ((route: any) => string | LocaleAwareMeta)
|
||||
/** Meta description (locale-aware) */
|
||||
description?: string | LocaleAwareMeta | ((route: any) => string | LocaleAwareMeta)
|
||||
/** Meta keywords */
|
||||
keywords?: string | string[] | ((route: any) => string | string[])
|
||||
/** Author meta tag */
|
||||
author?: string | ((route: any) => string)
|
||||
/** Canonical URL */
|
||||
canonical?: string | ((route: any) => string)
|
||||
/** OpenGraph configuration */
|
||||
og?: MetaConfig['og']
|
||||
/** Twitter Card configuration */
|
||||
twitter?: MetaConfig['twitter']
|
||||
/** Custom meta tags */
|
||||
custom?: MetaConfig['custom']
|
||||
/** Hreflang configuration for international SEO */
|
||||
hreflang?: {
|
||||
[locale: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create locale-aware meta configuration
|
||||
*/
|
||||
export function createI18nMeta(
|
||||
meta: I18nMetaConfig
|
||||
): I18nMetaConfig {
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to merge i18n meta configurations
|
||||
*/
|
||||
export function mergeI18nMeta(
|
||||
base: I18nMetaConfig,
|
||||
override: Partial<I18nMetaConfig>
|
||||
): I18nMetaConfig {
|
||||
return { ...base, ...override }
|
||||
}
|
||||
113
apps/vdn-static/src/composables/usePreviewMode.ts
Normal file
113
apps/vdn-static/src/composables/usePreviewMode.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { ref, computed, reactive } from 'vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { drupalService } from '../services/DrupalService'
|
||||
|
||||
export interface PreviewModeConfig {
|
||||
enabled: boolean
|
||||
userId?: string
|
||||
sessionId?: string
|
||||
entityType?: string
|
||||
entityId?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
export function usePreviewMode() {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Preview state
|
||||
const previewEnabled = ref(false)
|
||||
const previewConfig = reactive<PreviewModeConfig>({
|
||||
enabled: false,
|
||||
userId: undefined,
|
||||
sessionId: undefined,
|
||||
entityType: undefined,
|
||||
entityId: undefined,
|
||||
token: undefined
|
||||
})
|
||||
|
||||
// Computed properties
|
||||
const isInPreviewMode = computed(() => previewEnabled.value && userStore.isAuthenticated)
|
||||
const previewUserInfo = computed(() => ({
|
||||
name: previewConfig.userId ? `Preview as ${previewConfig.userId}` : userStore.user?.name || 'Editor',
|
||||
role: previewConfig.userId ? 'Editor' : userStore.user?.roles?.[0] || 'Authenticated User'
|
||||
}))
|
||||
|
||||
// Methods
|
||||
const enablePreviewMode = (config: Partial<PreviewModeConfig> = {}) => {
|
||||
if (!userStore.isAuthenticated) {
|
||||
throw new Error('Authentication required for preview mode')
|
||||
}
|
||||
|
||||
// Update preview config
|
||||
Object.assign(previewConfig, config, { enabled: true })
|
||||
previewEnabled.value = true
|
||||
|
||||
// Set auth headers for preview requests
|
||||
drupalService.setAuthHeaders({
|
||||
'X-CSRF-Token': userStore.tokens?.access_token || ''
|
||||
})
|
||||
|
||||
console.log('Preview mode enabled', previewConfig)
|
||||
}
|
||||
|
||||
const disablePreviewMode = () => {
|
||||
previewConfig.enabled = false
|
||||
previewEnabled.value = false
|
||||
console.log('Preview mode disabled')
|
||||
}
|
||||
|
||||
const togglePreviewMode = (config?: Partial<PreviewModeConfig>) => {
|
||||
if (previewEnabled.value) {
|
||||
disablePreviewMode()
|
||||
} else {
|
||||
enablePreviewMode(config || {})
|
||||
}
|
||||
}
|
||||
|
||||
const isPreviewEnabledForEntity = (entityType: string, entityId: string) => {
|
||||
return previewEnabled.value &&
|
||||
previewConfig.entityType === entityType &&
|
||||
previewConfig.entityId === entityId
|
||||
}
|
||||
|
||||
const setPreviewContext = (entityType: string, entityId: string, token?: string) => {
|
||||
if (!previewEnabled.value) {
|
||||
throw new Error('Preview mode must be enabled first')
|
||||
}
|
||||
|
||||
previewConfig.entityType = entityType
|
||||
previewConfig.entityId = entityId
|
||||
previewConfig.token = token
|
||||
}
|
||||
|
||||
const clearPreviewContext = () => {
|
||||
previewConfig.entityType = undefined
|
||||
previewConfig.entityId = undefined
|
||||
previewConfig.token = undefined
|
||||
}
|
||||
|
||||
// Auto-disable preview mode on logout
|
||||
const watchAuth = () => {
|
||||
// This would be implemented with a watch on authentication state
|
||||
// For now, we'll handle this in the logout method of useAuth
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
previewEnabled,
|
||||
previewConfig,
|
||||
|
||||
// Computed
|
||||
isInPreviewMode,
|
||||
previewUserInfo,
|
||||
|
||||
// Methods
|
||||
enablePreviewMode,
|
||||
disablePreviewMode,
|
||||
togglePreviewMode,
|
||||
isPreviewEnabledForEntity,
|
||||
setPreviewContext,
|
||||
clearPreviewContext,
|
||||
watchAuth,
|
||||
}
|
||||
}
|
||||
127
apps/vdn-static/src/composables/useRouteMeta.ts
Normal file
127
apps/vdn-static/src/composables/useRouteMeta.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { watch, onMounted, onUnmounted, type Ref, computed, unref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMeta } from './useMeta'
|
||||
import type { RouteMeta, RouteMetaValue, isMetaFunction } from '../types/route-meta.d'
|
||||
|
||||
/**
|
||||
* Composable for automatic meta tag management based on route configuration
|
||||
*
|
||||
* This composable integrates with Vue Router to automatically update meta tags
|
||||
* when the route changes. It reads the `meta` field from route definitions and
|
||||
* applies them to the document.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a route definition
|
||||
* const routes = [
|
||||
* {
|
||||
* path: '/resources/:id',
|
||||
* component: ResourcePage,
|
||||
* meta: {
|
||||
* title: (route) => `Resource: ${route.params.id}`,
|
||||
* description: 'Learning resources and tutorials',
|
||||
* og: {
|
||||
* type: 'article',
|
||||
* image: '/default-og-image.jpg'
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ]
|
||||
*
|
||||
* // In App.vue or main.ts
|
||||
* setup() {
|
||||
* useRouteMeta()
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useRouteMeta() {
|
||||
const route = useRoute()
|
||||
|
||||
/**
|
||||
* Resolves a meta value that can be static or a function
|
||||
*/
|
||||
const resolveMetaValue = <T>(
|
||||
value: RouteMetaValue<T> | undefined,
|
||||
routeValue: typeof route
|
||||
): T | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
return typeof value === 'function' ? value(routeValue) : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reactive meta configuration from the current route
|
||||
*/
|
||||
const metaConfig = computed(() => {
|
||||
const meta: RouteMeta = route.meta || {}
|
||||
|
||||
return {
|
||||
title: resolveMetaValue(meta.title, route),
|
||||
description: resolveMetaValue(meta.description, route),
|
||||
keywords: resolveMetaValue(meta.keywords, route),
|
||||
author: resolveMetaValue(meta.author, route),
|
||||
canonical: resolveMetaValue(meta.canonical, route),
|
||||
og: resolveMetaValue(meta.og, route),
|
||||
twitter: resolveMetaValue(meta.twitter, route),
|
||||
custom: resolveMetaValue(meta.custom, route),
|
||||
}
|
||||
})
|
||||
|
||||
// Apply meta tags whenever the route changes
|
||||
useMeta(metaConfig, route)
|
||||
|
||||
return {
|
||||
metaConfig,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin to install automatic route-based meta tag management
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In main.ts
|
||||
* import { createApp } from 'vue'
|
||||
* import App from './App.vue'
|
||||
* import router from './router'
|
||||
* import { RouteMetaPlugin } from './composables/useRouteMeta'
|
||||
*
|
||||
* const app = createApp(App)
|
||||
* app.use(router)
|
||||
* app.use(RouteMetaPlugin)
|
||||
* app.mount('#app')
|
||||
* ```
|
||||
*/
|
||||
export const RouteMetaPlugin = {
|
||||
install(app: any) {
|
||||
// The plugin installation logic will be handled in the root component
|
||||
// This is just a marker for the plugin system
|
||||
app.provide('route-meta-installed', true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable to define meta tags for the current route
|
||||
* This is useful for page components using `definePage` macro
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <script setup lang="ts">
|
||||
* import { usePageMeta } from '@/composables/useRouteMeta'
|
||||
*
|
||||
* usePageMeta({
|
||||
* title: 'Home Page',
|
||||
* description: 'Welcome to our website',
|
||||
* og: {
|
||||
* type: 'website',
|
||||
* image: '/og-home.jpg'
|
||||
* }
|
||||
* })
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function usePageMeta(meta: RouteMeta) {
|
||||
const route = useRoute()
|
||||
|
||||
// Return a reactive meta config that merges with route meta
|
||||
return useMeta(meta, route)
|
||||
}
|
||||
333
apps/vdn-static/src/config/meta-examples.ts
Normal file
333
apps/vdn-static/src/config/meta-examples.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
/**
|
||||
* Example Meta Tag Configurations for Different Page Types
|
||||
*
|
||||
* This file demonstrates how to configure meta tags for various page types
|
||||
* in the Viossa application.
|
||||
*/
|
||||
|
||||
import type { MetaConfig, I18nMetaConfig } from '../composables/useI18nMeta'
|
||||
import { createRouteMeta } from '../composables/useMeta'
|
||||
|
||||
/**
|
||||
* Home Page Meta Configuration
|
||||
*
|
||||
* Basic configuration for the main landing page with i18n support.
|
||||
*/
|
||||
export const homePageMeta: I18nMetaConfig = {
|
||||
title: {
|
||||
'en-US': 'Viossa - Learn a Language Through Immersion',
|
||||
'es-LA': 'Viossa - Aprende un Idioma a Través de la Inmersión',
|
||||
'vp-VL': 'Viossa - Vlosso pada via imerso',
|
||||
},
|
||||
description: {
|
||||
'en-US': 'Viossa is a language learning platform that uses immersive methods to help you learn naturally through interaction with native speakers.',
|
||||
'es-LA': 'Viossa es una plataforma de aprendizaje de idiomas que utiliza métodos inmersivos para ayudarte a aprender naturalmente a través de la interacción con hablantes nativos.',
|
||||
'vp-VL': 'Viossa es platforma de lernado de linguas ke usa metodos imersiv para ayudar tu aprender naturalmente.',
|
||||
},
|
||||
keywords: ['language learning', 'immersion', 'viossa', 'natural language acquisition', 'polyglot'],
|
||||
author: 'Viossa Team',
|
||||
og: {
|
||||
type: 'website',
|
||||
title: {
|
||||
'en-US': 'Viossa - Learn a Language Through Immersion',
|
||||
'es-LA': 'Viossa - Aprende un Idioma a Través de la Inmersión',
|
||||
},
|
||||
description: {
|
||||
'en-US': 'Learn languages naturally through immersion and interaction with native speakers.',
|
||||
'es-LA': 'Aprende idiomas naturalmente a través de la inmersión e interacción con hablantes nativos.',
|
||||
},
|
||||
image: '/images/og-viossa-home.jpg',
|
||||
siteName: 'Viossa',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
site: '@viossa',
|
||||
creator: '@viossa',
|
||||
title: {
|
||||
'en-US': 'Viossa - Learn a Language Through Immersion',
|
||||
},
|
||||
description: {
|
||||
'en-US': 'Learn languages naturally through immersion',
|
||||
},
|
||||
image: '/images/twitter-viossa-home.jpg',
|
||||
},
|
||||
hreflang: {
|
||||
'en': '/',
|
||||
'es': '/?locale=es-LA',
|
||||
'x-default': '/',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Resources Page Meta Configuration
|
||||
*
|
||||
* Configuration for the resources listing page.
|
||||
*/
|
||||
export const resourcesPageMeta: I18nMetaConfig = {
|
||||
title: {
|
||||
'en-US': 'Learning Resources - Viossa',
|
||||
'es-LA': 'Recursos de Aprendizaje - Viossa',
|
||||
},
|
||||
description: {
|
||||
'en-US': 'Browse our collection of learning resources, tutorials, and guides to help you on your language learning journey.',
|
||||
'es-LA': 'Explora nuestra colección de recursos de aprendizaje, tutoriales y guías para ayudarte en tu viaje de aprendizaje de idiomas.',
|
||||
},
|
||||
keywords: ['resources', 'tutorials', 'guides', 'language learning materials', 'viossa resources'],
|
||||
og: {
|
||||
type: 'website',
|
||||
title: {
|
||||
'en-US': 'Learning Resources - Viossa',
|
||||
},
|
||||
description: {
|
||||
'en-US': 'Browse our collection of learning resources and tutorials.',
|
||||
},
|
||||
image: '/images/og-resources.jpg',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: {
|
||||
'en-US': 'Learning Resources - Viossa',
|
||||
},
|
||||
description: {
|
||||
'en-US': 'Browse tutorials and guides for language learning',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource Detail Page Meta Configuration (Dynamic)
|
||||
*
|
||||
* Configuration for individual resource/article pages.
|
||||
* Uses route parameters to generate dynamic meta tags.
|
||||
*/
|
||||
export const resourceDetailMeta = (route: any): I18nMetaConfig => {
|
||||
// In a real implementation, this would fetch resource data from an API
|
||||
const resourceId = route.params.id
|
||||
const resourceTitle = `Resource #${resourceId}` // Would be actual resource title
|
||||
const resourceDescription = `Detailed information about resource ${resourceId}` // Would be actual description
|
||||
|
||||
return {
|
||||
title: {
|
||||
'en-US': `${resourceTitle} - Viossa Resources`,
|
||||
'es-LA': `${resourceTitle} - Recursos de Viossa`,
|
||||
},
|
||||
description: {
|
||||
'en-US': resourceDescription,
|
||||
'es-LA': resourceDescription,
|
||||
},
|
||||
// Dynamic canonical URL based on resource ID
|
||||
canonical: `https://viossa.net/resources/${resourceId}`,
|
||||
og: {
|
||||
type: 'article',
|
||||
title: {
|
||||
'en-US': resourceTitle,
|
||||
},
|
||||
description: {
|
||||
'en-US': resourceDescription,
|
||||
},
|
||||
image: '/images/og-resource-default.jpg',
|
||||
url: `https://viossa.net/resources/${resourceId}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: {
|
||||
'en-US': resourceTitle,
|
||||
},
|
||||
description: {
|
||||
'en-US': resourceDescription,
|
||||
},
|
||||
},
|
||||
// Custom meta tags for resource pages
|
||||
custom: [
|
||||
{
|
||||
name: 'resource-id',
|
||||
content: resourceId,
|
||||
},
|
||||
{
|
||||
name: 'article:published_time',
|
||||
content: new Date().toISOString(), // Would be actual publish date
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kotoba Page Meta Configuration
|
||||
*
|
||||
* Configuration for the Kotoba language learning tool page.
|
||||
*/
|
||||
export const kotobaPageMeta: I18nMetaConfig = {
|
||||
title: {
|
||||
'en-US': 'Kotoba - Interactive Language Learning - Viossa',
|
||||
'es-LA': 'Kotoba - Aprendizaje Interactivo de Idiomas - Viossa',
|
||||
},
|
||||
description: {
|
||||
'en-US': 'Practice vocabulary and grammar with Kotoba, our interactive language learning tool featuring spaced repetition and contextual learning.',
|
||||
'es-LA': 'Practica vocabulario y gramática con Kotoba, nuestra herramienta interactiva de aprendizaje de idiomas con repetición espaciada y aprendizaje contextual.',
|
||||
},
|
||||
keywords: ['kotoba', 'vocabulary', 'grammar', 'spaced repetition', 'interactive learning', 'language practice'],
|
||||
og: {
|
||||
type: 'website',
|
||||
title: {
|
||||
'en-US': 'Kotoba - Interactive Language Learning',
|
||||
},
|
||||
description: {
|
||||
'en-US': 'Practice vocabulary with spaced repetition',
|
||||
},
|
||||
image: '/images/og-kotoba.jpg',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: {
|
||||
'en-US': 'Kotoba - Interactive Language Learning',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord Rules Page Meta Configuration
|
||||
*
|
||||
* Configuration for the Discord community rules page.
|
||||
*/
|
||||
export const discordRulesPageMeta: I18nMetaConfig = {
|
||||
title: {
|
||||
'en-US': 'Discord Community Rules - Viossa',
|
||||
'es-LA': 'Reglas de la Comunidad Discord - Viossa',
|
||||
},
|
||||
description: {
|
||||
'en-US': 'Learn about our Discord community rules and guidelines for maintaining a respectful and supportive learning environment.',
|
||||
'es-LA': 'Conoce las reglas y directrices de nuestra comunidad Discord para mantener un ambiente de aprendizaje respetuoso y de apoyo.',
|
||||
},
|
||||
keywords: ['discord', 'community rules', 'guidelines', 'viossa discord', 'community standards'],
|
||||
robots: 'index, follow', // This page should be indexed
|
||||
og: {
|
||||
type: 'website',
|
||||
title: {
|
||||
'en-US': 'Discord Community Rules',
|
||||
},
|
||||
description: {
|
||||
'en-US': 'Community guidelines for Viossa Discord server',
|
||||
},
|
||||
image: '/images/og-discord-rules.jpg',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate article meta configuration
|
||||
*
|
||||
* This is an example of how to create meta configurations for
|
||||
* Drupal content types (articles, blog posts, etc.)
|
||||
*/
|
||||
export function generateArticleMeta(article: {
|
||||
id: string
|
||||
title: string
|
||||
summary: string
|
||||
author: string
|
||||
publishedDate: string
|
||||
modifiedDate?: string
|
||||
image?: string
|
||||
tags?: string[]
|
||||
}): MetaConfig {
|
||||
return {
|
||||
title: article.title,
|
||||
description: article.summary,
|
||||
keywords: article.tags || [],
|
||||
author: article.author,
|
||||
canonical: `https://viossa.net/articles/${article.id}`,
|
||||
og: {
|
||||
type: 'article',
|
||||
title: article.title,
|
||||
description: article.summary,
|
||||
image: article.image || '/images/og-article-default.jpg',
|
||||
url: `https://viossa.net/articles/${article.id}`,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: article.title,
|
||||
description: article.summary,
|
||||
image: article.image || '/images/twitter-article-default.jpg',
|
||||
},
|
||||
custom: [
|
||||
{
|
||||
property: 'article:published_time',
|
||||
content: article.publishedDate,
|
||||
},
|
||||
...(article.modifiedDate ? [{
|
||||
property: 'article:modified_time',
|
||||
content: article.modifiedDate,
|
||||
}] : []),
|
||||
{
|
||||
property: 'article:author',
|
||||
content: article.author,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate resource meta configuration
|
||||
*
|
||||
* This is an example of how to create meta configurations for
|
||||
* Drupal resource content types.
|
||||
*/
|
||||
export function generateResourceMeta(resource: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
author: string
|
||||
type: 'tutorial' | 'guide' | 'video' | 'course'
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
tags?: string[]
|
||||
}): MetaConfig {
|
||||
return {
|
||||
title: `${resource.title} - Viossa Resources`,
|
||||
description: resource.description,
|
||||
keywords: [...(resource.tags || []), resource.type, resource.difficulty],
|
||||
author: resource.author,
|
||||
canonical: `https://viossa.net/resources/${resource.id}`,
|
||||
og: {
|
||||
type: 'article',
|
||||
title: resource.title,
|
||||
description: resource.description,
|
||||
image: '/images/og-resource-default.jpg',
|
||||
url: `https://viossa.net/resources/${resource.id}`,
|
||||
},
|
||||
custom: [
|
||||
{
|
||||
name: 'resource-type',
|
||||
content: resource.type,
|
||||
},
|
||||
{
|
||||
name: 'difficulty',
|
||||
content: resource.difficulty,
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
content: resource.author,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Route configuration with meta tags
|
||||
*
|
||||
* This shows how to configure routes with meta tags in the router definition.
|
||||
*/
|
||||
export const exampleRouteWithMeta = {
|
||||
path: '/resources/:id',
|
||||
name: 'resource-detail',
|
||||
component: () => import('../pages/resources/[id].vue'),
|
||||
meta: createRouteMeta({
|
||||
title: (route) => `Resource: ${route.params.id}`,
|
||||
description: 'Learning resources and tutorials',
|
||||
keywords: ['resources', 'tutorials', 'learning'],
|
||||
og: {
|
||||
type: 'article',
|
||||
image: '/default-og-image.jpg'
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -12,12 +12,12 @@ import enUsFtlSrc from "@/assets/locale/en_US.ftl";
|
|||
import esLaFtlSrc from "@/assets/locale/es_LA.ftl";
|
||||
import vpVlFtlSrc from "@/assets/locale/vp_VL.ftl";
|
||||
import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl";
|
||||
import qpvFtlSrc from "@/assets/locale/qpv.ftl";
|
||||
|
||||
import type { FluentBundle } from "@fluent/bundle";
|
||||
import { compileLocale } from "@/vi18n-lib/compile";
|
||||
import type { ComputedRef } from "vue";
|
||||
|
||||
export const LOCALE_IDS = ["en-US", "es-LA", "vp-VL", "wp-VL", "qpv"] as const;
|
||||
export const LOCALE_IDS = ["en-US", "es-LA", "vp-VL", "wp-VL"] as const;
|
||||
|
||||
export type LocaleId = typeof LocaleId.infer;
|
||||
export const LocaleId = type.enumerated(...LOCALE_IDS);
|
||||
|
|
@ -170,11 +170,10 @@ async function initI18n(): Promise<I18n> {
|
|||
),
|
||||
);
|
||||
|
||||
const [esLa, vpVl, wpVl, qpv] = await Promise.all([
|
||||
const [esLa, vpVl, wpVl] = await Promise.all([
|
||||
doItAllForLocale("es-LA", esLaFtlSrc),
|
||||
doItAllForLocale("vp-VL", vpVlFtlSrc),
|
||||
doItAllForLocale("wp-VL", wpVlFtlSrc),
|
||||
doItAllForLocale("qpv", qpvFtlSrc),
|
||||
]);
|
||||
|
||||
const localeIdToLocale = {
|
||||
|
|
@ -182,7 +181,6 @@ async function initI18n(): Promise<I18n> {
|
|||
"es-LA": esLa,
|
||||
"vp-VL": vpVl,
|
||||
"wp-VL": wpVl,
|
||||
"qpv": qpv,
|
||||
} as const satisfies Record<LocaleId, DeepReadonly<Locale>>;
|
||||
|
||||
const useLocale = (opt: UseLocaleOptions = {}) =>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { onI18nInit } from "./i18n";
|
||||
|
||||
onI18nInit(() => {
|
||||
createApp(App).use(router).mount("#app");
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
});
|
||||
|
|
|
|||
257
apps/vdn-static/src/pages/admin/index.vue
Normal file
257
apps/vdn-static/src/pages/admin/index.vue
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<script setup lang="ts">
|
||||
import { definePage } from 'vue-router/auto-routes'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
import { usePreviewMode } from '../../composables/usePreviewMode'
|
||||
import PreviewModeToggle from '@/components/molecules/PreviewModeToggle.vue'
|
||||
|
||||
// Mark this page as requiring authentication
|
||||
definePage({
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
})
|
||||
|
||||
const { currentUser, isAdmin, logout } = useAuth()
|
||||
const { isInPreviewMode, togglePreviewMode } = usePreviewMode()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<div class="admin-header">
|
||||
<div class="header-left">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<div class="header-actions">
|
||||
<PreviewModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<button @click="logout" class="logout-button">Sign Out</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-content">
|
||||
<div class="user-info">
|
||||
<h2>Welcome, {{ currentUser?.name }}</h2>
|
||||
<p class="user-email">{{ currentUser?.mail }}</p>
|
||||
<p v-if="isAdmin" class="admin-badge">Administrator</p>
|
||||
<p v-else class="editor-badge">Content Editor</p>
|
||||
|
||||
<div v-if="isInPreviewMode" class="preview-status">
|
||||
<span class="preview-badge">Preview Mode Active</span>
|
||||
<p class="preview-description">You can view unpublished content in this mode</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h3>Quick Actions</h3>
|
||||
<div class="action-grid">
|
||||
<RouterLink to="/admin/content" class="action-card">
|
||||
<h4>Manage Content</h4>
|
||||
<p>Create, edit, and delete content</p>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/" class="action-card">
|
||||
<h4>View Site</h4>
|
||||
<p>Preview your changes on the live site</p>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink v-if="isAdmin" to="/admin/users" class="action-card">
|
||||
<h4>Manage Users</h4>
|
||||
<p>Add and edit user accounts</p>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink v-if="isAdmin" to="/admin/settings" class="action-card">
|
||||
<h4>Site Settings</h4>
|
||||
<p>Configure site options</p>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/preview" class="action-card">
|
||||
<h4>Content Preview</h4>
|
||||
<p>View unpublished content in preview mode</p>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.user-info h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.admin-badge,
|
||||
.editor-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.editor-badge {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.preview-status {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-description {
|
||||
margin: 0;
|
||||
color: #856404;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.admin-section h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
padding: 1.5rem;
|
||||
background: #f9f9f9;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
background: #fff;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.action-card h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.action-card p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
207
apps/vdn-static/src/pages/login.vue
Normal file
207
apps/vdn-static/src/pages/login.vue
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { login, loading, error } = useAuth()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const formError = ref<string | null>(null)
|
||||
|
||||
const handleLogin = async () => {
|
||||
// Clear previous errors
|
||||
formError.value = null
|
||||
|
||||
// Validate inputs
|
||||
if (!username.value.trim()) {
|
||||
formError.value = 'Username is required'
|
||||
return
|
||||
}
|
||||
|
||||
if (!password.value) {
|
||||
formError.value = 'Password is required'
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt login
|
||||
const success = await login({
|
||||
username: username.value.trim(),
|
||||
password: password.value,
|
||||
})
|
||||
|
||||
if (success) {
|
||||
// Redirect to originally requested page or admin home
|
||||
const redirect = route.query.redirect as string || '/admin'
|
||||
router.push(redirect)
|
||||
}
|
||||
}
|
||||
|
||||
// Get redirect destination from query param
|
||||
const redirectTo = computed(() => route.query.redirect as string || '/admin')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<h1>Login</h1>
|
||||
<p class="login-subtitle">Sign in to access protected content</p>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
:disabled="loading"
|
||||
placeholder="Enter your username"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
:disabled="loading"
|
||||
placeholder="Enter your password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error messages -->
|
||||
<div v-if="formError || error" class="error-message">
|
||||
{{ formError || error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="login-button">
|
||||
{{ loading ? 'Signing in...' : 'Sign In' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login-note">
|
||||
This is a private site. Contact the administrator for access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 3rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 0 0 2rem 0;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 8px;
|
||||
color: #c33;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-note {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-container {
|
||||
margin: 1rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
419
apps/vdn-static/src/pages/preview.vue
Normal file
419
apps/vdn-static/src/pages/preview.vue
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
<template>
|
||||
<div class="preview-page">
|
||||
<div class="preview-header">
|
||||
<h1>Content Preview</h1>
|
||||
<p class="preview-subtitle">View unpublished content in preview mode</p>
|
||||
</div>
|
||||
|
||||
<div class="preview-controls">
|
||||
<div class="control-group">
|
||||
<label class="label">Content Type:</label>
|
||||
<select v-model="selectedContentType" @change="loadPreviewContent" class="select">
|
||||
<option value="article">Articles</option>
|
||||
<option value="page">Pages</option>
|
||||
<option value="learning_resource">Learning Resources</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="label">Preview Mode:</label>
|
||||
<div class="toggle-container">
|
||||
<button
|
||||
@click="() => togglePreviewMode()"
|
||||
:class="['toggle-button', { active: isInPreviewMode }]"
|
||||
:disabled="!isAuthenticated"
|
||||
:title="isInPreviewMode ? 'Disable preview mode' : 'Enable preview mode'"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
<span class="toggle-text">{{ isInPreviewMode ? 'ON' : 'OFF' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<button
|
||||
@click="() => loadPreviewContent()"
|
||||
:disabled="loading"
|
||||
class="button is-primary"
|
||||
>
|
||||
<span v-if="loading" class="spinner"></span>
|
||||
<span v-else>Load Content</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-content">
|
||||
<div v-if="loading && !content.length" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading content...</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-state">
|
||||
<p class="error-message">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="content.length && !loading" class="content-grid">
|
||||
<div
|
||||
v-for="item in content"
|
||||
:key="item.id"
|
||||
class="content-card"
|
||||
:class="{ 'unpublished': !item.attributes.status }"
|
||||
>
|
||||
<div class="card-header">
|
||||
<h3>{{ item.attributes.title }}</h3>
|
||||
<span class="status-badge" :class="{ 'unpublished': !item.attributes.status }">
|
||||
{{ item.attributes.status ? 'Published' : 'Unpublished' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div v-if="item.attributes.body?.processed" class="content-preview" v-html="item.attributes.body.processed"></div>
|
||||
<div v-else class="content-text">{{ item.attributes.body?.value || 'No content available' }}</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<p class="meta-item">
|
||||
<span class="label">Created:</span>
|
||||
<span>{{ formatDate(item.attributes.created) }}</span>
|
||||
</p>
|
||||
<p class="meta-item">
|
||||
<span class="label">Updated:</span>
|
||||
<span>{{ formatDate(item.attributes.changed) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="content.length === 0 && !loading" class="empty-state">
|
||||
<p>No content found for the selected type.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isInPreviewMode" class="preview-notice">
|
||||
<div class="notification is-warning">
|
||||
<p><strong>Preview Mode Active:</strong> You can see unpublished content in this mode.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { usePreviewMode } from '../composables/usePreviewMode'
|
||||
import { drupalService } from '../services/DrupalService'
|
||||
|
||||
type ContentType = "article" | "page" | "learning_resource"
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { isInPreviewMode, togglePreviewMode } = usePreviewMode()
|
||||
|
||||
// State
|
||||
const selectedContentType = ref('article')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const content = ref<any[]>([])
|
||||
|
||||
// Methods
|
||||
const loadPreviewContent = async () => {
|
||||
if (!isAuthenticated.value) {
|
||||
error.value = 'Please log in to view content'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
content.value = []
|
||||
|
||||
try {
|
||||
const params = {
|
||||
contentType: selectedContentType.value as ContentType,
|
||||
includeUnpublished: isInPreviewMode.value ? true : false,
|
||||
include: ['uid', 'field_image'],
|
||||
sort: ['changed DESC'],
|
||||
page: { limit: 10 }
|
||||
}
|
||||
|
||||
const response = await drupalService.getNodes(params)
|
||||
content.value = drupalService.extractData(response)
|
||||
|
||||
if (content.value.length === 0) {
|
||||
error.value = isInPreviewMode.value
|
||||
? 'No content found (including unpublished)'
|
||||
: 'No published content found'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading content:', err)
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load content'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'N/A'
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadPreviewContent()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-page {
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.preview-header h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-subtitle {
|
||||
color: #7f8c8d;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toggle-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
background: #ccc;
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.toggle-button.active {
|
||||
background: #42b983;
|
||||
}
|
||||
|
||||
.toggle-button:disabled {
|
||||
background: #e0e0e0;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.toggle-button.active .toggle-slider {
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-weight: 500;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
background: #fdf2f2;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fcc;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.content-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.content-card.unpublished {
|
||||
border: 2px solid #f39c12;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.unpublished {
|
||||
background: #f39c12;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
margin-bottom: 1rem;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
border-top: 1px solid #e9ecef;
|
||||
padding-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.meta-item .label {
|
||||
font-weight: 500;
|
||||
color: #7f8c8d;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #7f8c8d;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-notice {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.preview-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { routes, handleHotUpdate } from "vue-router/auto-routes";
|
||||
import { setupAuthGuards } from "./router/guards";
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes });
|
||||
|
||||
// Setup authentication guards
|
||||
setupAuthGuards(router);
|
||||
|
||||
if (import.meta.hot) {
|
||||
handleHotUpdate(router);
|
||||
}
|
||||
|
|
|
|||
77
apps/vdn-static/src/router/guards.ts
Normal file
77
apps/vdn-static/src/router/guards.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Route Guards for Authentication
|
||||
*
|
||||
* This module provides navigation guards for protecting routes.
|
||||
* Works with unplugin-vue-router's definePage meta system.
|
||||
*/
|
||||
|
||||
import type { Router } from 'vue-router'
|
||||
import { useUserStore } from '../stores/user'
|
||||
|
||||
/**
|
||||
* Setup navigation guards on router
|
||||
*/
|
||||
export function setupAuthGuards(router: Router) {
|
||||
router.beforeEach((to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Check if route requires authentication
|
||||
if (to.meta.requiresAuth) {
|
||||
// User must be authenticated
|
||||
if (!userStore.isAuthenticated) {
|
||||
// Redirect to login withreturn URL
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if route requires admin role
|
||||
if (to.meta.requiresAdmin && !userStore.isAdmin) {
|
||||
// User is not an admin
|
||||
next({ path: '/' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If trying to access login page while authenticated, redirect to admin
|
||||
if (to.path === '/login' && userStore.isAuthenticated) {
|
||||
next({ path: '/admin' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type augmentation for vue-router
|
||||
* Allows using definePage with meta properties
|
||||
*/
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/** Route requires authentication */
|
||||
requiresAuth?: boolean
|
||||
/** Route requires admin role */
|
||||
requiresAdmin?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage in a page component:
|
||||
*
|
||||
* <script setup lang="ts">
|
||||
* import { definePage } from 'vue-router/auto-routes'
|
||||
*
|
||||
* definePage({
|
||||
* meta: {
|
||||
* requiresAuth: true,
|
||||
* }
|
||||
* })
|
||||
* </script>
|
||||
*
|
||||
* <template>
|
||||
* <h1>Protected Content</h1>
|
||||
* </template>
|
||||
*/
|
||||
|
|
@ -24,19 +24,21 @@ interface FetchOptions {
|
|||
|
||||
interface NodeListParams extends JsonApiParams {
|
||||
contentType: ContentType;
|
||||
langcode?: "en" | "qpv"; // Support English and Viossa translations
|
||||
langcode?: "en" | "vp-VL"; // Support English and Viossa translations
|
||||
includeUnpublished?: boolean; // New parameter for preview mode
|
||||
}
|
||||
|
||||
interface NodeParams extends JsonApiParams {
|
||||
id: string;
|
||||
contentType: ContentType;
|
||||
include?: string[];
|
||||
langcode?: "en" | "qpv";
|
||||
langcode?: "en" | "vp-VL";
|
||||
includeUnpublished?: boolean; // New parameter for preview mode
|
||||
}
|
||||
|
||||
interface TaxonomyParams {
|
||||
vocabulary: Vocabulary;
|
||||
langcode?: "en" | "qpv";
|
||||
langcode?: "en" | "vp-VL";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -50,6 +52,7 @@ interface TaxonomyParams {
|
|||
*/
|
||||
export class DrupalService {
|
||||
private baseUrl: string;
|
||||
private authHeaders: Record<string, string> = {};
|
||||
|
||||
constructor(options: FetchOptions = {}) {
|
||||
// Use environment variable or default to proxy path
|
||||
|
|
@ -68,10 +71,24 @@ export class DrupalService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication headers for subsequent requests
|
||||
*/
|
||||
setAuthHeaders(headers: Record<string, string>) {
|
||||
this.authHeaders = { ...headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication headers
|
||||
*/
|
||||
clearAuthHeaders() {
|
||||
this.authHeaders = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from JsonApiParams
|
||||
*/
|
||||
private buildQueryString(params: JsonApiParams = {}, langcode?: string): string {
|
||||
private buildQueryString(params: JsonApiParams = {}, langcode?: string, includeUnpublished?: boolean): string {
|
||||
const queryParts: string[] = [];
|
||||
|
||||
// Language filter for translated content
|
||||
|
|
@ -79,6 +96,11 @@ export class DrupalService {
|
|||
queryParts.push(`filter[langcode]=${langcode}`);
|
||||
}
|
||||
|
||||
// Include unpublished content in preview mode
|
||||
if (includeUnpublished) {
|
||||
queryParts.push(`filter[status][condition][path]=status&filter[status][condition][operator]=IS NULL&filter[status][condition][value]=1`);
|
||||
}
|
||||
|
||||
// Include relationships
|
||||
if (params.include?.length) {
|
||||
queryParts.push(`include=${params.include.join(",")}`);
|
||||
|
|
@ -132,14 +154,18 @@ export class DrupalService {
|
|||
return queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Fetch wrapper with error handling
|
||||
*/
|
||||
private async fetchJson<T>(url: string): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Accept": "application/vnd.api+json",
|
||||
...this.authHeaders,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Accept": "application/vnd.api+json",
|
||||
},
|
||||
headers,
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -150,7 +176,7 @@ export class DrupalService {
|
|||
const errorJson = JSON.parse(errorBody);
|
||||
errors = errorJson.errors || [];
|
||||
} catch {
|
||||
// NotJSON error
|
||||
// Not JSON error
|
||||
}
|
||||
|
||||
throw new DrupalApiError(
|
||||
|
|
@ -169,8 +195,8 @@ export class DrupalService {
|
|||
async getNodes<T extends DrupalNodeAttributes = DrupalNodeAttributes>(
|
||||
params: NodeListParams
|
||||
): Promise<JsonApiDocument<T>> {
|
||||
const { contentType, langcode, ...queryParams } = params;
|
||||
const queryString = this.buildQueryString(queryParams, langcode);
|
||||
const { contentType, langcode, includeUnpublished, ...queryParams } = params;
|
||||
const queryString = this.buildQueryString(queryParams, langcode, includeUnpublished);
|
||||
const url = `${this.baseUrl}/node/${contentType}${queryString}`;
|
||||
|
||||
return this.fetchJson<JsonApiDocument<T>>(url);
|
||||
|
|
@ -183,9 +209,9 @@ export class DrupalService {
|
|||
async getNode<T extends DrupalNodeAttributes = DrupalNodeAttributes>(
|
||||
params: NodeParams
|
||||
): Promise<JsonApiDocument<T>> {
|
||||
const { id, contentType, include, langcode } = params;
|
||||
const { id, contentType, include, langcode, includeUnpublished } = params;
|
||||
const queryParams: JsonApiParams = include ? { include } : {};
|
||||
const queryString = this.buildQueryString(queryParams, langcode);
|
||||
const queryString = this.buildQueryString(queryParams, langcode, includeUnpublished);
|
||||
const url = `${this.baseUrl}/node/${contentType}/${id}${queryString}`;
|
||||
|
||||
return this.fetchJson<JsonApiDocument<T>>(url);
|
||||
|
|
|
|||
367
apps/vdn-static/src/services/PreviewDrupalService.ts
Normal file
367
apps/vdn-static/src/services/PreviewDrupalService.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
/// <reference types="vite/client" />
|
||||
/**
|
||||
* Enhanced Drupal Service with Preview Mode Support
|
||||
*
|
||||
* Extends the base DrupalService to handle preview mode functionality
|
||||
* for viewing unpublished content.
|
||||
*/
|
||||
|
||||
import type {
|
||||
JsonApiParams,
|
||||
JsonApiDocument,
|
||||
JsonApiResource,
|
||||
JsonApiLinks,
|
||||
JsonApiError,
|
||||
DrupalNodeAttributes,
|
||||
DrupalTaxonomyTermAttributes,
|
||||
} from '../types/drupal';
|
||||
|
||||
type ContentType = "article" | "page" | "learning_resource";
|
||||
type Vocabulary = "tags" | "categories";
|
||||
|
||||
interface FetchOptions {
|
||||
baseUrl?: string;
|
||||
useProxy?: boolean;
|
||||
}
|
||||
|
||||
interface NodeListParams extends JsonApiParams {
|
||||
contentType: ContentType;
|
||||
langcode?: "en" | "vp-VL";
|
||||
includeUnpublished?: boolean; // New parameter for preview mode
|
||||
previewContext?: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NodeParams extends JsonApiParams {
|
||||
id: string;
|
||||
contentType: ContentType;
|
||||
include?: string[];
|
||||
langcode?: "en" | "vp-VL";
|
||||
includeUnpublished?: boolean; // New parameter for preview mode
|
||||
previewContext?: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TaxonomyParams {
|
||||
vocabulary: Vocabulary;
|
||||
langcode?: "en" | "vp-VL";
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced DrupalService with Preview Mode Support
|
||||
*/
|
||||
export class PreviewDrupalService {
|
||||
private baseUrl: string;
|
||||
private authHeaders: Record<string, string> = {};
|
||||
private previewEnabled: boolean = false;
|
||||
private previewConfig: {
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
token?: string;
|
||||
} = {};
|
||||
|
||||
constructor(options: FetchOptions = {}) {
|
||||
// Use environment variable or default to proxy path
|
||||
const envUrl = import.meta.env.VITE_DRUPAL_API_URL;
|
||||
|
||||
if (options.baseUrl) {
|
||||
this.baseUrl = options.baseUrl;
|
||||
} else if (envUrl) {
|
||||
this.baseUrl = envUrl;
|
||||
} else if (options.useProxy === false) {
|
||||
// Direct access to Drupal
|
||||
this.baseUrl = "https://cms.viossa.net/jsonapi";
|
||||
} else {
|
||||
// Default: use proxy
|
||||
this.baseUrl = "/api/drupal-proxy";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication headers for subsequent requests
|
||||
*/
|
||||
setAuthHeaders(headers: Record<string, string>) {
|
||||
this.authHeaders = { ...headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication headers
|
||||
*/
|
||||
clearAuthHeaders() {
|
||||
this.authHeaders = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable preview mode
|
||||
*/
|
||||
enablePreviewMode(config: { entityType?: string; entityId?: string; token?: string } = {}) {
|
||||
this.previewEnabled = true;
|
||||
this.previewConfig = { ...config };
|
||||
console.log('Preview mode enabled', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable preview mode
|
||||
*/
|
||||
disablePreviewMode() {
|
||||
this.previewEnabled = false;
|
||||
this.previewConfig = {};
|
||||
console.log('Preview mode disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if preview mode is enabled
|
||||
*/
|
||||
isPreviewEnabled(): boolean {
|
||||
return this.previewEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview configuration
|
||||
*/
|
||||
getPreviewConfig() {
|
||||
return { ...this.previewConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add preview headers to request
|
||||
*/
|
||||
private getPreviewHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { ...this.authHeaders };
|
||||
|
||||
if (this.previewEnabled && this.previewConfig.token) {
|
||||
headers['X-Preview-Token'] = this.previewConfig.token;
|
||||
}
|
||||
|
||||
if (this.previewEnabled) {
|
||||
headers['X-Preview-Mode'] = 'true';
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from JsonApiParams with preview support
|
||||
*/
|
||||
private buildQueryString(params: JsonApiParams = {}, langcode?: string, includeUnpublished?: boolean): string {
|
||||
const queryParts: string[] = [];
|
||||
|
||||
// Language filter for translated content
|
||||
if (langcode) {
|
||||
queryParts.push(`filter[langcode]=${langcode}`);
|
||||
}
|
||||
|
||||
// Include unpublished content in preview mode
|
||||
if (includeUnpublished) {
|
||||
queryParts.push(`filter[status][condition][path]=status&filter[status][condition][operator]=IS NULL&filter[status][condition][value]=1`);
|
||||
}
|
||||
|
||||
// Include relationships
|
||||
if (params.include?.length) {
|
||||
queryParts.push(`include=${params.include.join(",")}`);
|
||||
}
|
||||
|
||||
// Sparse fieldsets
|
||||
if (params.fields) {
|
||||
const fields = Object.entries(params.fields)
|
||||
.map(([type, fields]) => `fields[${type}]=${fields.join(",")}`)
|
||||
.join("&");
|
||||
queryParts.push(fields);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
if (params.sort?.length) {
|
||||
queryParts.push(`sort=${params.sort.join(",")}`);
|
||||
}
|
||||
|
||||
// Pagination
|
||||
if (params.page) {
|
||||
if (params.page.limit) {
|
||||
queryParts.push(`page[limit]=${params.page.limit}`);
|
||||
}
|
||||
if (params.page.offset) {
|
||||
queryParts.push(`page[offset]=${params.page.offset}`);
|
||||
}
|
||||
if (params.page.cursor) {
|
||||
queryParts.push(`page[cursor]=${params.page.cursor}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Filtering
|
||||
if (params.filter) {
|
||||
const buildFilter = (obj: Record<string, unknown>, prefix = "filter"): string[] => {
|
||||
const parts: string[] = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const paramKey = `${prefix}[${key}]`;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
parts.push(...buildFilter(value as Record<string, unknown>, paramKey));
|
||||
} else if (Array.isArray(value)) {
|
||||
parts.push(`${paramKey}=${value.join(",")}`);
|
||||
} else {
|
||||
parts.push(`${paramKey}=${value}`);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
queryParts.push(...buildFilter(params.filter));
|
||||
}
|
||||
|
||||
return queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch wrapper with error handling and preview support
|
||||
*/
|
||||
private async fetchJson<T>(url: string): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Accept": "application/vnd.api+json",
|
||||
...this.getPreviewHeaders(),
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
credentials: 'include', // Include cookies for session auth
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
let errors: JsonApiError[] = [];
|
||||
|
||||
try {
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
errors = errorJson.errors || [];
|
||||
} catch {
|
||||
// Not JSON error
|
||||
}
|
||||
|
||||
throw new DrupalApiError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
errors
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nodes by content type with preview support
|
||||
*/
|
||||
async getNodes<T extends DrupalNodeAttributes = DrupalNodeAttributes>(
|
||||
params: NodeListParams
|
||||
): Promise<JsonApiDocument<T>> {
|
||||
const { contentType, langcode, includeUnpublished, previewContext, ...queryParams } = params;
|
||||
const queryString = this.buildQueryString(queryParams, langcode, includeUnpublished);
|
||||
const url = `${this.baseUrl}/node/${contentType}${queryString}`;
|
||||
|
||||
return this.fetchJson<JsonApiDocument<T>>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single node by ID with preview support
|
||||
*/
|
||||
async getNode<T extends DrupalNodeAttributes = DrupalNodeAttributes>(
|
||||
params: NodeParams
|
||||
): Promise<JsonApiDocument<T>> {
|
||||
const { id, contentType, include, langcode, includeUnpublished, previewContext } = params;
|
||||
const queryParams: JsonApiParams = include ? { include } : {};
|
||||
const queryString = this.buildQueryString(queryParams, langcode, includeUnpublished);
|
||||
const url = `${this.baseUrl}/node/${contentType}/${id}${queryString}`;
|
||||
|
||||
return this.fetchJson<JsonApiDocument<T>>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get taxonomy terms by vocabulary (no preview support for taxonomy)
|
||||
*/
|
||||
async getTaxonomyTerms(
|
||||
params: TaxonomyParams
|
||||
): Promise<JsonApiDocument<DrupalTaxonomyTermAttributes>> {
|
||||
const { vocabulary, langcode } = params;
|
||||
const queryString = langcode ? this.buildQueryString({}, langcode) : "";
|
||||
const url = `${this.baseUrl}/taxonomy_term/${vocabulary}${queryString}`;
|
||||
|
||||
return this.fetchJson<JsonApiDocument<DrupalTaxonomyTermAttributes>>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow pagination links
|
||||
*/
|
||||
async getNextPage<T extends object>(links: JsonApiLinks): Promise<JsonApiDocument<T> | null> {
|
||||
if (!links.next) return null;
|
||||
|
||||
// Handle both string and object link formats
|
||||
const nextUrl = typeof links.next === "string" ? links.next : links.next;
|
||||
if (!nextUrl) return null;
|
||||
|
||||
// If relative link, prepend base URL
|
||||
const url = nextUrl.startsWith("/") ? `${this.baseUrl}${nextUrl}` : nextUrl;
|
||||
|
||||
return this.fetchJson<JsonApiDocument<T>>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Extract data array from document
|
||||
*/
|
||||
extractData<T extends object>(document: JsonApiDocument<T>): JsonApiResource<T>[] {
|
||||
return Array.isArray(document.data) ? document.data : [document.data];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get attribute from resource
|
||||
*/
|
||||
getAttribute<T extends DrupalNodeAttributes>(resource: JsonApiResource<T>): T {
|
||||
return resource.attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check for errors in response
|
||||
*/
|
||||
hasErrors(document: JsonApiDocument): boolean {
|
||||
return document.errors !== undefined && document.errors.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview URL for a specific entity
|
||||
*/
|
||||
getPreviewUrl(entityType: string, entityId: string): string {
|
||||
const baseUrl = window.location.origin;
|
||||
return `${baseUrl}/preview?entity_type=${entityType}&entity_id=${entityId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Drupal preview link format
|
||||
*/
|
||||
generateDrupalPreviewLink(entityType: string, entityId: string): string {
|
||||
const baseUrl = this.baseUrl.replace('/jsonapi', '');
|
||||
return `${baseUrl}/node/${entityType}/${entityId}/preview`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for Drupal API errors
|
||||
*/
|
||||
export class DrupalApiError extends Error {
|
||||
status: number;
|
||||
errors: JsonApiError[];
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
status: number,
|
||||
errors: JsonApiError[] = []
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DrupalApiError";
|
||||
this.status = status;
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance with default configuration
|
||||
export const previewDrupalService = new PreviewDrupalService();
|
||||
103
apps/vdn-static/src/stores/user.ts
Normal file
103
apps/vdn-static/src/stores/user.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface User {
|
||||
uid: string
|
||||
name: string
|
||||
mail: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
interface AuthTokens {
|
||||
access_token: string
|
||||
refresh_token?:string
|
||||
expires_at?: number
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// State
|
||||
const user = ref<User | null>(null)
|
||||
const tokens = ref<AuthTokens | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const isAuthenticated = computed(() => !!tokens.value?.access_token && !!user.value)
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
if (!user.value?.roles) return false
|
||||
return user.value.roles.includes('administrator') || user.value.roles.includes('admin')
|
||||
})
|
||||
|
||||
// Initialize from localStorage
|
||||
const initializeFromStorage = () => {
|
||||
try {
|
||||
const storedUser = localStorage.getItem('viossa_user')
|
||||
const storedTokens = localStorage.getItem('viossa_tokens')
|
||||
|
||||
if (storedUser) {
|
||||
user.value = JSON.parse(storedUser)
|
||||
}
|
||||
|
||||
if (storedTokens) {
|
||||
tokens.value = JSON.parse(storedTokens)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load auth state from localStorage:', e)
|
||||
// Clear corrupted data
|
||||
localStorage.removeItem('viossa_user')
|
||||
localStorage.removeItem('viossa_tokens')
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
const setUser = (newUser: User | null) => {
|
||||
user.value = newUser
|
||||
if (newUser) {
|
||||
localStorage.setItem('viossa_user', JSON.stringify(newUser))
|
||||
} else {
|
||||
localStorage.removeItem('viossa_user')
|
||||
}
|
||||
}
|
||||
|
||||
const setTokens = (newTokens: AuthTokens | null) => {
|
||||
tokens.value = newTokens
|
||||
if (newTokens) {
|
||||
localStorage.setItem('viossa_tokens', JSON.stringify(newTokens))
|
||||
} else {
|
||||
localStorage.removeItem('viossa_tokens')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
setUser(null)
|
||||
setTokens(null)
|
||||
error.value = null
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// Initialize on store creation
|
||||
initializeFromStorage()
|
||||
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
tokens,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// Computed
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
|
||||
// Actions
|
||||
setUser,
|
||||
setTokens,
|
||||
logout,
|
||||
clearError,
|
||||
initializeFromStorage,
|
||||
}
|
||||
})
|
||||
144
apps/vdn-static/src/types/route-meta.d.ts
vendored
Normal file
144
apps/vdn-static/src/types/route-meta.d.ts
vendored
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import type { MetaConfig } from '../composables/useMeta'
|
||||
|
||||
/**
|
||||
* Extended route meta interface for SEO and meta tag configuration
|
||||
*
|
||||
* This extends Vue Router's built-in RouteMeta interface to include
|
||||
* SEO-related fields for meta tag generation.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a page component with definePage macro
|
||||
* definePage({
|
||||
* meta: {
|
||||
* title: 'Home Page',
|
||||
* description: 'Welcome to our website',
|
||||
* og: {
|
||||
* type: 'website',
|
||||
* image: '/og-home.jpg'
|
||||
* }
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/**
|
||||
* Page title for meta tag and document.title
|
||||
* Can be static string or a function that receives the route
|
||||
*/
|
||||
title?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
|
||||
/**
|
||||
* Meta description for SEO
|
||||
* Can be static string or a function that receives the route
|
||||
*/
|
||||
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
|
||||
/**
|
||||
* Meta keywords (comma-separated string or array)
|
||||
*/
|
||||
keywords?: string | string[] | ((route: RouteLocationNormalizedLoaded) => string | string[])
|
||||
|
||||
/**
|
||||
* Author meta tag
|
||||
*/
|
||||
author?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
|
||||
/**
|
||||
* Canonical URL for the page
|
||||
*/
|
||||
canonical?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
|
||||
/**
|
||||
* OpenGraph configuration for social media sharing
|
||||
*/
|
||||
og?: MetaConfig['og']
|
||||
|
||||
/**
|
||||
* Twitter Card configuration for Twitter sharing
|
||||
*/
|
||||
twitter?: MetaConfig['twitter']
|
||||
|
||||
/**
|
||||
* Custom meta tags for specialized use cases
|
||||
*/
|
||||
custom?: MetaConfig['custom']
|
||||
|
||||
/**
|
||||
* Locale-specific meta configuration
|
||||
* Allows different meta content based on current locale
|
||||
*/
|
||||
localeMeta?: {
|
||||
[locale: string]: {
|
||||
title?: string
|
||||
description?: string
|
||||
keywords?: string | string[]
|
||||
author?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this route requires SSR
|
||||
*/
|
||||
ssr?: boolean
|
||||
|
||||
/**
|
||||
* Whether to index this page for search engines
|
||||
*/
|
||||
index?: boolean
|
||||
|
||||
/**
|
||||
* Whether to follow links on this page
|
||||
*/
|
||||
follow?: boolean
|
||||
|
||||
/**
|
||||
* Robots meta tag value
|
||||
*/
|
||||
robots?: string
|
||||
|
||||
/**
|
||||
* Custom hreflang tags for international SEO
|
||||
*/
|
||||
hreflang?: {
|
||||
[locale: string]: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper type for extracting route meta values
|
||||
*/
|
||||
export type RouteMetaValue<T> = T | ((route: RouteLocationNormalizedLoaded) => T)
|
||||
|
||||
/**
|
||||
* Helper function type for dynamic meta values
|
||||
*/
|
||||
export type MetaValueFunction<T> = (route: RouteLocationNormalizedLoaded) => T
|
||||
|
||||
/**
|
||||
* Type guard for checking if a meta value is a function
|
||||
*/
|
||||
export function isMetaFunction<T>(
|
||||
value: RouteMetaValue<T>
|
||||
): value is MetaValueFunction<T> {
|
||||
return typeof value === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for route with extended meta
|
||||
*/
|
||||
export interface RouteWithMeta {
|
||||
meta: RouteMeta
|
||||
name?: string | symbol
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for pages that support meta configuration
|
||||
*/
|
||||
export interface PageMetaConfig {
|
||||
meta?: RouteMeta
|
||||
}
|
||||
252
apps/vdn-static/src/utils/drupal-meta.ts
Normal file
252
apps/vdn-static/src/utils/drupal-meta.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/**
|
||||
* Drupal Content Type Meta Tag Integration
|
||||
*
|
||||
* This module provides utilities for generating meta tags from Drupal content types
|
||||
* via the JSON:API responses.
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMeta, type MetaConfig } from '../composables/useMeta'
|
||||
import type {
|
||||
ArticleNode,
|
||||
PageNode,
|
||||
LearningResourceNode,
|
||||
DrupalNodeAttributes
|
||||
} from '../types/drupal'
|
||||
|
||||
/**
|
||||
* Generates meta configuration from a Drupal node
|
||||
*
|
||||
* @param node - Drupal node from JSON:API response
|
||||
* @param baseUrl - Base URL for canonical links
|
||||
* @returns Meta configuration object
|
||||
*/
|
||||
export function generateDrupalNodeMeta(
|
||||
node: ArticleNode | PageNode | LearningResourceNode,
|
||||
baseUrl: string = 'https://viossa.net'
|
||||
): MetaConfig {
|
||||
const { attributes } = node
|
||||
const path = attributes.path?.alias || `node/${node.id}`
|
||||
|
||||
const meta: MetaConfig = {
|
||||
title: attributes.title,
|
||||
description: attributes.body?.summary || generateExcerpt(attributes.body?.processed || ''),
|
||||
canonical: `${baseUrl}${path}`,
|
||||
author: 'Viossa Team', // Could be extracted from relationships if available
|
||||
og: {
|
||||
type: 'article',
|
||||
title: attributes.title,
|
||||
description: attributes.body?.summary || generateExcerpt(attributes.body?.processed || ''),
|
||||
url: `${baseUrl}${path}`,
|
||||
image: '/images/og-default.jpg', // Could be extracted from relationships
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: attributes.title,
|
||||
description: attributes.body?.summary || generateExcerpt(attributes.body?.processed || ''),
|
||||
image: '/images/twitter-default.jpg',
|
||||
},
|
||||
custom: [
|
||||
{
|
||||
property: 'article:published_time',
|
||||
content: attributes.created,
|
||||
},
|
||||
{
|
||||
property: 'article:modified_time',
|
||||
content: attributes.changed,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Add sticky/promote as robots meta
|
||||
if (attributes.sticky || attributes.promote) {
|
||||
meta.custom?.push({
|
||||
name: 'robots',
|
||||
content: 'index, follow',
|
||||
})
|
||||
}
|
||||
|
||||
// Add status-based robots meta
|
||||
if (!attributes.status) {
|
||||
meta.custom?.push({
|
||||
name: 'robots',
|
||||
content: 'noindex, nofollow',
|
||||
})
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates meta configuration specifically for Article content type
|
||||
*
|
||||
* @param node - Article node from JSON:API
|
||||
* @param baseUrl - Base URL for canonical links
|
||||
* @returns Meta configuration object
|
||||
*/
|
||||
export function generateArticleContentMeta(
|
||||
node: ArticleNode,
|
||||
baseUrl: string = 'https://viossa.net'
|
||||
): MetaConfig {
|
||||
const baseMeta = generateDrupalNodeMeta(node, baseUrl)
|
||||
|
||||
return {
|
||||
...baseMeta,
|
||||
og: {
|
||||
...baseMeta.og,
|
||||
type: 'article',
|
||||
},
|
||||
custom: [
|
||||
...(baseMeta.custom || []),
|
||||
// Article-specific meta tags
|
||||
{
|
||||
property: 'article:section',
|
||||
content: 'Language Learning',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates meta configuration specifically for Page content type
|
||||
*
|
||||
* @param node - Page node from JSON:API
|
||||
* @param baseUrl - Base URL for canonical links
|
||||
* @returns Meta configuration object
|
||||
*/
|
||||
export function generatePageContentMeta(
|
||||
node: PageNode,
|
||||
baseUrl: string = 'https://viossa.net'
|
||||
): MetaConfig {
|
||||
const baseMeta = generateDrupalNodeMeta(node, baseUrl)
|
||||
|
||||
return {
|
||||
...baseMeta,
|
||||
og: {
|
||||
...baseMeta.og,
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates meta configuration specifically for Learning Resource content type
|
||||
*
|
||||
* @param node - Learning Resource node from JSON:API
|
||||
* @param baseUrl - Base URL for canonical links
|
||||
* @returns Meta configuration object
|
||||
*/
|
||||
export function generateLearningResourceMeta(
|
||||
node: LearningResourceNode,
|
||||
baseUrl: string = 'https://viossa.net'
|
||||
): MetaConfig {
|
||||
const baseMeta = generateDrupalNodeMeta(node, baseUrl)
|
||||
|
||||
return {
|
||||
...baseMeta,
|
||||
title: `${baseMeta.title} - Viossa Resources`,
|
||||
og: {
|
||||
...baseMeta.og,
|
||||
type: 'article',
|
||||
},
|
||||
custom: [
|
||||
...(baseMeta.custom || []),
|
||||
// Learning resource-specific meta tags
|
||||
{
|
||||
name: 'resource-type',
|
||||
content: 'learning-material',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to generate an excerpt from HTML content
|
||||
*
|
||||
* @param html - HTML content string
|
||||
* @param maxLength - Maximum length of excerpt (default: 160)
|
||||
* @returns Plain text excerpt
|
||||
*/
|
||||
function generateExcerpt(html: string, maxLength: number = 160): string {
|
||||
// Strip HTML tags
|
||||
const text = html.replace(/<[^>]*>/g, '')
|
||||
|
||||
// Decode HTML entities
|
||||
const decoded = text
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
|
||||
// Trim to max length
|
||||
if (decoded.length <= maxLength) {
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Truncate at word boundary
|
||||
const truncated = decoded.substring(0, maxLength)
|
||||
const lastSpace = truncated.lastIndexOf(' ')
|
||||
|
||||
return truncated.substring(0, lastSpace > 0 ? lastSpace : maxLength) + '...'
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for fetching and applying Drupal node meta tags
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <script setup lang="ts">
|
||||
* import { useDrupalNodeMeta } from '@/composables/drupal-meta'
|
||||
* import { ref, watch } from 'vue'
|
||||
*
|
||||
* const props = defineProps<{ nodeId: string }>()
|
||||
* const node = ref(null)
|
||||
*
|
||||
* // Fetch node data from API
|
||||
* // ...
|
||||
*
|
||||
* // Apply meta tags when node is loaded
|
||||
* useDrupalNodeMeta(node)
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useDrupalNodeMeta(
|
||||
nodeResult: { value: ArticleNode | PageNode | LearningResourceNode | null },
|
||||
baseUrl?: string
|
||||
) {
|
||||
const route = useRoute()
|
||||
|
||||
// Determine which meta generator to use based on node type
|
||||
const getMetaGenerator = (node: ArticleNode | PageNode | LearningResourceNode) => {
|
||||
switch (node.type) {
|
||||
case 'node--article':
|
||||
return generateArticleContentMeta
|
||||
case 'node--page':
|
||||
return generatePageContentMeta
|
||||
case 'node--learning_resource':
|
||||
return generateLearningResourceMeta
|
||||
default:
|
||||
return generateDrupalNodeMeta
|
||||
}
|
||||
}
|
||||
|
||||
// Compute meta config based on current node
|
||||
const metaConfig = computed(() => {
|
||||
const node = nodeResult.value
|
||||
if (!node) {
|
||||
return {
|
||||
title: 'Loading...',
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
const generator = getMetaGenerator(node)
|
||||
return generator(node, baseUrl)
|
||||
})
|
||||
|
||||
// Apply meta tags
|
||||
useMeta(metaConfig, route)
|
||||
}
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
import {
|
||||
watch,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
type Ref,
|
||||
type ComputedRef,
|
||||
unref,
|
||||
nextTick,
|
||||
} from 'vue'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
/**
|
||||
* Meta tag configuration interface
|
||||
*/
|
||||
export interface MetaConfig {
|
||||
/** Page title */
|
||||
title?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
/** Meta description */
|
||||
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
/** Meta keywords */
|
||||
keywords?: string | string[] | ((route: RouteLocationNormalizedLoaded) => string | string[])
|
||||
/** Author meta tag */
|
||||
author?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
/** Canonical URL */
|
||||
canonical?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
/** OpenGraph configuration */
|
||||
og?: OpenGraphConfig | ((route: RouteLocationNormalizedLoaded) => OpenGraphConfig)
|
||||
/** Twitter Card configuration */
|
||||
twitter?: TwitterCardConfig | ((route: RouteLocationNormalizedLoaded) => TwitterCardConfig)
|
||||
/** Custom meta tags */
|
||||
custom?: CustomMetaTag[] | ((route: RouteLocationNormalizedLoaded) => CustomMetaTag[])
|
||||
/** Whether to auto-append locale to title */
|
||||
appendLocale?: boolean
|
||||
/** Title template for formatting */
|
||||
titleTemplate?: (title: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenGraph meta tag configuration
|
||||
*/
|
||||
export interface OpenGraphConfig {
|
||||
type?: 'website' | 'article' | 'product' | 'profile' | 'book' | 'video' | 'music'
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
url?: string
|
||||
siteName?: string
|
||||
locale?: string
|
||||
localeAlternate?: string[]
|
||||
video?: string
|
||||
audio?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitter Card meta tag configuration
|
||||
*/
|
||||
export interface TwitterCardConfig {
|
||||
card?: 'summary' | 'summary_large_image' | 'app' | 'player'
|
||||
site?: string
|
||||
creator?: string
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
imageAlt?: string
|
||||
player?: string
|
||||
playerWidth?: number
|
||||
playerHeight?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom meta tag definition
|
||||
*/
|
||||
export interface CustomMetaTag {
|
||||
name?: string
|
||||
property?: string
|
||||
content: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
charset?: string
|
||||
httpEquiv?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware meta tag content
|
||||
*/
|
||||
export interface LocaleAwareMeta {
|
||||
[locale: string]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta tag element with tracking
|
||||
*/
|
||||
interface ManagedTag {
|
||||
element: HTMLMetaElement | HTMLTitleElement | HTMLLinkElement
|
||||
type: 'title' | 'meta' | 'link'
|
||||
}
|
||||
|
||||
/**
|
||||
* Default meta configuration
|
||||
*/
|
||||
const DEFAULT_META: Partial<MetaConfig> = {
|
||||
appendLocale: false,
|
||||
titleTemplate: (title) => title,
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta tag management composable for Vue.js applications
|
||||
*
|
||||
* This composable manages all aspects of meta tag generation and updates:
|
||||
* - Standard HTML meta tags (title, description, keywords, author, canonical)
|
||||
* - OpenGraph tags for social media sharing
|
||||
* - Twitter Card tags for Twitter sharing
|
||||
* - Custom meta tags for specialized use cases
|
||||
* - i18n integration for multi-language support
|
||||
* - SSR support for server-side rendering
|
||||
* - Automatic reactive updates
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage
|
||||
* useMeta({
|
||||
* title: 'Home Page',
|
||||
* description: 'Welcome to our website',
|
||||
* })
|
||||
*
|
||||
* // With dynamic route-based content
|
||||
* useMeta({
|
||||
* title: (route) => `Resource: ${route.params.id}`,
|
||||
* description: 'Learning resources and tutorials',
|
||||
* og: {
|
||||
* type: 'article',
|
||||
* image: '/default-og-image.jpg'
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // With i18n integration
|
||||
* useMeta({
|
||||
* title: { 'en-US': 'Welcome', 'es-LA': 'Bienvenido' },
|
||||
* description: { 'en-US': 'Welcome to our site', 'es-LA': 'Bienvenido a nuestro sitio' }
|
||||
* }, locale)
|
||||
* ```
|
||||
*
|
||||
* @param config - Meta configuration object
|
||||
* @param route - Current route (optional, auto-injected)
|
||||
* @param locale - Current locale reference (optional)
|
||||
* @returns Cleanup function to remove managed meta tags
|
||||
*/
|
||||
export function useMeta(
|
||||
config: MetaConfig | Ref<MetaConfig> | ComputedRef<MetaConfig>,
|
||||
route?: RouteLocationNormalizedLoaded | Ref<RouteLocationNormalizedLoaded>,
|
||||
locale?: Ref<string>
|
||||
): () => void {
|
||||
// Track managed elements for cleanup
|
||||
const managedElements: ManagedTag[] = []
|
||||
|
||||
/**
|
||||
* Creates or updates a meta element
|
||||
*/
|
||||
const setMetaTag = (
|
||||
name?: string,
|
||||
property?: string,
|
||||
content?: string,
|
||||
charset?: string,
|
||||
httpEquiv?: string
|
||||
): void => {
|
||||
if (!content && !charset) return
|
||||
|
||||
// Find existing tag or create new one
|
||||
let element: HTMLMetaElement | null = null
|
||||
|
||||
if (name) {
|
||||
element = document.querySelector(`meta[name="${name}"]`)
|
||||
} else if (property) {
|
||||
element = document.querySelector(`meta[property="${property}"]`)
|
||||
} else if (charset) {
|
||||
element = document.querySelector('meta[charset]')
|
||||
} else if (httpEquiv) {
|
||||
element = document.querySelector(`meta[http-equiv="${httpEquiv}"]`)
|
||||
}
|
||||
|
||||
if (!element) {
|
||||
element = document.createElement('meta')
|
||||
if (name) element.setAttribute('name', name)
|
||||
if (property) element.setAttribute('property', property)
|
||||
if (charset) element.setAttribute('charset', charset)
|
||||
if (httpEquiv) element.setAttribute('http-equiv', httpEquiv)
|
||||
document.head.appendChild(element)
|
||||
managedElements.push({ element, type: 'meta' })
|
||||
}
|
||||
|
||||
if (content) {
|
||||
element.setAttribute('content', content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates a link element
|
||||
*/
|
||||
const setLinkTag = (rel: string, href: string): void => {
|
||||
if (!href) return
|
||||
|
||||
let element: HTMLLinkElement | null = document.querySelector(`link[rel="${rel}"]`)
|
||||
|
||||
if (!element) {
|
||||
element = document.createElement('link')
|
||||
element.setAttribute('rel', rel)
|
||||
document.head.appendChild(element)
|
||||
managedElements.push({ element, type: 'link' })
|
||||
}
|
||||
|
||||
element.setAttribute('href', href)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the page title
|
||||
*/
|
||||
const setTitle = (title: string): void => {
|
||||
if (!title) return
|
||||
|
||||
let titleElement: HTMLTitleElement | null = document.querySelector('title')
|
||||
|
||||
if (!titleElement) {
|
||||
titleElement = document.createElement('title')
|
||||
document.head.appendChild(titleElement)
|
||||
managedElements.push({ element: titleElement, type: 'title' })
|
||||
}
|
||||
|
||||
const template = unref(config).titleTemplate || DEFAULT_META.titleTemplate!
|
||||
titleElement.textContent = template(title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a meta value that can be static, reactive, or a function
|
||||
*/
|
||||
const resolveValue = <T>(
|
||||
value: T | ((route: RouteLocationNormalizedLoaded) => T) | undefined,
|
||||
routeValue: RouteLocationNormalizedLoaded
|
||||
): T | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
return typeof value === 'function' ? value(routeValue) : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves locale-aware content
|
||||
*/
|
||||
const resolveLocaleContent = (
|
||||
content: string | LocaleAwareMeta | undefined,
|
||||
currentLocale?: string
|
||||
): string | undefined => {
|
||||
if (content === undefined) return undefined
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return content
|
||||
}
|
||||
|
||||
// It's a locale-aware object
|
||||
if (currentLocale && content[currentLocale]) {
|
||||
return content[currentLocale]
|
||||
}
|
||||
|
||||
// Fallback to first available locale or default
|
||||
return content['en-US'] || Object.values(content)[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all meta tags
|
||||
*/
|
||||
const updateMeta = (): void => {
|
||||
const configValue = unref(config)
|
||||
const routeValue = route ? unref(route) : null
|
||||
|
||||
// Resolve standard meta tags
|
||||
const title = resolveValue(configValue.title, routeValue!)
|
||||
const description = resolveValue(configValue.description, routeValue!)
|
||||
const keywords = resolveValue(configValue.keywords, routeValue!)
|
||||
const author = resolveValue(configValue.author, routeValue!)
|
||||
const canonical = resolveValue(configValue.canonical, routeValue!)
|
||||
|
||||
// Set standard meta tags
|
||||
if (title) {
|
||||
const currentLocale = locale?.value
|
||||
const localizedTitle = resolveLocaleContent(title, currentLocale)
|
||||
if (localizedTitle) {
|
||||
setTitle(localizedTitle)
|
||||
}
|
||||
}
|
||||
|
||||
if (description) {
|
||||
const currentLocale = locale?.value
|
||||
const localizedDesc = resolveLocaleContent(description, currentLocale)
|
||||
if (localizedDesc) {
|
||||
setMetaTag('description', undefined, localizedDesc)
|
||||
}
|
||||
}
|
||||
|
||||
if (keywords) {
|
||||
const keywordsValue = Array.isArray(keywords) ? keywords.join(', ') : keywords
|
||||
setMetaTag('keywords', undefined, keywordsValue)
|
||||
}
|
||||
|
||||
if (author) {
|
||||
setMetaTag('author', undefined, author)
|
||||
}
|
||||
|
||||
if (canonical) {
|
||||
setLinkTag('canonical', canonical)
|
||||
}
|
||||
|
||||
// Set OpenGraph tags
|
||||
const ogConfig = resolveValue(configValue.og, routeValue!)
|
||||
if (ogConfig) {
|
||||
setOpenGraphTags(ogConfig, locale?.value)
|
||||
}
|
||||
|
||||
// Set Twitter Card tags
|
||||
const twitterConfig = resolveValue(configValue.twitter, routeValue!)
|
||||
if (twitterConfig) {
|
||||
setTwitterCardTags(twitterConfig, locale?.value)
|
||||
}
|
||||
|
||||
// Set custom meta tags
|
||||
const customTags = resolveValue(configValue.custom, routeValue!)
|
||||
if (customTags && Array.isArray(customTags)) {
|
||||
customTags.forEach((tag) => {
|
||||
const content = typeof tag.content === 'function'
|
||||
? tag.content(routeValue!)
|
||||
: tag.content
|
||||
if (content) {
|
||||
setMetaTag(tag.name, tag.property, content, tag.charset, tag.httpEquiv)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets OpenGraph meta tags
|
||||
*/
|
||||
const setOpenGraphTags = (config: OpenGraphConfig, currentLocale?: string): void => {
|
||||
if (config.type) setMetaTag(undefined, 'og:type', config.type)
|
||||
if (config.title) {
|
||||
const localized = resolveLocaleContent(config.title, currentLocale)
|
||||
if (localized) setMetaTag(undefined, 'og:title', localized)
|
||||
}
|
||||
if (config.description) {
|
||||
const localized = resolveLocaleContent(config.description, currentLocale)
|
||||
if (localized) setMetaTag(undefined, 'og:description', localized)
|
||||
}
|
||||
if (config.image) setMetaTag(undefined, 'og:image', config.image)
|
||||
if (config.url) setMetaTag(undefined, 'og:url', config.url)
|
||||
if (config.siteName) setMetaTag(undefined, 'og:site_name', config.siteName)
|
||||
if (config.locale) setMetaTag(undefined, 'og:locale', config.locale)
|
||||
if (config.localeAlternate) {
|
||||
config.localeAlternate.forEach((loc) => {
|
||||
setMetaTag(undefined, 'og:locale:alternate', loc)
|
||||
})
|
||||
}
|
||||
if (config.video) setMetaTag(undefined, 'og:video', config.video)
|
||||
if (config.audio) setMetaTag(undefined, 'og:audio', config.audio)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets Twitter Card meta tags
|
||||
*/
|
||||
const setTwitterCardTags = (config: TwitterCardConfig, currentLocale?: string): void => {
|
||||
if (config.card) setMetaTag('twitter:card', undefined, config.card)
|
||||
if (config.site) setMetaTag('twitter:site', undefined, config.site)
|
||||
if (config.creator) setMetaTag('twitter:creator', undefined, config.creator)
|
||||
if (config.title) {
|
||||
const localized = resolveLocaleContent(config.title, currentLocale)
|
||||
if (localized) setMetaTag('twitter:title', undefined, localized)
|
||||
}
|
||||
if (config.description) {
|
||||
const localized = resolveLocaleContent(config.description, currentLocale)
|
||||
if (localized) setMetaTag('twitter:description', undefined, localized)
|
||||
}
|
||||
if (config.image) setMetaTag('twitter:image', undefined, config.image)
|
||||
if (config.imageAlt) setMetaTag('twitter:image:alt', undefined, config.imageAlt)
|
||||
if (config.player) setMetaTag('twitter:player', undefined, config.player)
|
||||
if (config.playerWidth) setMetaTag('twitter:player:width', undefined, String(config.playerWidth))
|
||||
if (config.playerHeight) setMetaTag('twitter:player:height', undefined, String(config.playerHeight))
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup function to remove managed meta tags
|
||||
*/
|
||||
const cleanup = (): void => {
|
||||
managedElements.forEach(({ element }) => {
|
||||
if (element.parentNode) {
|
||||
element.parentNode.removeChild(element)
|
||||
}
|
||||
})
|
||||
managedElements.length = 0
|
||||
}
|
||||
|
||||
// Setup reactive watcher
|
||||
let stopWatch: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
// Initial update
|
||||
nextTick(updateMeta)
|
||||
|
||||
// Watch for changes
|
||||
stopWatch = watch(
|
||||
() => [unref(config), route ? unref(route) : null, locale?.value],
|
||||
() => {
|
||||
updateMeta()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
if (stopWatch) {
|
||||
stopWatch()
|
||||
}
|
||||
cleanup()
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to create meta configuration for a route
|
||||
*/
|
||||
export function createRouteMeta(
|
||||
meta: MetaConfig
|
||||
): MetaConfig {
|
||||
return { ...DEFAULT_META, ...meta }
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to merge meta configurations
|
||||
*/
|
||||
export function mergeMeta(
|
||||
base: MetaConfig,
|
||||
override: Partial<MetaConfig>
|
||||
): MetaConfig {
|
||||
return { ...base, ...override }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue