Enhance SEO and feedback features across the application
- Updated index.html with improved meta tags for SEO, including author and theme color. - Added a feedback dialog in ImprintContainer.vue for user feedback submission. - Refactored LoginForm.vue to utilize a utility for cookie management, simplifying profile persistence. - Introduced new routes and schemas for feedback in the router and server, enhancing SEO and user experience. - Improved ChatView.vue with better error handling and command table display. - Implemented feedback API endpoints in server routes for managing user feedback submissions and admin access. These changes collectively improve the application's SEO, user interaction, and feedback management capabilities.
This commit is contained in:
@@ -6,7 +6,9 @@
|
||||
<title>SingleChat - Chat, Single-Chat und Bildaustausch</title>
|
||||
<meta name="description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch. Chatte mit Menschen aus aller Welt, finde neue Kontakte und teile Erinnerungen sicher und komfortabel.">
|
||||
<meta name="keywords" content="Chat, Single-Chat, Bildaustausch, Online-Chat, Singles, Kontakte, Community">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
|
||||
<meta name="author" content="SingleChat">
|
||||
<meta name="theme-color" content="#2f6f46">
|
||||
|
||||
<!-- Open Graph Tags -->
|
||||
<meta property="og:title" content="SingleChat - Chat, Single-Chat und Bildaustausch">
|
||||
@@ -14,9 +16,11 @@
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://ypchat.net/">
|
||||
<meta property="og:image" content="https://ypchat.net/static/favicon.png">
|
||||
<meta property="og:site_name" content="SingleChat">
|
||||
<meta property="og:locale" content="de_DE">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="SingleChat - Chat, Single-Chat und Bildaustausch">
|
||||
<meta name="twitter:description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.">
|
||||
<meta name="twitter:image" content="https://ypchat.net/static/favicon.png">
|
||||
@@ -26,10 +30,10 @@
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<script type="application/ld+json" id="seo-json-ld">{"@context":"https://schema.org","@type":"WebSite","name":"SingleChat","url":"https://ypchat.net/","description":"Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.","inLanguage":"de-DE"}</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
482
client/src/components/FeedbackPanel.vue
Normal file
482
client/src/components/FeedbackPanel.vue
Normal file
@@ -0,0 +1,482 @@
|
||||
<template>
|
||||
<div :class="['feedback-panel-shell', { 'feedback-panel-embedded': embedded }]">
|
||||
<section v-if="showHero" class="feedback-hero">
|
||||
<p class="feedback-eyebrow">Oeffentliches Feedback</p>
|
||||
<h2>Meinungen, Hinweise und Verbesserungsvorschlaege</h2>
|
||||
<p>
|
||||
Alle Kommentare sind oeffentlich sichtbar. Pflicht ist nur der Kommentar selbst, alle weiteren Angaben sind optional.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="feedback-layout">
|
||||
<section class="feedback-form-panel">
|
||||
<h3>Feedback senden</h3>
|
||||
<form class="feedback-form" @submit.prevent="submitFeedback">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input v-model="form.name" type="text" maxlength="80">
|
||||
</label>
|
||||
<label>
|
||||
<span>Alter</span>
|
||||
<input v-model="form.age" type="number" min="18" max="120">
|
||||
</label>
|
||||
<label>
|
||||
<span>Land</span>
|
||||
<select v-model="form.country">
|
||||
<option value="">{{ $t('label_country') }}</option>
|
||||
<option v-for="(code, name) in countries" :key="code" :value="name">
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Geschlecht</span>
|
||||
<select v-model="form.gender">
|
||||
<option value="">{{ $t('label_gender') }}</option>
|
||||
<option value="F">{{ $t('gender_female') }}</option>
|
||||
<option value="M">{{ $t('gender_male') }}</option>
|
||||
<option value="P">{{ $t('gender_pair') }}</option>
|
||||
<option value="TF">{{ $t('gender_trans_mf') }}</option>
|
||||
<option value="TM">{{ $t('gender_trans_fm') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="feedback-form-comment">
|
||||
<span>Kommentar *</span>
|
||||
<textarea v-model="form.comment" rows="7" required maxlength="4000"></textarea>
|
||||
</label>
|
||||
<button type="submit" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? 'Wird gesendet...' : 'Feedback absenden' }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="submitMessage" class="feedback-success">{{ submitMessage }}</p>
|
||||
<p v-if="submitError" class="feedback-error">{{ submitError }}</p>
|
||||
</section>
|
||||
|
||||
<section class="feedback-list-panel">
|
||||
<div class="feedback-list-header">
|
||||
<div>
|
||||
<h3>Kommentare</h3>
|
||||
<p>{{ feedbackItems.length }} Eintraege</p>
|
||||
</div>
|
||||
<div class="feedback-admin">
|
||||
<template v-if="adminStatus.authenticated">
|
||||
<span class="feedback-admin-badge">Admin: {{ adminStatus.username }}</span>
|
||||
<button type="button" class="feedback-admin-button" @click="logoutAdmin">Logout</button>
|
||||
</template>
|
||||
<form v-else class="feedback-admin-form" @submit.prevent="loginAdmin">
|
||||
<input v-model="adminForm.username" type="text" placeholder="Admin-Name">
|
||||
<input v-model="adminForm.password" type="password" placeholder="Passwort">
|
||||
<button type="submit">Admin-Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="adminError" class="feedback-error">{{ adminError }}</p>
|
||||
|
||||
<div class="feedback-list">
|
||||
<article v-for="item in feedbackItems" :key="item.id" class="feedback-item">
|
||||
<header class="feedback-item-header">
|
||||
<div>
|
||||
<strong>{{ item.name || 'Anonym' }}</strong>
|
||||
<span v-if="formatMeta(item)" class="feedback-item-meta">{{ formatMeta(item) }}</span>
|
||||
</div>
|
||||
<div class="feedback-item-actions">
|
||||
<time :datetime="item.createdAt">{{ formatDate(item.createdAt) }}</time>
|
||||
<button
|
||||
v-if="adminStatus.authenticated"
|
||||
type="button"
|
||||
class="feedback-delete"
|
||||
@click="deleteFeedback(item.id)"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<p>{{ item.comment }}</p>
|
||||
</article>
|
||||
|
||||
<p v-if="feedbackItems.length === 0" class="feedback-empty">
|
||||
Noch kein Feedback vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import countryTranslations from '../i18n/countries.json';
|
||||
import { readProfileCookie } from '../utils/profileCookie';
|
||||
|
||||
const props = defineProps({
|
||||
showHero: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
embedded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const { locale } = useI18n();
|
||||
|
||||
const feedbackItems = ref([]);
|
||||
const isSubmitting = ref(false);
|
||||
const submitMessage = ref('');
|
||||
const submitError = ref('');
|
||||
const adminError = ref('');
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
age: '',
|
||||
country: '',
|
||||
gender: '',
|
||||
comment: ''
|
||||
});
|
||||
|
||||
const countriesRaw = ref({});
|
||||
|
||||
const countries = computed(() => {
|
||||
const translated = {};
|
||||
const translations = countryTranslations[locale.value] || countryTranslations.en || {};
|
||||
|
||||
for (const [englishName, code] of Object.entries(countriesRaw.value)) {
|
||||
translated[translations[englishName] || englishName] = code;
|
||||
}
|
||||
|
||||
const sorted = {};
|
||||
Object.keys(translated)
|
||||
.sort((a, b) => a.localeCompare(b, locale.value))
|
||||
.forEach((key) => {
|
||||
sorted[key] = translated[key];
|
||||
});
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const adminForm = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const adminStatus = ref({
|
||||
authenticated: false,
|
||||
username: null
|
||||
});
|
||||
|
||||
async function loadFeedback() {
|
||||
const response = await axios.get('/api/feedback', { withCredentials: true });
|
||||
feedbackItems.value = response.data.items || [];
|
||||
adminStatus.value.authenticated = !!response.data.admin;
|
||||
}
|
||||
|
||||
async function loadAdminStatus() {
|
||||
const response = await axios.get('/api/feedback/admin-status', { withCredentials: true });
|
||||
adminStatus.value = response.data;
|
||||
}
|
||||
|
||||
async function submitFeedback() {
|
||||
isSubmitting.value = true;
|
||||
submitMessage.value = '';
|
||||
submitError.value = '';
|
||||
|
||||
try {
|
||||
await axios.post('/api/feedback', form.value, { withCredentials: true });
|
||||
submitMessage.value = 'Danke. Dein Feedback wurde gespeichert.';
|
||||
const profile = readProfileCookie();
|
||||
form.value = {
|
||||
name: profile?.nickname || '',
|
||||
age: Number.isFinite(profile?.age) ? profile.age : '',
|
||||
country: profile?.country || '',
|
||||
gender: profile?.gender || '',
|
||||
comment: ''
|
||||
};
|
||||
await loadFeedback();
|
||||
} catch (error) {
|
||||
submitError.value = error.response?.data?.error || 'Feedback konnte nicht gespeichert werden.';
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAdmin() {
|
||||
adminError.value = '';
|
||||
try {
|
||||
const response = await axios.post('/api/feedback/admin-login', adminForm.value, { withCredentials: true });
|
||||
adminStatus.value = { authenticated: true, username: response.data.username };
|
||||
adminForm.value.password = '';
|
||||
} catch (error) {
|
||||
adminError.value = error.response?.data?.error || 'Admin-Login fehlgeschlagen.';
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutAdmin() {
|
||||
await axios.post('/api/feedback/admin-logout', {}, { withCredentials: true });
|
||||
adminStatus.value = { authenticated: false, username: null };
|
||||
}
|
||||
|
||||
async function deleteFeedback(id) {
|
||||
await axios.delete(`/api/feedback/${id}`, { withCredentials: true });
|
||||
await loadFeedback();
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return new Date(value).toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function formatMeta(item) {
|
||||
return [item.country, item.age, item.gender].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/countries');
|
||||
countriesRaw.value = response.data;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Länderliste:', error);
|
||||
}
|
||||
|
||||
const profile = readProfileCookie();
|
||||
if (profile) {
|
||||
form.value.name = profile.nickname || '';
|
||||
form.value.age = Number.isFinite(profile.age) ? profile.age : '';
|
||||
form.value.country = profile.country || '';
|
||||
form.value.gender = profile.gender || '';
|
||||
}
|
||||
|
||||
await Promise.all([loadFeedback(), loadAdminStatus()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feedback-panel-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.feedback-panel-embedded {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.feedback-hero {
|
||||
max-width: 960px;
|
||||
margin: 0 auto 20px;
|
||||
padding: 24px;
|
||||
border: 1px solid #d7dfd9;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(61, 134, 84, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(243, 247, 244, 0.92) 100%);
|
||||
}
|
||||
|
||||
.feedback-eyebrow {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #617067;
|
||||
}
|
||||
|
||||
.feedback-hero h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
color: #18201b;
|
||||
}
|
||||
|
||||
.feedback-hero p {
|
||||
margin: 0;
|
||||
color: #4e5b53;
|
||||
}
|
||||
|
||||
.feedback-layout {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.feedback-form-panel,
|
||||
.feedback-list-panel {
|
||||
border: 1px solid #d7dfd9;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 18px 40px rgba(31, 50, 39, 0.06);
|
||||
}
|
||||
|
||||
.feedback-form-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.feedback-list-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.feedback-form-panel h3,
|
||||
.feedback-list-panel h3 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.feedback-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feedback-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.feedback-form span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #536159;
|
||||
}
|
||||
|
||||
.feedback-form input,
|
||||
.feedback-form textarea,
|
||||
.feedback-admin-form input {
|
||||
width: 100%;
|
||||
border: 1px solid #d3ddd5;
|
||||
border-radius: 10px;
|
||||
background: #fbfdfb;
|
||||
padding: 10px 12px;
|
||||
color: #18201b;
|
||||
}
|
||||
|
||||
.feedback-form button,
|
||||
.feedback-admin-button,
|
||||
.feedback-admin-form button,
|
||||
.feedback-delete {
|
||||
height: 40px;
|
||||
border: 1px solid #295f3d;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #4a8d61 0%, #2c6240 100%);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.feedback-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.feedback-list-header p {
|
||||
margin: 4px 0 0;
|
||||
color: #66746b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.feedback-admin {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.feedback-admin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #e7f1ea;
|
||||
color: #245c3a;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feedback-admin-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.feedback-admin-form input {
|
||||
width: 140px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.feedback-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feedback-item {
|
||||
padding: 14px;
|
||||
border: 1px solid #dce4de;
|
||||
border-radius: 12px;
|
||||
background: #f9fbf9;
|
||||
}
|
||||
|
||||
.feedback-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feedback-item-header strong {
|
||||
display: block;
|
||||
color: #18201b;
|
||||
}
|
||||
|
||||
.feedback-item-meta,
|
||||
.feedback-item-actions time {
|
||||
font-size: 12px;
|
||||
color: #647168;
|
||||
}
|
||||
|
||||
.feedback-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.feedback-delete {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-color: #b74848;
|
||||
background: linear-gradient(180deg, #cd6161 0%, #a24040 100%);
|
||||
}
|
||||
|
||||
.feedback-item p,
|
||||
.feedback-empty,
|
||||
.feedback-success,
|
||||
.feedback-error {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.feedback-success {
|
||||
margin-top: 12px;
|
||||
color: #245c3a;
|
||||
}
|
||||
|
||||
.feedback-error {
|
||||
margin-top: 12px;
|
||||
color: #a24040;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.feedback-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.feedback-admin-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feedback-admin-form input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,16 @@
|
||||
<template>
|
||||
<div class="imprint-container">
|
||||
<a href="/partners">Partner</a>
|
||||
<a href="#" @click.prevent="showFeedback = true">Feedback</a>
|
||||
<a href="#" @click.prevent="showImprint = true">Impressum</a>
|
||||
|
||||
<div v-if="showFeedback" class="imprint-dialog" @click.self="showFeedback = false">
|
||||
<div class="feedback-dialog-content">
|
||||
<button class="close-button" @click="showFeedback = false">×</button>
|
||||
<FeedbackPanel :show-hero="false" :embedded="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showImprint" class="imprint-dialog" @click.self="showImprint = false">
|
||||
<div class="imprint-content">
|
||||
<button class="close-button" @click="showImprint = false">×</button>
|
||||
@@ -14,8 +22,10 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import FeedbackPanel from './FeedbackPanel.vue';
|
||||
|
||||
const showImprint = ref(false);
|
||||
const showFeedback = ref(false);
|
||||
|
||||
const imprintText = `
|
||||
<h1>Imprint</h1>
|
||||
@@ -75,6 +85,18 @@ Thanks for the flag icons to <a href="https://flagpedia.net">flagpedia.net</a>
|
||||
box-shadow: 0 24px 60px rgba(18, 26, 21, 0.18);
|
||||
}
|
||||
|
||||
.feedback-dialog-content {
|
||||
width: min(1100px, 96vw);
|
||||
max-height: 88vh;
|
||||
overflow: auto;
|
||||
background: #f4f7f5;
|
||||
border: 1px solid #d7dfd9;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
box-shadow: 0 24px 60px rgba(18, 26, 21, 0.18);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
|
||||
@@ -67,9 +67,7 @@ import axios from 'axios';
|
||||
import { useChatStore } from '../stores/chat';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import countryTranslations from '../i18n/countries.json';
|
||||
|
||||
const PROFILE_COOKIE_NAME = 'singlechat_profile';
|
||||
const PROFILE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
import { readProfileCookie, writeProfileCookie } from '../utils/profileCookie';
|
||||
|
||||
const { locale } = useI18n();
|
||||
const chatStore = useChatStore();
|
||||
@@ -114,52 +112,30 @@ watch([nickname, gender, age, country], () => {
|
||||
});
|
||||
|
||||
function restoreProfileFromCookie() {
|
||||
const cookieValue = readCookie(PROFILE_COOKIE_NAME);
|
||||
if (!cookieValue) return;
|
||||
const profile = readProfileCookie();
|
||||
if (!profile) return;
|
||||
|
||||
try {
|
||||
const profile = JSON.parse(decodeURIComponent(cookieValue));
|
||||
|
||||
if (typeof profile.nickname === 'string') {
|
||||
nickname.value = profile.nickname;
|
||||
}
|
||||
if (typeof profile.gender === 'string') {
|
||||
gender.value = profile.gender;
|
||||
}
|
||||
if (Number.isFinite(profile.age)) {
|
||||
age.value = profile.age;
|
||||
}
|
||||
if (typeof profile.country === 'string') {
|
||||
country.value = profile.country;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Profil-Cookie konnte nicht gelesen werden:', error);
|
||||
if (typeof profile.nickname === 'string') {
|
||||
nickname.value = profile.nickname;
|
||||
}
|
||||
if (typeof profile.gender === 'string') {
|
||||
gender.value = profile.gender;
|
||||
}
|
||||
if (Number.isFinite(profile.age)) {
|
||||
age.value = profile.age;
|
||||
}
|
||||
if (typeof profile.country === 'string') {
|
||||
country.value = profile.country;
|
||||
}
|
||||
}
|
||||
|
||||
function persistProfileToCookie() {
|
||||
const profile = {
|
||||
writeProfileCookie({
|
||||
nickname: nickname.value.trim(),
|
||||
gender: gender.value,
|
||||
age: Number(age.value) || 18,
|
||||
country: country.value
|
||||
};
|
||||
|
||||
document.cookie = [
|
||||
`${PROFILE_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(profile))}`,
|
||||
`Max-Age=${PROFILE_COOKIE_MAX_AGE}`,
|
||||
'Path=/',
|
||||
'SameSite=Lax'
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
function readCookie(name) {
|
||||
const prefix = `${name}=`;
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find(entry => entry.startsWith(prefix));
|
||||
|
||||
return cookie ? cookie.slice(prefix.length) : null;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
|
||||
@@ -2,6 +2,47 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
import ChatView from '../views/ChatView.vue';
|
||||
import PartnersView from '../views/PartnersView.vue';
|
||||
import MockupView from '../views/MockupView.vue';
|
||||
import FeedbackView from '../views/FeedbackView.vue';
|
||||
|
||||
const SITE_URL = 'https://ypchat.net';
|
||||
const DEFAULT_IMAGE = `${SITE_URL}/static/favicon.png`;
|
||||
|
||||
const homeSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'SingleChat',
|
||||
url: `${SITE_URL}/`,
|
||||
description: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.',
|
||||
inLanguage: 'de-DE'
|
||||
};
|
||||
|
||||
const partnersSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'Partner - SingleChat',
|
||||
url: `${SITE_URL}/partners`,
|
||||
description: 'Unsere Partner und befreundete Seiten. Entdecke weitere interessante Angebote und Communities.',
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'SingleChat',
|
||||
url: `${SITE_URL}/`
|
||||
},
|
||||
inLanguage: 'de-DE'
|
||||
};
|
||||
|
||||
const feedbackSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'Feedback - SingleChat',
|
||||
url: `${SITE_URL}/feedback`,
|
||||
description: 'Oeffentliche Rueckmeldungen, Meinungen und Verbesserungsvorschlaege zu SingleChat.',
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'SingleChat',
|
||||
url: `${SITE_URL}/`
|
||||
},
|
||||
inLanguage: 'de-DE'
|
||||
};
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -11,7 +52,13 @@ const routes = [
|
||||
meta: {
|
||||
title: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
||||
description: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch. Chatte mit Menschen aus aller Welt, finde neue Kontakte und teile Erinnerungen sicher und komfortabel.',
|
||||
keywords: 'Chat, Single-Chat, Bildaustausch, Online-Chat, Singles, Kontakte, Community'
|
||||
keywords: 'Chat, Single-Chat, Bildaustausch, Online-Chat, Singles, Kontakte, Community',
|
||||
ogTitle: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
||||
ogDescription: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.',
|
||||
ogType: 'website',
|
||||
image: DEFAULT_IMAGE,
|
||||
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||
schema: homeSchema
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -21,7 +68,29 @@ const routes = [
|
||||
meta: {
|
||||
title: 'Partner - SingleChat',
|
||||
description: 'Unsere Partner und befreundete Seiten. Entdecke weitere interessante Angebote und Communities.',
|
||||
keywords: 'Partner, Links, befreundete Seiten, Community'
|
||||
keywords: 'Partner, Links, befreundete Seiten, Community',
|
||||
ogTitle: 'Partner - SingleChat',
|
||||
ogDescription: 'Unsere Partner und befreundete Seiten.',
|
||||
ogType: 'website',
|
||||
image: DEFAULT_IMAGE,
|
||||
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||
schema: partnersSchema
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/feedback',
|
||||
name: 'feedback',
|
||||
component: FeedbackView,
|
||||
meta: {
|
||||
title: 'Feedback - SingleChat',
|
||||
description: 'Oeffentliche Rueckmeldungen, Meinungen und Verbesserungsvorschlaege zu SingleChat.',
|
||||
keywords: 'SingleChat Feedback, Kommentare, Rueckmeldungen, Verbesserungsvorschlaege',
|
||||
ogTitle: 'Feedback - SingleChat',
|
||||
ogDescription: 'Oeffentliche Rueckmeldungen, Meinungen und Verbesserungsvorschlaege zu SingleChat.',
|
||||
ogType: 'website',
|
||||
image: DEFAULT_IMAGE,
|
||||
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||
schema: feedbackSchema
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -31,7 +100,13 @@ const routes = [
|
||||
meta: {
|
||||
title: 'Design Mockup - SingleChat',
|
||||
description: 'Visuelle Vorschau des geplanten Design-Refreshs fuer SingleChat.',
|
||||
keywords: 'SingleChat, Mockup, Design, Redesign, Vorschau'
|
||||
keywords: 'SingleChat, Mockup, Design, Redesign, Vorschau',
|
||||
ogTitle: 'Design Mockup - SingleChat',
|
||||
ogDescription: 'Interne Vorschau des geplanten Design-Refreshs fuer SingleChat.',
|
||||
ogType: 'website',
|
||||
image: DEFAULT_IMAGE,
|
||||
robots: 'noindex, nofollow, noarchive',
|
||||
schema: null
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -41,48 +116,73 @@ const router = createRouter({
|
||||
routes
|
||||
});
|
||||
|
||||
// Meta-Tags dynamisch aktualisieren basierend auf Route
|
||||
function updateMetaTag(name, content, attribute = 'name') {
|
||||
let element = document.querySelector(`meta[${attribute}="${name}"]`);
|
||||
if (!element) {
|
||||
element = document.createElement('meta');
|
||||
element.setAttribute(attribute, name);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.setAttribute('content', content);
|
||||
}
|
||||
|
||||
function updateLinkTag(rel, href) {
|
||||
let element = document.querySelector(`link[rel="${rel}"]`);
|
||||
if (!element) {
|
||||
element = document.createElement('link');
|
||||
element.setAttribute('rel', rel);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.setAttribute('href', href);
|
||||
}
|
||||
|
||||
function updateJsonLd(schema) {
|
||||
let element = document.querySelector('#seo-json-ld');
|
||||
if (!element) {
|
||||
element = document.createElement('script');
|
||||
element.id = 'seo-json-ld';
|
||||
element.type = 'application/ld+json';
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
|
||||
element.textContent = schema ? JSON.stringify(schema) : '';
|
||||
}
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
// Aktualisiere Title
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title;
|
||||
}
|
||||
|
||||
// Aktualisiere Meta-Tags
|
||||
const updateMetaTag = (name, content, attribute = 'name') => {
|
||||
let element = document.querySelector(`meta[${attribute}="${name}"]`);
|
||||
if (!element) {
|
||||
element = document.createElement('meta');
|
||||
element.setAttribute(attribute, name);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.setAttribute('content', content);
|
||||
};
|
||||
|
||||
if (to.meta.description) {
|
||||
updateMetaTag('description', to.meta.description);
|
||||
updateMetaTag('og:description', to.meta.description, 'property');
|
||||
updateMetaTag('twitter:description', to.meta.description);
|
||||
}
|
||||
|
||||
if (to.meta.keywords) {
|
||||
updateMetaTag('keywords', to.meta.keywords);
|
||||
}
|
||||
|
||||
// Aktualisiere Open Graph URL
|
||||
const ogUrl = `https://ypchat.net${to.path}`;
|
||||
updateMetaTag('og:url', ogUrl, 'property');
|
||||
updateMetaTag('canonical', ogUrl, 'rel');
|
||||
|
||||
// Aktualisiere Canonical Link
|
||||
let canonicalLink = document.querySelector('link[rel="canonical"]');
|
||||
if (!canonicalLink) {
|
||||
canonicalLink = document.createElement('link');
|
||||
canonicalLink.setAttribute('rel', 'canonical');
|
||||
document.head.appendChild(canonicalLink);
|
||||
}
|
||||
canonicalLink.setAttribute('href', ogUrl);
|
||||
|
||||
const meta = to.meta || {};
|
||||
const pageUrl = `${SITE_URL}${to.path}`;
|
||||
const title = meta.title || 'SingleChat';
|
||||
const description = meta.description || '';
|
||||
const keywords = meta.keywords || '';
|
||||
const ogTitle = meta.ogTitle || title;
|
||||
const ogDescription = meta.ogDescription || description;
|
||||
const ogType = meta.ogType || 'website';
|
||||
const image = meta.image || DEFAULT_IMAGE;
|
||||
const robots = meta.robots || 'index, follow';
|
||||
|
||||
document.title = title;
|
||||
|
||||
updateMetaTag('description', description);
|
||||
updateMetaTag('keywords', keywords);
|
||||
updateMetaTag('robots', robots);
|
||||
updateMetaTag('theme-color', '#2f6f46');
|
||||
|
||||
updateMetaTag('og:title', ogTitle, 'property');
|
||||
updateMetaTag('og:description', ogDescription, 'property');
|
||||
updateMetaTag('og:type', ogType, 'property');
|
||||
updateMetaTag('og:url', pageUrl, 'property');
|
||||
updateMetaTag('og:image', image, 'property');
|
||||
updateMetaTag('og:site_name', 'SingleChat', 'property');
|
||||
updateMetaTag('og:locale', 'de_DE', 'property');
|
||||
|
||||
updateMetaTag('twitter:card', robots.startsWith('noindex') ? 'summary' : 'summary_large_image');
|
||||
updateMetaTag('twitter:title', ogTitle);
|
||||
updateMetaTag('twitter:description', ogDescription);
|
||||
updateMetaTag('twitter:image', image);
|
||||
|
||||
updateLinkTag('canonical', pageUrl);
|
||||
updateJsonLd(meta.schema || null);
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
39
client/src/utils/profileCookie.js
Normal file
39
client/src/utils/profileCookie.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const PROFILE_COOKIE_NAME = 'singlechat_profile';
|
||||
const PROFILE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
function readCookie(name) {
|
||||
const prefix = `${name}=`;
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find((entry) => entry.startsWith(prefix));
|
||||
|
||||
return cookie ? cookie.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
export function readProfileCookie() {
|
||||
const cookieValue = readCookie(PROFILE_COOKIE_NAME);
|
||||
if (!cookieValue) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(cookieValue));
|
||||
} catch (error) {
|
||||
console.warn('Profil-Cookie konnte nicht gelesen werden:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeProfileCookie(profile) {
|
||||
const normalizedProfile = {
|
||||
nickname: typeof profile.nickname === 'string' ? profile.nickname.trim() : '',
|
||||
gender: typeof profile.gender === 'string' ? profile.gender : '',
|
||||
age: Number(profile.age) || 18,
|
||||
country: typeof profile.country === 'string' ? profile.country : ''
|
||||
};
|
||||
|
||||
document.cookie = [
|
||||
`${PROFILE_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(normalizedProfile))}`,
|
||||
`Max-Age=${PROFILE_COOKIE_MAX_AGE}`,
|
||||
'Path=/',
|
||||
'SameSite=Lax'
|
||||
].join('; ');
|
||||
}
|
||||
@@ -25,38 +25,38 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="main-content-wrapper">
|
||||
<div v-if="chatStore.errorMessage" class="error-message">
|
||||
{{ chatStore.errorMessage }}
|
||||
</div>
|
||||
<div v-if="chatStore.commandTable" class="command-table-container">
|
||||
<div class="command-table-header">
|
||||
<strong>{{ chatStore.commandTable.title }}</strong>
|
||||
<button class="command-table-close" @click="chatStore.clearCommandTable()">Schließen</button>
|
||||
</div>
|
||||
<div class="command-table-scroll">
|
||||
<table class="command-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="(column, idx) in chatStore.commandTable.columns" :key="`head-${idx}`">
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIdx) in chatStore.commandTable.rows" :key="`row-${rowIdx}`">
|
||||
<td v-for="(cell, cellIdx) in row" :key="`cell-${rowIdx}-${cellIdx}`">
|
||||
{{ cell }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<SearchView v-if="chatStore.currentView === 'search'" />
|
||||
<InboxView v-else-if="chatStore.currentView === 'inbox'" />
|
||||
<HistoryView v-else-if="chatStore.currentView === 'history'" />
|
||||
<div v-else class="chat-content">
|
||||
<div v-if="chatStore.errorMessage" class="error-message">
|
||||
{{ chatStore.errorMessage }}
|
||||
</div>
|
||||
<div v-if="chatStore.commandTable" class="command-table-container">
|
||||
<div class="command-table-header">
|
||||
<strong>{{ chatStore.commandTable.title }}</strong>
|
||||
<button class="command-table-close" @click="chatStore.clearCommandTable()">Schließen</button>
|
||||
</div>
|
||||
<div class="command-table-scroll">
|
||||
<table class="command-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="(column, idx) in chatStore.commandTable.columns" :key="`head-${idx}`">
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIdx) in chatStore.commandTable.rows" :key="`row-${rowIdx}`">
|
||||
<td v-for="(cell, cellIdx) in row" :key="`cell-${rowIdx}-${cellIdx}`">
|
||||
{{ cell }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="chatStore.currentConversation && currentUserInfo" class="chat-header">
|
||||
<div v-if="chatStore.currentConversation && currentUserInfo" class="chat-header">
|
||||
<span :class="['chat-header-accent', 'chat-header-accent-' + currentUserInfo.gender]"></span>
|
||||
<div class="chat-header-main">
|
||||
<h2>{{ chatStore.currentConversation }}</h2>
|
||||
@@ -67,8 +67,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<ChatWindow />
|
||||
<ChatInput />
|
||||
</div>
|
||||
<ChatInput />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
22
client/src/views/FeedbackView.vue
Normal file
22
client/src/views/FeedbackView.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<header class="header">
|
||||
<div class="app-brand">
|
||||
<span class="app-brand-mark">S</span>
|
||||
<div class="app-brand-copy">
|
||||
<span class="app-brand-eyebrow">SingleChat</span>
|
||||
<h1>Feedback</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<FeedbackPanel />
|
||||
|
||||
<ImprintContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FeedbackPanel from '../components/FeedbackPanel.vue';
|
||||
import ImprintContainer from '../components/ImprintContainer.vue';
|
||||
</script>
|
||||
Reference in New Issue
Block a user