Add adult verification and erotic moderation features: Implement new routes and controller methods for managing adult verification requests, status updates, and document retrieval. Introduce erotic moderation actions and reports, enhancing administrative capabilities. Update chat and navigation controllers to support adult content filtering and access control. Enhance user parameter handling for adult verification status and requests, improving overall user experience and compliance.

This commit is contained in:
Torsten Schulz (local)
2026-03-27 09:14:54 +01:00
parent f93687c753
commit 3e6c09ab29
73 changed files with 4459 additions and 197 deletions

View File

@@ -0,0 +1,258 @@
<template>
<div class="adult-verification">
<section class="adult-verification__hero surface-card">
<span class="adult-verification__eyebrow">Administration</span>
<h1>{{ $t('admin.adultVerification.title') }}</h1>
<p>{{ $t('admin.adultVerification.intro') }}</p>
</section>
<section class="adult-verification__filters surface-card">
<button
v-for="option in filterOptions"
:key="option.value"
type="button"
:class="{ active: statusFilter === option.value }"
@click="changeFilter(option.value)"
>
{{ option.label }}
</button>
</section>
<section class="adult-verification__list surface-card">
<div v-if="loading" class="adult-verification__state">{{ $t('general.loading') }}</div>
<div v-else-if="rows.length === 0" class="adult-verification__state">{{ $t('admin.adultVerification.empty') }}</div>
<table v-else>
<thead>
<tr>
<th>{{ $t('admin.adultVerification.username') }}</th>
<th>{{ $t('admin.adultVerification.age') }}</th>
<th>{{ $t('admin.adultVerification.statusLabel') }}</th>
<th>{{ $t('admin.adultVerification.requestLabel') }}</th>
<th>{{ $t('admin.adultVerification.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>{{ row.username }}</td>
<td>{{ row.age }}</td>
<td>
<span class="adult-verification__badge" :class="`adult-verification__badge--${row.adultVerificationStatus}`">
{{ $t(`admin.adultVerification.status.${row.adultVerificationStatus}`) }}
</span>
</td>
<td class="adult-verification__request">
<template v-if="row.adultVerificationRequest">
<strong>{{ row.adultVerificationRequest.originalName }}</strong>
<span v-if="row.adultVerificationRequest.note">{{ row.adultVerificationRequest.note }}</span>
<button type="button" class="secondary" @click="openDocument(row)">
{{ $t('admin.adultVerification.openDocument') }}
</button>
</template>
<span v-else></span>
</td>
<td class="adult-verification__actions">
<button type="button" @click="setStatus(row, 'approved')">{{ $t('admin.adultVerification.approve') }}</button>
<button type="button" class="secondary" @click="setStatus(row, 'rejected')">{{ $t('admin.adultVerification.reject') }}</button>
<button
v-if="row.adultVerificationStatus !== 'pending'"
type="button"
class="secondary"
@click="setStatus(row, 'pending')"
>
{{ $t('admin.adultVerification.resetPending') }}
</button>
</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'AdminAdultVerificationView',
data() {
return {
loading: false,
statusFilter: 'pending',
rows: []
};
},
computed: {
filterOptions() {
return [
{ value: 'pending', label: this.$t('admin.adultVerification.filters.pending') },
{ value: 'approved', label: this.$t('admin.adultVerification.filters.approved') },
{ value: 'rejected', label: this.$t('admin.adultVerification.filters.rejected') },
{ value: 'all', label: this.$t('admin.adultVerification.filters.all') }
];
}
},
methods: {
async load() {
this.loading = true;
try {
const response = await apiClient.get('/api/admin/users/adult-verification', {
params: { status: this.statusFilter }
});
this.rows = response.data || [];
} catch (error) {
this.rows = [];
showApiError(this, error, this.$t('admin.adultVerification.loadError'));
} finally {
this.loading = false;
}
},
async changeFilter(filter) {
this.statusFilter = filter;
await this.load();
},
async setStatus(row, status) {
try {
await apiClient.put(`/api/admin/users/${row.id}/adult-verification`, { status });
showSuccess(this, this.$t(`admin.adultVerification.messages.${status}`));
await this.load();
} catch (error) {
showApiError(this, error, this.$t('admin.adultVerification.updateError'));
}
},
async openDocument(row) {
try {
const response = await apiClient.get(`/api/admin/users/${row.id}/adult-verification/document`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(response.data);
window.open(url, '_blank', 'noopener');
window.setTimeout(() => window.URL.revokeObjectURL(url), 10000);
} catch (error) {
showApiError(this, error, this.$t('admin.adultVerification.documentError'));
}
}
},
async mounted() {
await this.load();
}
};
</script>
<style scoped>
.adult-verification {
display: grid;
gap: 18px;
}
.adult-verification__hero,
.adult-verification__filters,
.adult-verification__list {
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
padding: 22px 24px;
}
.adult-verification__eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.adult-verification__hero p,
.adult-verification__state {
margin: 0;
color: var(--color-text-secondary);
}
.adult-verification__filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.adult-verification__filters button.active {
background: var(--color-primary);
color: #fff;
}
.adult-verification table {
width: 100%;
border-collapse: collapse;
}
.adult-verification th,
.adult-verification td {
padding: 12px 10px;
border-bottom: 1px solid rgba(93, 64, 55, 0.08);
text-align: left;
}
.adult-verification__badge {
display: inline-flex;
padding: 4px 10px;
border-radius: var(--radius-pill);
font-weight: 700;
font-size: 0.82rem;
}
.adult-verification__badge--pending {
background: rgba(216, 167, 65, 0.16);
}
.adult-verification__badge--approved {
background: rgba(92, 156, 106, 0.18);
}
.adult-verification__badge--rejected {
background: rgba(176, 88, 88, 0.16);
}
.adult-verification__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.adult-verification__request {
display: grid;
gap: 4px;
}
.adult-verification__actions .secondary {
background: transparent;
}
@media (max-width: 760px) {
.adult-verification__hero,
.adult-verification__filters,
.adult-verification__list {
padding: 18px;
}
.adult-verification table,
.adult-verification thead,
.adult-verification tbody,
.adult-verification tr,
.adult-verification td {
display: block;
}
.adult-verification thead {
display: none;
}
.adult-verification td {
padding: 8px 0;
}
}
</style>

View File

@@ -9,6 +9,7 @@
<th>{{ $t('admin.chatrooms.roomName') }}</th>
<th>{{ $t('admin.chatrooms.type') }}</th>
<th>{{ $t('admin.chatrooms.isPublic') }}</th>
<th>{{ $t('admin.chatrooms.isAdultOnly') }}</th>
<th>{{ $t('admin.chatrooms.actions') }}</th>
</tr>
</thead>
@@ -17,6 +18,7 @@
<td>{{ room.title }}</td>
<td>{{ getRoomTypeLabel(room) }}</td>
<td>{{ room.isPublic ? $t('common.yes') : $t('common.no') }}</td>
<td>{{ room.isAdultOnly ? $t('common.yes') : $t('common.no') }}</td>
<td>
<button @click="editRoom(room)">{{ $t('common.edit') }}</button>
<button @click="deleteRoom(room)">{{ $t('common.delete') }}</button>

View File

@@ -0,0 +1,244 @@
<template>
<div class="adult-verification">
<section class="adult-verification__hero surface-card">
<span class="adult-verification__eyebrow">Administration</span>
<h1>{{ $t('admin.eroticModeration.title') }}</h1>
<p>{{ $t('admin.eroticModeration.intro') }}</p>
</section>
<section class="adult-verification__filters surface-card">
<button
v-for="option in filterOptions"
:key="option.value"
type="button"
:class="{ active: statusFilter === option.value }"
@click="changeFilter(option.value)"
>
{{ option.label }}
</button>
</section>
<section class="adult-verification__list surface-card">
<div v-if="loading" class="adult-verification__state">{{ $t('general.loading') }}</div>
<div v-else-if="rows.length === 0" class="adult-verification__state">{{ $t('admin.eroticModeration.empty') }}</div>
<table v-else>
<thead>
<tr>
<th>{{ $t('admin.eroticModeration.target') }}</th>
<th>{{ $t('admin.eroticModeration.owner') }}</th>
<th>{{ $t('admin.eroticModeration.reporter') }}</th>
<th>{{ $t('admin.eroticModeration.reason') }}</th>
<th>{{ $t('admin.eroticModeration.statusLabel') }}</th>
<th>{{ $t('admin.eroticModeration.meta') }}</th>
<th>{{ $t('admin.eroticModeration.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td class="adult-verification__request">
<strong>{{ row.targetType === 'image' ? $t('admin.eroticModeration.image') : $t('admin.eroticModeration.video') }}</strong>
<span>{{ row.target?.title || '—' }}</span>
<span v-if="row.target?.isModeratedHidden" class="adult-verification__badge adult-verification__badge--rejected">
{{ $t('admin.eroticModeration.hidden') }}
</span>
<button v-if="row.target" type="button" class="secondary" @click="previewTarget(row)">
{{ $t('admin.eroticModeration.preview') }}
</button>
</td>
<td>{{ row.owner?.username || '—' }}</td>
<td>{{ row.reporter?.username || '—' }}</td>
<td class="adult-verification__request">
<strong>{{ $t(`socialnetwork.erotic.reportReasons.${row.reason}`) }}</strong>
<span v-if="row.note">{{ row.note }}</span>
</td>
<td>
<span class="adult-verification__badge" :class="`adult-verification__badge--${row.status}`">
{{ $t(`admin.eroticModeration.status.${row.status}`) }}
</span>
</td>
<td class="adult-verification__request">
<span>{{ formatDate(row.createdAt) }}</span>
<span v-if="row.actionTaken">{{ $t(`admin.eroticModeration.actionLabels.${row.actionTaken}`) }}</span>
<span v-if="row.handledAt">{{ formatDate(row.handledAt) }}</span>
</td>
<td class="adult-verification__actions">
<button type="button" @click="applyAction(row, 'dismiss')">{{ $t('admin.eroticModeration.dismiss') }}</button>
<button type="button" class="secondary" @click="applyAction(row, 'hide_content')">{{ $t('admin.eroticModeration.hide') }}</button>
<button type="button" class="secondary" @click="applyAction(row, 'restore_content')">{{ $t('admin.eroticModeration.restore') }}</button>
<button type="button" class="secondary" @click="applyAction(row, 'delete_content')">{{ $t('admin.eroticModeration.delete') }}</button>
<button type="button" class="secondary" @click="applyAction(row, 'block_uploads')">{{ $t('admin.eroticModeration.blockUploads') }}</button>
<button type="button" class="secondary" @click="applyAction(row, 'revoke_access')">{{ $t('admin.eroticModeration.revokeAccess') }}</button>
</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'EroticModerationView',
data() {
return {
loading: false,
statusFilter: 'open',
rows: []
};
},
computed: {
filterOptions() {
return [
{ value: 'open', label: this.$t('admin.eroticModeration.filters.open') },
{ value: 'actioned', label: this.$t('admin.eroticModeration.filters.actioned') },
{ value: 'dismissed', label: this.$t('admin.eroticModeration.filters.dismissed') },
{ value: 'all', label: this.$t('admin.eroticModeration.filters.all') }
];
}
},
methods: {
async load() {
this.loading = true;
try {
const response = await apiClient.get('/api/admin/users/erotic-moderation', {
params: { status: this.statusFilter }
});
this.rows = response.data || [];
} catch (error) {
this.rows = [];
showApiError(this, error, this.$t('admin.eroticModeration.loadError'));
} finally {
this.loading = false;
}
},
async changeFilter(filter) {
this.statusFilter = filter;
await this.load();
},
async applyAction(row, action) {
const note = window.prompt(this.$t('admin.eroticModeration.notePrompt'), row.note || '') || '';
try {
await apiClient.put(`/api/admin/users/erotic-moderation/${row.id}`, { action, note });
showSuccess(this, this.$t('admin.eroticModeration.actionSuccess'));
await this.load();
} catch (error) {
showApiError(this, error, this.$t('admin.eroticModeration.actionError'));
}
},
async previewTarget(row) {
try {
const response = await apiClient.get(`/api/admin/users/erotic-moderation/preview/${row.targetType}/${row.targetId}`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(response.data);
window.open(url, '_blank', 'noopener');
window.setTimeout(() => window.URL.revokeObjectURL(url), 10000);
} catch (error) {
showApiError(this, error, this.$t('admin.eroticModeration.previewError'));
}
},
formatDate(value) {
if (!value) return '—';
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
},
async mounted() {
await this.load();
}
};
</script>
<style scoped>
.adult-verification {
display: grid;
gap: 18px;
}
.adult-verification__hero,
.adult-verification__filters,
.adult-verification__list {
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
padding: 22px 24px;
}
.adult-verification__eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.adult-verification__hero p,
.adult-verification__state {
margin: 0;
color: var(--color-text-secondary);
}
.adult-verification__filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.adult-verification__filters button.active {
background: var(--color-primary);
color: #fff;
}
.adult-verification table {
width: 100%;
border-collapse: collapse;
}
.adult-verification th,
.adult-verification td {
padding: 12px 10px;
border-bottom: 1px solid rgba(93, 64, 55, 0.08);
text-align: left;
}
.adult-verification__badge {
display: inline-flex;
padding: 4px 10px;
border-radius: var(--radius-pill);
font-weight: 700;
font-size: 0.82rem;
}
.adult-verification__badge--open {
background: rgba(216, 167, 65, 0.16);
}
.adult-verification__badge--actioned {
background: rgba(92, 156, 106, 0.18);
}
.adult-verification__badge--dismissed {
background: rgba(176, 88, 88, 0.16);
}
.adult-verification__actions,
.adult-verification__request {
display: grid;
gap: 6px;
}
.adult-verification__actions .secondary {
background: transparent;
}
</style>

View File

@@ -394,6 +394,7 @@ const CERTIFICATE_PRODUCT_LEVELS = [
{ level: 5, products: ['horse', 'ox'] },
];
// Stückkosten wie backend/utils/falukant/falukantProductEconomy.js (bei Änderungen dort mitziehen).
const PRODUCTION_COST_BASE = 6.0;
const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0;
const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035;
@@ -761,7 +762,7 @@ export default {
// Fallback auf Standard-Berechnung
const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0;
const maxPrice = product.sellCost;
const minPrice = maxPrice * 0.6;
const minPrice = maxPrice * 0.7;
prices[product.id] = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
}
}
@@ -845,7 +846,7 @@ export default {
// Fallback auf Standard-Berechnung
const knowledgeFactor = product.knowledges[0].knowledge || 0;
const maxPrice = product.sellCost;
const minPrice = maxPrice * 0.6;
const minPrice = maxPrice * 0.7;
revenuePerUnit = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
}

View File

@@ -46,6 +46,19 @@
<span>{{ $t("settings.account.showinsearch") }}</span>
</label>
<section class="account-settings__adult surface-card">
<h3>{{ $t("settings.account.adultAccessTitle") }}</h3>
<p>{{ $t("settings.account.adultAccessIntro") }}</p>
<div class="account-settings__adult-status">
<strong>{{ adultStatusTitle }}</strong>
<span>{{ adultStatusText }}</span>
<span v-if="adultVerificationRequest?.originalName">{{ adultVerificationRequest.originalName }}</span>
</div>
<div class="account-settings__adult-actions" v-if="canRequestAdultVerification">
<router-link to="/socialnetwork/erotic/access">{{ $t("settings.account.requestAdultVerification") }}</router-link>
</div>
</section>
<div class="account-settings__actions">
<button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button>
</div>
@@ -69,6 +82,9 @@ export default {
newpasswordretype: "",
showInSearch: false,
oldpassword: "",
isAdult: false,
adultVerificationStatus: "none",
adultVerificationRequest: null,
};
},
computed: {
@@ -81,6 +97,21 @@ export default {
},
passwordsMatch() {
return this.newpassword === this.newpasswordretype;
},
canRequestAdultVerification() {
return this.isAdult && ['none', 'rejected'].includes(this.adultVerificationStatus);
},
adultStatusTitle() {
if (!this.isAdult) {
return this.$t('settings.account.adultStatus.ineligible.title');
}
return this.$t(`settings.account.adultStatus.${this.adultVerificationStatus}.title`);
},
adultStatusText() {
if (!this.isAdult) {
return this.$t('settings.account.adultStatus.ineligible.body');
}
return this.$t(`settings.account.adultStatus.${this.adultVerificationStatus}.body`);
}
},
methods: {
@@ -137,13 +168,17 @@ export default {
console.error('Fehler beim Speichern der Account-Einstellungen:', error);
showApiError(this, error, 'Ein Fehler ist beim Speichern der Account-Einstellungen aufgetreten.');
}
}
},
},
async mounted() {
const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
this.username = response.data.username;
this.showInSearch = response.data.showinsearch;
this.email = response.data.email;
this.isAdult = !!response.data.isAdult;
this.adultVerificationStatus = response.data.adultVerificationStatus || 'none';
this.adultVerificationRequest = response.data.adultVerificationRequest || null;
// Stelle sicher, dass Passwort-Felder leer sind
this.newpassword = '';
@@ -228,6 +263,29 @@ export default {
margin-top: 18px;
}
.account-settings__adult {
margin-top: 18px;
padding: 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(250, 244, 235, 0.72);
}
.account-settings__adult p,
.account-settings__adult-status {
color: var(--color-text-secondary);
}
.account-settings__adult-status {
display: grid;
gap: 6px;
margin-top: 10px;
}
.account-settings__adult-actions {
margin-top: 14px;
}
@media (max-width: 760px) {
.account-settings__grid {
grid-template-columns: 1fr;

View File

@@ -0,0 +1,201 @@
<template>
<div class="erotic-access-page">
<section class="erotic-access-hero surface-card">
<span class="erotic-access-eyebrow">{{ $t('socialnetwork.erotic.eyebrow') }}</span>
<h2>{{ $t('socialnetwork.erotic.accessTitle') }}</h2>
<p>{{ $t('socialnetwork.erotic.accessIntro') }}</p>
</section>
<section class="erotic-access-panel surface-card">
<div class="erotic-access-status">
<strong>{{ statusTitle }}</strong>
<span>{{ statusText }}</span>
</div>
<div v-if="account?.adultVerificationRequest" class="erotic-access-request">
<strong>{{ $t('socialnetwork.erotic.requestInfoTitle') }}</strong>
<span>{{ account.adultVerificationRequest.originalName }}</span>
<span v-if="account.adultVerificationRequest.submittedAt">{{ formattedSubmittedAt }}</span>
<span v-if="account.adultVerificationRequest.note">{{ account.adultVerificationRequest.note }}</span>
</div>
<div class="erotic-access-actions">
<router-link to="/settings/account" class="erotic-access-link">
{{ $t('socialnetwork.erotic.settingsLink') }}
</router-link>
</div>
<form v-if="canRequestVerification" class="erotic-access-form" @submit.prevent="requestVerification">
<label>
<span>{{ $t('socialnetwork.erotic.documentLabel') }}</span>
<input type="file" accept=".jpg,.jpeg,.png,.webp,.pdf" @change="handleFileChange" />
</label>
<label>
<span>{{ $t('socialnetwork.erotic.noteLabel') }}</span>
<textarea v-model="note" rows="4"></textarea>
</label>
<button type="submit">{{ $t('socialnetwork.erotic.requestVerification') }}</button>
</form>
</section>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { mapGetters } from 'vuex';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'EroticAccessView',
data() {
return {
account: null,
note: '',
documentFile: null
};
},
computed: {
...mapGetters(['user']),
status() {
return this.account?.adultVerificationStatus || 'none';
},
canRequestVerification() {
return this.account?.isAdult && ['none', 'rejected'].includes(this.status);
},
statusTitle() {
if (!this.account?.isAdult) {
return this.$t('settings.account.adultStatus.ineligible.title');
}
return this.$t(`socialnetwork.erotic.status.${this.status}.title`);
},
statusText() {
if (!this.account?.isAdult) {
return this.$t('settings.account.adultStatus.ineligible.body');
}
return this.$t(`socialnetwork.erotic.status.${this.status}.body`);
},
formattedSubmittedAt() {
const value = this.account?.adultVerificationRequest?.submittedAt;
if (!value) return '';
return new Date(value).toLocaleString();
}
},
methods: {
async loadAccount() {
const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
this.account = response.data;
},
handleFileChange(event) {
this.documentFile = event.target.files?.[0] || null;
},
async requestVerification() {
try {
const formData = new FormData();
if (this.documentFile) {
formData.append('document', this.documentFile);
}
formData.append('note', this.note || '');
await apiClient.post('/api/settings/adult-verification/request', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
await this.loadAccount();
this.note = '';
this.documentFile = null;
showSuccess(this, this.$t('socialnetwork.erotic.requestSent'));
} catch (error) {
showApiError(this, error, this.$t('socialnetwork.erotic.requestError'));
}
}
},
async mounted() {
await this.loadAccount();
if (this.account?.adultAccessEnabled) {
this.$router.replace('/socialnetwork/erotic/pictures');
}
}
};
</script>
<style lang="scss" scoped>
.erotic-access-page {
display: grid;
gap: 18px;
max-width: 920px;
}
.erotic-access-hero,
.erotic-access-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: linear-gradient(180deg, rgba(255, 249, 244, 0.98), rgba(245, 237, 229, 0.96));
box-shadow: var(--shadow-soft);
}
.erotic-access-hero {
padding: 26px 28px;
}
.erotic-access-eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: rgba(164, 98, 72, 0.14);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.erotic-access-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.erotic-access-panel {
display: grid;
gap: 18px;
padding: 24px;
}
.erotic-access-status {
display: grid;
gap: 6px;
padding: 14px 16px;
border-radius: var(--radius-md);
background: rgba(196, 162, 108, 0.14);
}
.erotic-access-request,
.erotic-access-form {
display: grid;
gap: 10px;
}
.erotic-access-request {
padding: 14px 16px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.6);
color: var(--color-text-secondary);
}
.erotic-access-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.erotic-access-link {
color: var(--color-primary);
font-weight: 600;
}
.erotic-access-form label {
display: grid;
gap: 6px;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<div class="adult-media-page">
<section class="adult-media-hero surface-card">
<span class="adult-media-eyebrow">{{ $t('socialnetwork.erotic.eyebrow') }}</span>
<h2>{{ title }}</h2>
<p>{{ $t('socialnetwork.erotic.intro') }}</p>
</section>
<section class="adult-media-panel surface-card">
<div class="adult-media-status">
<strong>{{ $t('socialnetwork.erotic.enabledTitle') }}</strong>
<span>{{ $t('socialnetwork.erotic.enabledBody') }}</span>
</div>
<div class="adult-media-roadmap">
<h3>{{ $t('socialnetwork.erotic.roadmapTitle') }}</h3>
<ul>
<li>{{ $t('socialnetwork.erotic.roadmapModeration') }}</li>
<li>{{ $t('socialnetwork.erotic.roadmapUpload') }}</li>
<li>{{ $t('socialnetwork.erotic.roadmapSeparation') }}</li>
</ul>
</div>
<router-link to="/settings/account" class="adult-media-link">
{{ $t('socialnetwork.erotic.settingsLink') }}
</router-link>
</section>
</div>
</template>
<script>
export default {
name: 'EroticMediaPlaceholderView',
props: {
mediaType: {
type: String,
default: 'pictures'
}
},
computed: {
title() {
return this.mediaType === 'videos'
? this.$t('socialnetwork.erotic.videosTitle')
: this.$t('socialnetwork.erotic.picturesTitle');
}
}
};
</script>
<style lang="scss" scoped>
.adult-media-page {
display: grid;
gap: 18px;
max-width: 920px;
}
.adult-media-hero,
.adult-media-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: linear-gradient(180deg, rgba(255, 249, 244, 0.98), rgba(245, 237, 229, 0.96));
box-shadow: var(--shadow-soft);
}
.adult-media-hero {
padding: 26px 28px;
}
.adult-media-eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: rgba(164, 98, 72, 0.14);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.adult-media-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.adult-media-panel {
display: grid;
gap: 18px;
padding: 24px;
}
.adult-media-status {
display: grid;
gap: 6px;
padding: 14px 16px;
border-radius: var(--radius-md);
background: rgba(196, 162, 108, 0.14);
}
.adult-media-roadmap h3 {
margin-bottom: 8px;
}
.adult-media-roadmap ul {
margin: 0;
padding-left: 18px;
color: var(--color-text-secondary);
}
.adult-media-link {
width: fit-content;
color: var(--color-primary);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,361 @@
<template>
<div class="gallery-page erotic-gallery-page">
<section class="gallery-hero erotic-gallery-hero surface-card">
<div>
<span class="gallery-kicker">{{ $t('socialnetwork.erotic.eyebrow') }}</span>
<h2>{{ $t('socialnetwork.erotic.picturesTitle') }}</h2>
<p>{{ $t('socialnetwork.erotic.picturesIntro') }}</p>
</div>
</section>
<div class="gallery-view">
<div class="sidebar surface-card">
<h3>{{ $t('socialnetwork.gallery.folders') }}</h3>
<ul class="tree">
<folder-item
v-for="folder in [folders]"
:key="folder.id"
:folder="folder"
:selected-folder="selectedFolder"
@select-folder="selectFolder"
:isLastItem="true"
:depth="0"
:parentsWithChildren="[false]"
@edit-folder="openEditFolderDialog"
@delete-folder="deleteFolder"
/>
</ul>
<button @click="openCreateFolderDialog">{{ $t('socialnetwork.gallery.create_folder') }}</button>
</div>
<div class="content">
<div class="upload-section surface-card">
<div class="upload-header" @click="toggleUploadSection">
<span><i class="icon-upload-toggle">{{ isUploadVisible ? '&#9650;' : '&#9660;' }}</i></span>
<h3>{{ $t('socialnetwork.erotic.uploadTitle') }}</h3>
</div>
<div v-if="isUploadVisible" class="upload-content">
<form @submit.prevent="handleUpload">
<div class="form-group">
<label for="imageTitle">{{ $t('socialnetwork.gallery.upload.image_title') }}</label>
<input v-model="imageTitle" type="text" :placeholder="$t('socialnetwork.gallery.upload.image_title')" />
</div>
<div class="form-group">
<label for="imageFile">{{ $t('socialnetwork.gallery.upload.image_file') }}</label>
<input type="file" accept="image/*" required @change="onFileChange" />
<div v-if="imagePreview" class="image-preview">
<img :src="imagePreview" alt="Image Preview" style="max-width: 150px; max-height: 150px;" />
</div>
</div>
<div class="form-group">
<label for="visibility">{{ $t('socialnetwork.gallery.upload.visibility') }}</label>
<multiselect
v-model="selectedVisibilities"
:options="visibilityOptions"
:multiple="true"
:close-on-select="false"
label="description"
:placeholder="$t('socialnetwork.gallery.upload.selectvisibility')"
:track-by="'value'"
>
<template #option="{ option }">
<span v-if="option && option.description">
{{ $t(`socialnetwork.gallery.visibility.${option.description}`) }}
</span>
</template>
<template #tag="{ option, remove }">
<span v-if="option && option.description" class="multiselect__tag">
{{ $t(`socialnetwork.gallery.visibility.${option.description}`) }}
<span @click="remove(option)">×</span>
</span>
</template>
</multiselect>
</div>
<button type="submit" class="upload-button">
{{ $t('socialnetwork.gallery.upload.upload_button') }}
</button>
</form>
</div>
</div>
<div class="image-list surface-card">
<h3>{{ $t('socialnetwork.gallery.images') }}</h3>
<ul v-if="images.length > 0" class="image-grid">
<li v-for="image in images" :key="image.id" class="erotic-image-card">
<div class="erotic-image-card__preview" @click="!image.isModeratedHidden && openImageDialog(image)">
<img v-if="!image.isModeratedHidden" :src="image.url || image.placeholder" alt="Loading..." />
<div v-else class="erotic-image-card__hidden">
{{ $t('socialnetwork.erotic.hiddenByModeration') }}
</div>
</div>
<p>{{ image.title }}</p>
<span v-if="image.isModeratedHidden" class="erotic-image-card__badge">
{{ $t('socialnetwork.erotic.moderationHidden') }}
</span>
<div class="erotic-image-card__actions">
<button type="button" class="secondary" @click="startReport('image', image.id)">
{{ $t('socialnetwork.erotic.reportAction') }}
</button>
</div>
<div v-if="reportTarget.type === 'image' && reportTarget.id === image.id" class="erotic-report-form">
<select v-model="reportReason">
<option v-for="option in reportReasonOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<textarea v-model="reportNote" rows="3" :placeholder="$t('socialnetwork.erotic.reportNote')" />
<div class="erotic-report-form__actions">
<button type="button" @click="submitReport">{{ $t('socialnetwork.erotic.submitReport') }}</button>
<button type="button" class="secondary" @click="resetReport">{{ $t('general.cancel') }}</button>
</div>
</div>
</li>
</ul>
<span v-else>{{ $t('socialnetwork.erotic.noimages') }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import Multiselect from 'vue-multiselect';
import FolderItem from '../../components/FolderItem.vue';
import 'vue-multiselect/dist/vue-multiselect.min.css';
import { EventBus } from '@/utils/eventBus.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
components: {
FolderItem,
Multiselect,
},
data() {
return {
folders: { children: [] },
images: [],
selectedFolder: null,
imageTitle: '',
fileToUpload: null,
isUploadVisible: true,
visibilityOptions: [],
selectedVisibilities: [],
imagePreview: null,
reportTarget: { type: null, id: null },
reportReason: 'other',
reportNote: '',
};
},
computed: {
reportReasonOptions() {
return ['suspected_minor', 'non_consensual', 'violence', 'harassment', 'spam', 'other'].map(value => ({
value,
label: this.$t(`socialnetwork.erotic.reportReasons.${value}`)
}));
}
},
async mounted() {
await this.loadFolders();
await this.loadImageVisibilities();
if (this.folders) {
this.selectFolder(this.folders);
}
EventBus.on('folderCreated', this.loadFolders);
},
beforeUnmount() {
EventBus.off('folderCreated', this.loadFolders);
},
methods: {
async loadFolders() {
const response = await apiClient.get('/api/socialnetwork/erotic/folders');
this.folders = response.data;
},
async loadImageVisibilities() {
const response = await apiClient.get('/api/socialnetwork/imagevisibilities');
this.visibilityOptions = response.data.filter(option => option.description !== 'everyone');
if (!this.selectedVisibilities.length) {
this.selectedVisibilities = this.visibilityOptions.filter(option => option.description === 'adults');
}
},
async selectFolder(folder) {
this.selectedFolder = folder;
await this.loadImages(folder.id);
},
async loadImages(folderId) {
const response = await apiClient.get(`/api/socialnetwork/erotic/folder/${folderId}`);
this.images = response.data.map((image) => ({
...image,
placeholder: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3C/svg%3E',
url: null,
}));
await this.fetchImages();
},
async fetchImages() {
this.images.forEach((image) => {
this.fetchImage(image);
});
},
openCreateFolderDialog() {
const parentFolder = this.selectedFolder || { id: null, name: this.$t('socialnetwork.gallery.root_folder') };
Object.assign(this.$root.$refs.createFolderDialog, {
parentFolder,
folderId: 0,
eroticMode: true,
});
this.$root.$refs.createFolderDialog.open();
},
onFileChange(event) {
this.fileToUpload = event.target.files[0];
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreview = e.target.result;
};
reader.readAsDataURL(this.fileToUpload);
},
async handleUpload() {
if (!this.fileToUpload || !this.selectedFolder?.id) return;
const formData = new FormData();
formData.append('image', this.fileToUpload);
formData.append('folderId', this.selectedFolder.id);
formData.append('title', this.imageTitle);
formData.append('visibility', JSON.stringify(this.selectedVisibilities.map((v) => v.id)));
await apiClient.post('/api/socialnetwork/erotic/images', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
await this.loadImages(this.selectedFolder.id);
this.imageTitle = '';
this.fileToUpload = null;
this.imagePreview = null;
this.selectedVisibilities = this.visibilityOptions.filter(option => option.description === 'adults');
},
async fetchImage(image) {
if (image.isModeratedHidden) {
return;
}
const userId = localStorage.getItem('userid') || sessionStorage.getItem('userid');
const response = await apiClient.get(`/api/socialnetwork/erotic/image/${image.hash}`, {
headers: { userid: userId },
responseType: 'blob',
});
image.url = URL.createObjectURL(response.data);
},
toggleUploadSection() {
this.isUploadVisible = !this.isUploadVisible;
},
openImageDialog(image) {
this.$root.$refs.editImageDialog.open(image);
},
startReport(type, id) {
this.reportTarget = { type, id };
this.reportReason = 'other';
this.reportNote = '';
},
resetReport() {
this.reportTarget = { type: null, id: null };
this.reportReason = 'other';
this.reportNote = '';
},
async submitReport() {
try {
await apiClient.post('/api/socialnetwork/erotic/report', {
targetType: this.reportTarget.type,
targetId: this.reportTarget.id,
reason: this.reportReason,
note: this.reportNote
});
showSuccess(this, this.$t('socialnetwork.erotic.reportSubmitted'));
this.resetReport();
} catch (error) {
showApiError(this, error, this.$t('socialnetwork.erotic.reportError'));
}
},
async saveImage(updatedImage) {
const response = await apiClient.put(`/api/socialnetwork/erotic/images/${updatedImage.id}`, {
title: updatedImage.title,
visibilities: updatedImage.visibilities,
});
this.images = response.data.map((image) => ({
...image,
placeholder: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3C/svg%3E',
url: null,
}));
await this.fetchImages();
},
openEditFolderDialog(folder) {
const parentFolder = folder.parent || { id: null, name: this.$t('socialnetwork.gallery.root_folder') };
Object.assign(this.$root.$refs.createFolderDialog, {
parentFolder,
folderId: folder.id,
eroticMode: true,
});
this.$root.$refs.createFolderDialog.open(folder);
},
async deleteFolder() {
// Separate delete flow for adult folders is intentionally not enabled yet.
},
},
};
</script>
<style scoped>
.erotic-gallery-page {
display: grid;
gap: 1.25rem;
}
.erotic-gallery-hero {
background:
radial-gradient(circle at top right, rgba(204, 44, 94, 0.18), transparent 38%),
linear-gradient(140deg, rgba(37, 25, 33, 0.98), rgba(83, 34, 51, 0.96));
color: #fff1f5;
}
.erotic-gallery-hero p {
color: rgba(255, 241, 245, 0.85);
}
.erotic-image-card {
display: grid;
gap: 0.55rem;
}
.erotic-image-card__preview {
cursor: pointer;
}
.erotic-image-card__hidden {
display: grid;
place-items: center;
min-height: 180px;
border-radius: var(--radius-md);
background: rgba(96, 32, 48, 0.18);
color: var(--color-text-secondary);
text-align: center;
padding: 1rem;
}
.erotic-image-card__badge {
display: inline-flex;
width: fit-content;
padding: 0.2rem 0.65rem;
border-radius: var(--radius-pill);
background: rgba(176, 88, 88, 0.14);
color: #8b3340;
font-size: 0.78rem;
font-weight: 700;
}
.erotic-image-card__actions,
.erotic-report-form,
.erotic-report-form__actions {
display: grid;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<div class="erotic-videos-page">
<section class="erotic-videos-hero surface-card">
<div>
<span class="erotic-videos-eyebrow">{{ $t('socialnetwork.erotic.eyebrow') }}</span>
<h2>{{ $t('socialnetwork.erotic.videosTitle') }}</h2>
<p>{{ $t('socialnetwork.erotic.videosIntro') }}</p>
</div>
</section>
<section class="erotic-videos-upload surface-card">
<div class="erotic-videos-upload__header">
<h3>{{ $t('socialnetwork.erotic.videoUploadTitle') }}</h3>
<p>{{ $t('socialnetwork.erotic.videoUploadHint') }}</p>
</div>
<form class="erotic-videos-form" @submit.prevent="handleUpload">
<label>
<span>{{ $t('socialnetwork.gallery.upload.image_title') }}</span>
<input v-model="title" type="text" :placeholder="$t('socialnetwork.gallery.upload.image_title')" />
</label>
<label>
<span>{{ $t('socialnetwork.erotic.videoDescription') }}</span>
<textarea v-model="description" rows="3" />
</label>
<label>
<span>{{ $t('socialnetwork.erotic.videoFile') }}</span>
<input type="file" accept="video/mp4,video/webm,video/ogg,video/quicktime" required @change="onFileChange" />
</label>
<button type="submit">{{ $t('socialnetwork.gallery.upload.upload_button') }}</button>
</form>
</section>
<section class="erotic-videos-list surface-card">
<h3>{{ $t('socialnetwork.erotic.myVideos') }}</h3>
<div v-if="videos.length === 0" class="erotic-videos-empty">
{{ $t('socialnetwork.erotic.noVideos') }}
</div>
<ul v-else class="erotic-videos-grid">
<li v-for="video in videos" :key="video.id" class="erotic-videos-card">
<video v-if="!video.isModeratedHidden" :src="video.url" controls preload="metadata" />
<div v-else class="erotic-videos-card__hidden">
{{ $t('socialnetwork.erotic.hiddenByModeration') }}
</div>
<strong>{{ video.title }}</strong>
<span v-if="video.isModeratedHidden" class="erotic-videos-card__badge">
{{ $t('socialnetwork.erotic.moderationHidden') }}
</span>
<p v-if="video.description">{{ video.description }}</p>
<div class="erotic-videos-card__actions">
<button type="button" class="secondary" @click="startReport('video', video.id)">
{{ $t('socialnetwork.erotic.reportAction') }}
</button>
</div>
<div v-if="reportTarget.type === 'video' && reportTarget.id === video.id" class="erotic-report-form">
<select v-model="reportReason">
<option v-for="option in reportReasonOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<textarea v-model="reportNote" rows="3" :placeholder="$t('socialnetwork.erotic.reportNote')" />
<div class="erotic-report-form__actions">
<button type="button" @click="submitReport">{{ $t('socialnetwork.erotic.submitReport') }}</button>
<button type="button" class="secondary" @click="resetReport">{{ $t('general.cancel') }}</button>
</div>
</div>
</li>
</ul>
</section>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'EroticVideosView',
data() {
return {
videos: [],
fileToUpload: null,
title: '',
description: '',
reportTarget: { type: null, id: null },
reportReason: 'other',
reportNote: ''
};
},
computed: {
reportReasonOptions() {
return ['suspected_minor', 'non_consensual', 'violence', 'harassment', 'spam', 'other'].map(value => ({
value,
label: this.$t(`socialnetwork.erotic.reportReasons.${value}`)
}));
}
},
async mounted() {
await this.loadVideos();
},
methods: {
async loadVideos() {
const response = await apiClient.get('/api/socialnetwork/erotic/videos');
this.videos = await Promise.all(response.data.map(async (video) => ({
...video,
url: video.isModeratedHidden ? null : await this.fetchVideoUrl(video.hash),
})));
},
async fetchVideoUrl(hash) {
const response = await apiClient.get(`/api/socialnetwork/erotic/video/${hash}`, {
responseType: 'blob',
});
return URL.createObjectURL(response.data);
},
onFileChange(event) {
this.fileToUpload = event.target.files[0];
},
async handleUpload() {
if (!this.fileToUpload) return;
const formData = new FormData();
formData.append('video', this.fileToUpload);
formData.append('title', this.title);
formData.append('description', this.description);
await apiClient.post('/api/socialnetwork/erotic/videos', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
this.fileToUpload = null;
this.title = '';
this.description = '';
await this.loadVideos();
},
startReport(type, id) {
this.reportTarget = { type, id };
this.reportReason = 'other';
this.reportNote = '';
},
resetReport() {
this.reportTarget = { type: null, id: null };
this.reportReason = 'other';
this.reportNote = '';
},
async submitReport() {
try {
await apiClient.post('/api/socialnetwork/erotic/report', {
targetType: this.reportTarget.type,
targetId: this.reportTarget.id,
reason: this.reportReason,
note: this.reportNote
});
showSuccess(this, this.$t('socialnetwork.erotic.reportSubmitted'));
this.resetReport();
} catch (error) {
showApiError(this, error, this.$t('socialnetwork.erotic.reportError'));
}
}
}
};
</script>
<style scoped>
.erotic-videos-page {
display: grid;
gap: 1.25rem;
}
.erotic-videos-hero,
.erotic-videos-upload,
.erotic-videos-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.erotic-videos-hero {
padding: 1.5rem;
background:
radial-gradient(circle at top right, rgba(204, 44, 94, 0.18), transparent 38%),
linear-gradient(140deg, rgba(37, 25, 33, 0.98), rgba(83, 34, 51, 0.96));
color: #fff1f5;
}
.erotic-videos-eyebrow {
display: inline-flex;
margin-bottom: 0.5rem;
padding: 0.25rem 0.7rem;
border-radius: 999px;
background: rgba(255, 241, 245, 0.12);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.erotic-videos-hero p {
color: rgba(255, 241, 245, 0.84);
}
.erotic-videos-upload,
.erotic-videos-list {
padding: 1.25rem;
}
.erotic-videos-upload__header,
.erotic-videos-form {
display: grid;
gap: 0.75rem;
}
.erotic-videos-form label {
display: grid;
gap: 0.35rem;
}
.erotic-videos-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
.erotic-videos-card {
display: grid;
gap: 0.5rem;
padding: 0.85rem;
border-radius: var(--radius-md);
background: rgba(112, 60, 80, 0.08);
}
.erotic-videos-card__hidden {
display: grid;
place-items: center;
min-height: 160px;
border-radius: var(--radius-sm);
background: rgba(96, 32, 48, 0.18);
color: var(--color-text-secondary);
text-align: center;
padding: 1rem;
}
.erotic-videos-card__badge {
display: inline-flex;
width: fit-content;
padding: 0.2rem 0.65rem;
border-radius: var(--radius-pill);
background: rgba(176, 88, 88, 0.14);
color: #8b3340;
font-size: 0.78rem;
font-weight: 700;
}
.erotic-videos-card video {
width: 100%;
border-radius: var(--radius-sm);
background: #120b0f;
}
.erotic-videos-card__actions,
.erotic-report-form,
.erotic-report-form__actions {
display: grid;
gap: 0.5rem;
}
.erotic-videos-empty {
color: var(--color-text-secondary);
}
</style>

View File

@@ -166,6 +166,7 @@ export default {
Object.assign(this.$root.$refs.createFolderDialog, {
parentFolder: parentFolder,
folderId: 0,
eroticMode: false,
});
this.$root.$refs.createFolderDialog.open();
},
@@ -246,6 +247,7 @@ export default {
Object.assign(this.$root.$refs.createFolderDialog, {
parentFolder: parentFolder,
folderId: folder.id,
eroticMode: false,
});
this.$root.$refs.createFolderDialog.open(folder);
},