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>
|
||||
11
logs/feedback.json
Normal file
11
logs/feedback.json
Normal file
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"id": "97f7163b-64ea-4e90-9644-4bd4b9adcf6b",
|
||||
"createdAt": "2026-03-19T14:19:19.555Z",
|
||||
"name": "comic",
|
||||
"age": null,
|
||||
"country": "",
|
||||
"gender": "",
|
||||
"comment": "Schöne Seite"
|
||||
}
|
||||
]
|
||||
73
server/chat-auth.js
Normal file
73
server/chat-auth.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import crypto from 'crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const CHAT_USERS_FILE_NAME = 'chat-users.json';
|
||||
|
||||
function ensureLogsDir(baseDir) {
|
||||
const logsDir = join(baseDir, '../logs');
|
||||
if (!existsSync(logsDir)) {
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
return logsDir;
|
||||
}
|
||||
|
||||
function getChatUsersPath(baseDir) {
|
||||
return join(ensureLogsDir(baseDir), CHAT_USERS_FILE_NAME);
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
export function ensureChatUsersFile(baseDir) {
|
||||
const usersPath = getChatUsersPath(baseDir);
|
||||
if (existsSync(usersPath)) return;
|
||||
writeFileSync(usersPath, '[]\n', 'utf-8');
|
||||
console.warn(
|
||||
`[Auth] ${CHAT_USERS_FILE_NAME} wurde neu erstellt. Bitte mindestens einen Admin-User mit Passwort-Hash konfigurieren.`
|
||||
);
|
||||
}
|
||||
|
||||
export function loadChatUsers(baseDir) {
|
||||
ensureChatUsersFile(baseDir);
|
||||
const usersPath = getChatUsersPath(baseDir);
|
||||
const raw = readFileSync(usersPath, 'utf-8').trim();
|
||||
if (!raw) return [];
|
||||
|
||||
let users = [];
|
||||
try {
|
||||
users = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Ungültige ${CHAT_USERS_FILE_NAME}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(users)) {
|
||||
throw new Error(`${CHAT_USERS_FILE_NAME} muss ein Array sein`);
|
||||
}
|
||||
|
||||
return users
|
||||
.filter((entry) => entry && typeof entry.username === 'string')
|
||||
.map((entry) => ({
|
||||
username: entry.username.trim(),
|
||||
passwordHash: typeof entry.passwordHash === 'string' ? entry.passwordHash.trim() : '',
|
||||
rights: Array.isArray(entry.rights) ? entry.rights.map((r) => String(r).toLowerCase()) : []
|
||||
}))
|
||||
.filter((entry) => entry.username && entry.passwordHash);
|
||||
}
|
||||
|
||||
export function verifyChatUser(baseDir, username, password) {
|
||||
if (!username || !password) return null;
|
||||
const normalizedUser = String(username).trim();
|
||||
const passwordHash = sha256(password);
|
||||
const user = loadChatUsers(baseDir).find(
|
||||
(entry) => entry.username.toLowerCase() === normalizedUser.toLowerCase() && entry.passwordHash === passwordHash
|
||||
);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
username: user.username,
|
||||
rights: new Set(user.rights)
|
||||
};
|
||||
}
|
||||
55
server/feedback-store.js
Normal file
55
server/feedback-store.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import crypto from 'crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const FEEDBACK_FILE_NAME = 'feedback.json';
|
||||
|
||||
function ensureLogsDir(baseDir) {
|
||||
const logsDir = join(baseDir, '../logs');
|
||||
if (!existsSync(logsDir)) {
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
return logsDir;
|
||||
}
|
||||
|
||||
function getFeedbackPath(baseDir) {
|
||||
return join(ensureLogsDir(baseDir), FEEDBACK_FILE_NAME);
|
||||
}
|
||||
|
||||
export function ensureFeedbackFile(baseDir) {
|
||||
const feedbackPath = getFeedbackPath(baseDir);
|
||||
if (!existsSync(feedbackPath)) {
|
||||
writeFileSync(feedbackPath, '[]\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
export function loadFeedback(baseDir) {
|
||||
ensureFeedbackFile(baseDir);
|
||||
const feedbackPath = getFeedbackPath(baseDir);
|
||||
const raw = readFileSync(feedbackPath, 'utf-8').trim();
|
||||
if (!raw) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
throw new Error(`Ungültige ${FEEDBACK_FILE_NAME}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFeedback(baseDir, items) {
|
||||
const feedbackPath = getFeedbackPath(baseDir);
|
||||
writeFileSync(feedbackPath, `${JSON.stringify(items, null, 2)}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
export function createFeedbackEntry(input) {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
name: typeof input.name === 'string' ? input.name.trim() : '',
|
||||
age: Number.isFinite(input.age) ? input.age : null,
|
||||
country: typeof input.country === 'string' ? input.country.trim() : '',
|
||||
gender: typeof input.gender === 'string' ? input.gender.trim() : '',
|
||||
comment: typeof input.comment === 'string' ? input.comment.trim() : ''
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
import { loadFeedback } from './feedback-store.js';
|
||||
|
||||
const SITE_URL = 'https://ypchat.net';
|
||||
const DEFAULT_IMAGE = `${SITE_URL}/static/favicon.png`;
|
||||
|
||||
// SEO-Meta-Daten für verschiedene Routen
|
||||
const seoData = {
|
||||
'/': {
|
||||
title: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
||||
@@ -10,8 +13,17 @@ const seoData = {
|
||||
ogTitle: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
||||
ogDescription: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.',
|
||||
ogType: 'website',
|
||||
ogUrl: 'https://ypchat.net/',
|
||||
ogImage: 'https://ypchat.net/static/favicon.png'
|
||||
ogUrl: `${SITE_URL}/`,
|
||||
ogImage: DEFAULT_IMAGE,
|
||||
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||
schema: {
|
||||
'@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'
|
||||
}
|
||||
},
|
||||
'/partners': {
|
||||
title: 'Partner - SingleChat',
|
||||
@@ -20,162 +32,214 @@ const seoData = {
|
||||
ogTitle: 'Partner - SingleChat',
|
||||
ogDescription: 'Unsere Partner und befreundete Seiten.',
|
||||
ogType: 'website',
|
||||
ogUrl: 'https://ypchat.net/partners',
|
||||
ogImage: 'https://ypchat.net/static/favicon.png'
|
||||
ogUrl: `${SITE_URL}/partners`,
|
||||
ogImage: DEFAULT_IMAGE,
|
||||
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||
schema: {
|
||||
'@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'
|
||||
}
|
||||
},
|
||||
'/feedback': {
|
||||
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',
|
||||
ogUrl: `${SITE_URL}/feedback`,
|
||||
ogImage: DEFAULT_IMAGE,
|
||||
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||
schema: {
|
||||
'@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'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// HTML-Template für Pre-Rendering
|
||||
function escapeHtml(value = '') {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function upsertMetaTag(html, name, content, attribute = 'name') {
|
||||
const escapedContent = escapeHtml(content);
|
||||
const regex = new RegExp(`<meta\\s+${attribute}="${name}"[^>]*>`, 'g');
|
||||
const tag = `<meta ${attribute}="${name}" content="${escapedContent}">`;
|
||||
|
||||
if (regex.test(html)) {
|
||||
return html.replace(regex, tag);
|
||||
}
|
||||
|
||||
return html.replace('</head>', ` ${tag}\n</head>`);
|
||||
}
|
||||
|
||||
function upsertLinkTag(html, rel, href) {
|
||||
const escapedHref = escapeHtml(href);
|
||||
const regex = new RegExp(`<link\\s+rel="${rel}"[^>]*>`, 'g');
|
||||
const tag = `<link rel="${rel}" href="${escapedHref}">`;
|
||||
|
||||
if (regex.test(html)) {
|
||||
return html.replace(regex, tag);
|
||||
}
|
||||
|
||||
return html.replace('</head>', ` ${tag}\n</head>`);
|
||||
}
|
||||
|
||||
function upsertJsonLd(html, schema) {
|
||||
const tag = schema
|
||||
? `<script type="application/ld+json" id="seo-json-ld">${JSON.stringify(schema)}</script>`
|
||||
: '<script type="application/ld+json" id="seo-json-ld"></script>';
|
||||
|
||||
if (html.includes('id="seo-json-ld"')) {
|
||||
return html.replace(/<script type="application\/ld\+json" id="seo-json-ld">.*?<\/script>/s, tag);
|
||||
}
|
||||
|
||||
return html.replace('</head>', ` ${tag}\n</head>`);
|
||||
}
|
||||
|
||||
function generateHTML(route, meta, __dirname) {
|
||||
// Versuche, die gebaute index.html zu lesen
|
||||
const distIndexPath = join(__dirname, '../docroot/dist/index.html');
|
||||
|
||||
console.log('[SEO] Prüfe gebaute index.html:', distIndexPath);
|
||||
console.log('[SEO] Datei existiert:', existsSync(distIndexPath));
|
||||
|
||||
|
||||
if (!existsSync(distIndexPath)) {
|
||||
// Fallback: Gebaute index.html nicht gefunden
|
||||
console.error('WARNUNG: Gebaute index.html nicht gefunden:', distIndexPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verwende die gebaute index.html (mit korrekten Asset-Pfaden von Vite)
|
||||
let baseHTML = readFileSync(distIndexPath, 'utf-8');
|
||||
console.log('[SEO] Gebaute HTML geladen, Länge:', baseHTML.length);
|
||||
console.log('[SEO] Enthält Script-Tags:', baseHTML.includes('<script'));
|
||||
|
||||
// Ersetze Meta-Tags in der gebauten HTML
|
||||
baseHTML = baseHTML.replace(/<title>.*?<\/title>/, `<title>${meta.title}</title>`);
|
||||
|
||||
// Ersetze oder füge description hinzu
|
||||
if (baseHTML.includes('<meta name="description"')) {
|
||||
baseHTML = baseHTML.replace(/<meta name="description"[^>]*>/g, `<meta name="description" content="${meta.description}">`);
|
||||
} else {
|
||||
baseHTML = baseHTML.replace('</head>', ` <meta name="description" content="${meta.description}">\n</head>`);
|
||||
|
||||
let html = readFileSync(distIndexPath, 'utf-8');
|
||||
|
||||
html = html.replace(/<title>.*?<\/title>/, `<title>${escapeHtml(meta.title)}</title>`);
|
||||
html = upsertMetaTag(html, 'description', meta.description);
|
||||
html = upsertMetaTag(html, 'keywords', meta.keywords);
|
||||
html = upsertMetaTag(html, 'robots', meta.robots);
|
||||
html = upsertMetaTag(html, 'theme-color', '#2f6f46');
|
||||
|
||||
html = upsertMetaTag(html, 'og:title', meta.ogTitle, 'property');
|
||||
html = upsertMetaTag(html, 'og:description', meta.ogDescription, 'property');
|
||||
html = upsertMetaTag(html, 'og:type', meta.ogType, 'property');
|
||||
html = upsertMetaTag(html, 'og:url', meta.ogUrl, 'property');
|
||||
html = upsertMetaTag(html, 'og:image', meta.ogImage, 'property');
|
||||
html = upsertMetaTag(html, 'og:site_name', 'SingleChat', 'property');
|
||||
html = upsertMetaTag(html, 'og:locale', 'de_DE', 'property');
|
||||
|
||||
html = upsertMetaTag(html, 'twitter:card', 'summary_large_image');
|
||||
html = upsertMetaTag(html, 'twitter:title', meta.ogTitle);
|
||||
html = upsertMetaTag(html, 'twitter:description', meta.ogDescription);
|
||||
html = upsertMetaTag(html, 'twitter:image', meta.ogImage);
|
||||
|
||||
html = upsertLinkTag(html, 'canonical', meta.ogUrl);
|
||||
html = upsertJsonLd(html, meta.schema);
|
||||
|
||||
if (route === '/feedback') {
|
||||
const feedbackItems = loadFeedback(__dirname)
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
.slice(0, 20);
|
||||
|
||||
const feedbackMarkup = feedbackItems.length > 0
|
||||
? feedbackItems.map((item) => {
|
||||
const metaLine = [item.country, item.age, item.gender].filter(Boolean).join(' · ');
|
||||
return `<article style="border:1px solid #d7dfd9;border-radius:12px;padding:14px 16px;margin-bottom:12px;background:#fff;">
|
||||
<strong style="display:block;color:#18201b;">${escapeHtml(item.name || 'Anonym')}</strong>
|
||||
${metaLine ? `<div style="font-size:12px;color:#637067;margin-top:4px;">${escapeHtml(metaLine)}</div>` : ''}
|
||||
<div style="font-size:12px;color:#637067;margin-top:4px;">${escapeHtml(new Date(item.createdAt).toLocaleString('de-DE'))}</div>
|
||||
<p style="margin-top:10px;color:#2c362f;white-space:pre-wrap;">${escapeHtml(item.comment)}</p>
|
||||
</article>`;
|
||||
}).join('\n')
|
||||
: '<p>Noch kein Feedback vorhanden.</p>';
|
||||
|
||||
const preview = `<section style="max-width:960px;margin:24px auto;padding:0 16px;">
|
||||
<h2 style="font:600 28px/1.15 sans-serif;color:#18201b;margin:0 0 10px;">Feedback zu SingleChat</h2>
|
||||
<p style="font:400 15px/1.5 sans-serif;color:#4f5d54;margin:0 0 18px;">Oeffentliche Rueckmeldungen und Verbesserungsvorschlaege.</p>
|
||||
${feedbackMarkup}
|
||||
</section>`;
|
||||
|
||||
html = html.replace('<div id="app"></div>', `<div id="app">${preview}</div>`);
|
||||
}
|
||||
|
||||
// Ersetze oder füge keywords hinzu
|
||||
if (baseHTML.includes('<meta name="keywords"')) {
|
||||
baseHTML = baseHTML.replace(/<meta name="keywords"[^>]*>/g, `<meta name="keywords" content="${meta.keywords}">`);
|
||||
} else {
|
||||
baseHTML = baseHTML.replace('</head>', ` <meta name="keywords" content="${meta.keywords}">\n</head>`);
|
||||
}
|
||||
|
||||
// Ersetze oder füge Open Graph Tags hinzu
|
||||
const ogTags = `
|
||||
<meta property="og:title" content="${meta.ogTitle}">
|
||||
<meta property="og:description" content="${meta.ogDescription}">
|
||||
<meta property="og:type" content="${meta.ogType}">
|
||||
<meta property="og:url" content="${meta.ogUrl}">
|
||||
<meta property="og:image" content="${meta.ogImage}">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="${meta.ogTitle}">
|
||||
<meta name="twitter:description" content="${meta.ogDescription}">
|
||||
<meta name="twitter:image" content="${meta.ogImage}">
|
||||
<link rel="canonical" href="${meta.ogUrl}">`;
|
||||
|
||||
// Entferne alte OG/Twitter/Canonical Tags falls vorhanden (nur Meta-Tags, keine Script-Tags!)
|
||||
baseHTML = baseHTML.replace(/<meta property="og:[^>]*>/g, '');
|
||||
baseHTML = baseHTML.replace(/<meta name="twitter:[^>]*>/g, '');
|
||||
baseHTML = baseHTML.replace(/<link rel="canonical"[^>]*>/g, '');
|
||||
|
||||
// Füge neue Tags vor </head> ein (aber NACH den Script-Tags!)
|
||||
// Finde die Position von </head> und füge die Tags davor ein
|
||||
const headEndIndex = baseHTML.indexOf('</head>');
|
||||
if (headEndIndex !== -1) {
|
||||
baseHTML = baseHTML.substring(0, headEndIndex) + ogTags + '\n' + baseHTML.substring(headEndIndex);
|
||||
}
|
||||
|
||||
// Füge robots meta hinzu falls nicht vorhanden
|
||||
if (!baseHTML.includes('<meta name="robots"')) {
|
||||
const headEndIndex2 = baseHTML.indexOf('</head>');
|
||||
if (headEndIndex2 !== -1) {
|
||||
baseHTML = baseHTML.substring(0, headEndIndex2) + ` <meta name="robots" content="index, follow">\n` + baseHTML.substring(headEndIndex2);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SEO] HTML nach Manipulation, Länge:', baseHTML.length);
|
||||
console.log('[SEO] Enthält Script-Tags nach Manipulation:', baseHTML.includes('<script'));
|
||||
|
||||
return baseHTML;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export function setupSEORoutes(app, __dirname) {
|
||||
// Pre-Rendering für SEO-relevante Routen (nur in Production)
|
||||
// In Development wird die normale index.html verwendet
|
||||
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
||||
|
||||
|
||||
if (IS_PRODUCTION) {
|
||||
const distIndexPath = resolve(__dirname, '../docroot/dist/index.html');
|
||||
|
||||
// Pre-Rendering für Hauptseite
|
||||
app.get('/', (req, res) => {
|
||||
const meta = seoData['/'];
|
||||
const html = generateHTML('/', meta, __dirname);
|
||||
if (html) {
|
||||
res.send(html);
|
||||
} else {
|
||||
// Fallback: Verwende die gebaute index.html direkt (ohne Meta-Tag-Anpassung)
|
||||
|
||||
Object.entries(seoData).forEach(([route, meta]) => {
|
||||
app.get(route, (req, res) => {
|
||||
const html = generateHTML(route, meta, __dirname);
|
||||
if (html) {
|
||||
res.send(html);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(distIndexPath)) {
|
||||
res.sendFile(distIndexPath);
|
||||
} else {
|
||||
console.error('FEHLER: Gebaute index.html nicht gefunden:', distIndexPath);
|
||||
res.status(500).send('Gebaute index.html nicht gefunden. Bitte führe "npm run build" aus.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-Rendering für Partners-Seite
|
||||
app.get('/partners', (req, res) => {
|
||||
const meta = seoData['/partners'];
|
||||
const html = generateHTML('/partners', meta, __dirname);
|
||||
if (html) {
|
||||
res.send(html);
|
||||
} else {
|
||||
// Fallback: Verwende die gebaute index.html direkt (ohne Meta-Tag-Anpassung)
|
||||
if (existsSync(distIndexPath)) {
|
||||
res.sendFile(distIndexPath);
|
||||
} else {
|
||||
console.error('FEHLER: Gebaute index.html nicht gefunden:', distIndexPath);
|
||||
res.status(500).send('Gebaute index.html nicht gefunden. Bitte führe "npm run build" aus.');
|
||||
}
|
||||
}
|
||||
|
||||
console.error('FEHLER: Gebaute index.html nicht gefunden:', distIndexPath);
|
||||
res.status(500).send('Gebaute index.html nicht gefunden. Bitte führe "npm run build" aus.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// robots.txt
|
||||
|
||||
app.get('/robots.txt', (req, res) => {
|
||||
const robotsTxt = `User-agent: *
|
||||
Allow: /
|
||||
Allow: /partners
|
||||
Allow: /feedback
|
||||
Disallow: /api/
|
||||
Disallow: /static/logs/
|
||||
Disallow: /mockup-redesign
|
||||
|
||||
Sitemap: https://ypchat.net/sitemap.xml
|
||||
Sitemap: ${SITE_URL}/sitemap.xml
|
||||
`;
|
||||
res.type('text/plain');
|
||||
res.send(robotsTxt);
|
||||
});
|
||||
|
||||
// sitemap.xml
|
||||
|
||||
app.get('/sitemap.xml', (req, res) => {
|
||||
const currentDate = new Date().toISOString().split('T')[0];
|
||||
const urls = Object.entries(seoData)
|
||||
.map(([route, meta]) => {
|
||||
const priority = route === '/' ? '1.0' : '0.8';
|
||||
const changefreq = route === '/' ? 'daily' : 'weekly';
|
||||
return ` <url>
|
||||
<loc>${meta.ogUrl}</loc>
|
||||
<lastmod>${currentDate}</lastmod>
|
||||
<changefreq>${changefreq}</changefreq>
|
||||
<priority>${priority}</priority>
|
||||
</url>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://ypchat.net/</loc>
|
||||
<lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ypchat.net/partners</loc>
|
||||
<lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
${urls}
|
||||
</urlset>`;
|
||||
res.type('application/xml');
|
||||
res.send(sitemap);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import crypto from 'crypto';
|
||||
|
||||
import axios from 'axios';
|
||||
import { getSessionStatus, getClientsMap, getSessionIdForSocket, extractSessionId } from './broadcast.js';
|
||||
import { verifyChatUser } from './chat-auth.js';
|
||||
import { loadFeedback, saveFeedback, createFeedbackEntry } from './feedback-store.js';
|
||||
|
||||
// __dirname für ES-Module
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -87,6 +89,102 @@ export function setupRoutes(app, __dirname) {
|
||||
res.status(500).json({ success: false });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/feedback', (req, res) => {
|
||||
try {
|
||||
const feedback = loadFeedback(__dirname)
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
res.json({
|
||||
items: feedback,
|
||||
admin: !!req.session.feedbackAdmin
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Feedbacks:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Laden des Feedbacks' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/feedback/admin-status', (req, res) => {
|
||||
res.json({
|
||||
authenticated: !!req.session.feedbackAdmin,
|
||||
username: req.session.feedbackAdmin?.username || null
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/feedback', (req, res) => {
|
||||
try {
|
||||
const ageValue = req.body.age === '' || req.body.age === null || req.body.age === undefined
|
||||
? null
|
||||
: Number.parseInt(req.body.age, 10);
|
||||
|
||||
const entry = createFeedbackEntry({
|
||||
name: req.body.name,
|
||||
age: Number.isNaN(ageValue) ? null : ageValue,
|
||||
country: req.body.country,
|
||||
gender: req.body.gender,
|
||||
comment: req.body.comment
|
||||
});
|
||||
|
||||
if (!entry.comment) {
|
||||
return res.status(400).json({ error: 'Kommentar ist erforderlich.' });
|
||||
}
|
||||
|
||||
const feedback = loadFeedback(__dirname);
|
||||
feedback.push(entry);
|
||||
saveFeedback(__dirname, feedback);
|
||||
|
||||
res.status(201).json({ success: true, item: entry });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern des Feedbacks:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Speichern des Feedbacks' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/feedback/admin-login', (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body || {};
|
||||
const auth = verifyChatUser(__dirname, username, password);
|
||||
|
||||
if (!auth) {
|
||||
return res.status(401).json({ error: 'Login fehlgeschlagen.' });
|
||||
}
|
||||
|
||||
req.session.feedbackAdmin = {
|
||||
username: auth.username
|
||||
};
|
||||
|
||||
res.json({ success: true, username: auth.username });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Feedback-Admin-Login:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Login' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/feedback/admin-logout', (req, res) => {
|
||||
delete req.session.feedbackAdmin;
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.delete('/api/feedback/:id', (req, res) => {
|
||||
try {
|
||||
if (!req.session.feedbackAdmin) {
|
||||
return res.status(403).json({ error: 'Nicht erlaubt.' });
|
||||
}
|
||||
|
||||
const feedback = loadFeedback(__dirname);
|
||||
const nextFeedback = feedback.filter((item) => item.id !== req.params.id);
|
||||
|
||||
if (nextFeedback.length === feedback.length) {
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
|
||||
}
|
||||
|
||||
saveFeedback(__dirname, nextFeedback);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Feedbacks:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Löschen des Feedbacks' });
|
||||
}
|
||||
});
|
||||
|
||||
// Bild-Upload-Endpoint
|
||||
app.post('/api/upload-image', upload.single('image'), (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user