Files
yourpart3/frontend/src/views/social/EroticVideosView.vue
Torsten Schulz (local) c50a6b3c35 feat(localization): expand language support and enhance UI for user settings
- Added support for additional UI locales including Cebuano and Spanish, improving accessibility for a broader user base.
- Updated language selection components in the AppHeader and SettingsWidget to reflect new language options, enhancing user experience.
- Enhanced localization of various UI elements across components, ensuring consistent language representation and improved user engagement.
- Implemented logic to synchronize user language preferences with backend settings, providing a seamless experience when changing languages.
2026-04-02 07:54:44 +02:00

650 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="contenthidden">
<div class="contentscroll">
<div class="erotic-videos-page">
<section class="erotic-videos-hero surface-card">
<div>
<span class="erotic-videos-eyebrow">{{ $t('socialnetwork.erotic.eyebrow') }}</span>
<h2>{{ isForeignView ? `${$t('socialnetwork.erotic.videosTitle')} · ${viewUsername}` : $t('socialnetwork.erotic.videosTitle') }}</h2>
<p>{{ isForeignView ? $t('socialnetwork.erotic.foreignVideosIntro') : $t('socialnetwork.erotic.videosIntro') }}</p>
</div>
</section>
<div class="erotic-videos-workspace">
<aside class="erotic-videos-sidebar">
<section v-if="!isForeignView" 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="4" />
</label>
<label>
<span>{{ $t('socialnetwork.gallery.upload.visibility') }}</span>
<multiselect
v-model="selectedVisibilities"
:options="visibilityOptions"
:multiple="true"
:close-on-select="false"
label="description"
:placeholder="$t('socialnetwork.gallery.upload.selectvisibility')"
:track-by="'id'"
>
<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>
</label>
<label v-if="requiresSelectedUsers">
<span>{{ $t('socialnetwork.gallery.visibility.selected-users') }}</span>
<input v-model="selectedUsernamesText" type="text" :placeholder="$t('socialnetwork.erotic.selectedUsersPlaceholder')" />
</label>
<label>
<span>{{ $t('socialnetwork.erotic.videoFile') }}</span>
<input type="file" accept="video/mp4,video/webm,video/ogg,video/quicktime" required @change="onFileChange" />
</label>
<div class="erotic-videos-upload__meta">
<span>{{ $t('socialnetwork.erotic.videoFormats') }}</span>
<span v-if="fileToUpload">{{ fileToUpload.name }}</span>
</div>
<button type="submit">{{ $t('socialnetwork.gallery.upload.upload_button') }}</button>
</form>
</section>
<section class="erotic-videos-panel surface-card">
<h3>{{ $t('socialnetwork.erotic.libraryTitle') }}</h3>
<div class="erotic-videos-panel__list">
<div class="erotic-videos-panel__item">
<span>{{ isForeignView ? $t('socialnetwork.erotic.sharedVideos') : $t('socialnetwork.erotic.myVideos') }}</span>
<strong>{{ videos.length }}</strong>
</div>
<div class="erotic-videos-panel__item">
<span>{{ $t('socialnetwork.erotic.latestUpload') }}</span>
<strong>{{ latestVideoTitle }}</strong>
</div>
<div class="erotic-videos-panel__item">
<span>{{ $t('socialnetwork.erotic.visibleVideos') }}</span>
<strong>{{ visibleVideosCount }}</strong>
</div>
<div class="erotic-videos-panel__item">
<span>{{ $t('socialnetwork.erotic.moderationCases') }}</span>
<strong>{{ hiddenVideosCount }}</strong>
</div>
</div>
</section>
<section class="erotic-videos-panel surface-card">
<h3>{{ $t('socialnetwork.erotic.notesTitle') }}</h3>
<ul class="erotic-videos-checklist">
<li>{{ isForeignView ? $t('socialnetwork.erotic.foreignVideosOnlyHint') : $t('socialnetwork.erotic.videoUploadHint') }}</li>
<li>{{ $t('socialnetwork.erotic.friendsVisibilityHint') }}</li>
<li>{{ $t('socialnetwork.erotic.selectedUsersVisibilityHint') }}</li>
<li>{{ $t('socialnetwork.erotic.reportHint', { action: $t('socialnetwork.erotic.reportAction') }) }}</li>
</ul>
</section>
</aside>
<section class="erotic-videos-library surface-card">
<div class="erotic-videos-library__header">
<div>
<h3>{{ isForeignView ? $t('socialnetwork.erotic.sharedVideos') : $t('socialnetwork.erotic.myVideos') }}</h3>
<p>{{ isForeignView ? $t('socialnetwork.erotic.sharedVideosIntro') : $t('socialnetwork.erotic.libraryIntro') }}</p>
</div>
<span class="erotic-videos-library__count">{{ videos.length }}</span>
</div>
<div v-if="videos.length === 0" class="erotic-videos-empty">
<strong>{{ $t('socialnetwork.erotic.noVideos') }}</strong>
<span>{{ isForeignView ? $t('socialnetwork.erotic.noSharedVideos') : $t('socialnetwork.erotic.libraryEmptyHint') }}</span>
</div>
<div v-else class="erotic-videos-library__scroll">
<ul 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>
<div class="erotic-videos-card__meta">
<strong>{{ video.title || $t('socialnetwork.erotic.untitled') }}</strong>
<span v-if="video.createdAtLabel">{{ video.createdAtLabel }}</span>
</div>
<div v-if="video.visibilities?.length" class="erotic-videos-card__visibility">
<span v-for="visibility in video.visibilities" :key="`${video.id}-${visibility.id}`" class="erotic-videos-card__visibility-badge">
{{ $t(`socialnetwork.gallery.visibility.${visibility.description}`) }}
</span>
</div>
<span v-if="video.isModeratedHidden" class="erotic-videos-card__badge">
{{ $t('socialnetwork.erotic.moderationHidden') }}
</span>
<p v-if="video.description">{{ video.description }}</p>
<div v-if="!isForeignView" class="erotic-videos-card__edit">
<button type="button" class="secondary" @click="toggleEditor(video.id)">
{{ editingVideoId === video.id ? $t('socialnetwork.erotic.closeEditing') : $t('socialnetwork.erotic.editVisibility') }}
</button>
<div v-if="editingVideoId === video.id" class="erotic-videos-editor">
<label>
<span>{{ $t('socialnetwork.gallery.upload.image_title') }}</span>
<input v-model="editForm.title" type="text" />
</label>
<label>
<span>{{ $t('socialnetwork.erotic.videoDescription') }}</span>
<textarea v-model="editForm.description" rows="3" />
</label>
<label>
<span>{{ $t('socialnetwork.gallery.upload.visibility') }}</span>
<multiselect
v-model="editForm.visibilities"
:options="visibilityOptions"
:multiple="true"
:close-on-select="false"
label="description"
:placeholder="$t('socialnetwork.gallery.upload.selectvisibility')"
:track-by="'id'"
>
<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>
</label>
<label v-if="editRequiresSelectedUsers">
<span>{{ $t('socialnetwork.gallery.visibility.selected-users') }}</span>
<input v-model="editForm.selectedUsernamesText" type="text" :placeholder="$t('socialnetwork.erotic.selectedUsersPlaceholder')" />
</label>
<div class="erotic-videos-editor__actions">
<button type="button" @click="saveVideo(video.id)">{{ $t('common.save') }}</button>
</div>
</div>
</div>
<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>
</div>
</section>
</div>
</div>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import Multiselect from 'vue-multiselect';
import 'vue-multiselect/dist/vue-multiselect.min.css';
import { mapGetters } from 'vuex';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'EroticVideosView',
components: {
Multiselect
},
data() {
return {
videos: [],
fileToUpload: null,
title: '',
description: '',
visibilityOptions: [],
selectedVisibilities: [],
selectedUsernamesText: '',
editingVideoId: null,
editForm: {
title: '',
description: '',
visibilities: [],
selectedUsernamesText: ''
},
reportTarget: { type: null, id: null },
reportReason: 'other',
reportNote: '',
viewUsername: ''
};
},
computed: {
...mapGetters(['user']),
reportReasonOptions() {
return ['suspected_minor', 'non_consensual', 'violence', 'harassment', 'spam', 'other'].map(value => ({
value,
label: this.$t(`socialnetwork.erotic.reportReasons.${value}`)
}));
},
isForeignView() {
return Boolean(this.viewUsername && this.viewUsername !== this.user?.username);
},
requiresSelectedUsers() {
return this.selectedVisibilities.some(option => option?.description === 'selected-users');
},
editRequiresSelectedUsers() {
return this.editForm.visibilities.some(option => option?.description === 'selected-users');
},
visibleVideosCount() {
return this.videos.filter((video) => !video.isModeratedHidden).length;
},
hiddenVideosCount() {
return this.videos.filter((video) => video.isModeratedHidden).length;
},
latestVideoTitle() {
return this.videos[0]?.title || this.$t('socialnetwork.erotic.noUploadYet');
}
},
async mounted() {
await this.initializeView();
await this.loadImageVisibilities();
},
beforeUnmount() {
this.releaseVideoUrls();
},
methods: {
async initializeView() {
this.viewUsername = String(this.$route.query.username || '').trim();
await this.loadVideos();
},
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 loadVideos() {
this.releaseVideoUrls();
const response = this.isForeignView
? await apiClient.get(`/api/socialnetwork/profile/erotic/videos/${this.viewUsername}`)
: 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),
createdAtLabel: video.createdAt ? new Date(video.createdAt).toLocaleDateString() : '',
})));
},
releaseVideoUrls() {
this.videos.forEach((video) => {
if (video?.url) {
URL.revokeObjectURL(video.url);
}
});
},
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);
formData.append('visibility', JSON.stringify(this.selectedVisibilities.map((visibility) => visibility.id)));
formData.append('selectedUsers', JSON.stringify(
this.selectedUsernamesText.split(',').map(value => value.trim()).filter(Boolean)
));
await apiClient.post('/api/socialnetwork/erotic/videos', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
this.fileToUpload = null;
this.title = '';
this.description = '';
this.selectedUsernamesText = '';
this.selectedVisibilities = this.visibilityOptions.filter(option => option.description === 'adults');
await this.loadVideos();
},
toggleEditor(videoId) {
if (this.editingVideoId === videoId) {
this.editingVideoId = null;
return;
}
const video = this.videos.find(entry => entry.id === videoId);
if (!video) return;
this.editingVideoId = videoId;
this.editForm = {
title: video.title || '',
description: video.description || '',
visibilities: this.visibilityOptions.filter(option =>
(video.visibilities || []).some(visibility => visibility.id === option.id)
),
selectedUsernamesText: (video.selectedUsers || []).join(', ')
};
},
async saveVideo(videoId) {
await apiClient.put(`/api/socialnetwork/erotic/videos/${videoId}`, {
title: this.editForm.title,
description: this.editForm.description,
visibilities: this.editForm.visibilities,
selectedUsers: this.editForm.selectedUsernamesText.split(',').map(value => value.trim()).filter(Boolean)
});
this.editingVideoId = null;
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'));
}
}
},
watch: {
'$route.query.username': {
async handler() {
await this.initializeView();
}
}
}
};
</script>
<style scoped>
.erotic-videos-page {
display: grid;
gap: 1.25rem;
padding-bottom: 1.25rem;
}
.erotic-videos-hero,
.erotic-videos-upload,
.erotic-videos-library,
.erotic-videos-panel {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.erotic-videos-hero {
padding: 1.4rem 1.5rem;
}
.erotic-videos-eyebrow {
display: inline-flex;
margin-bottom: 0.5rem;
padding: 0.25rem 0.7rem;
border-radius: 999px;
background: rgba(120, 195, 138, 0.14);
color: #42634e;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.erotic-videos-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.erotic-videos-workspace {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 1.25rem;
min-height: min(72vh, 860px);
}
.erotic-videos-sidebar {
display: grid;
gap: 1rem;
align-content: start;
min-height: 0;
}
.erotic-videos-upload,
.erotic-videos-library,
.erotic-videos-panel {
padding: 1.25rem;
overflow: hidden;
}
.erotic-videos-upload__header,
.erotic-videos-form {
display: grid;
gap: 0.75rem;
}
.erotic-videos-form label,
.erotic-videos-editor label {
display: grid;
gap: 0.35rem;
}
.erotic-videos-upload__meta {
display: grid;
gap: 0.25rem;
color: var(--color-text-secondary);
font-size: 0.82rem;
}
.erotic-videos-panel {
display: grid;
gap: 0.9rem;
}
.erotic-videos-panel__list,
.erotic-videos-checklist {
display: grid;
gap: 0.7rem;
}
.erotic-videos-panel__item {
display: grid;
gap: 0.15rem;
padding: 0.75rem 0.85rem;
border-radius: var(--radius-md);
background: rgba(112, 60, 80, 0.06);
}
.erotic-videos-panel__item span,
.erotic-videos-checklist {
color: var(--color-text-secondary);
}
.erotic-videos-checklist {
margin: 0;
padding-left: 1.1rem;
}
.erotic-videos-library {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 1rem;
min-height: 0;
}
.erotic-videos-library__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
}
.erotic-videos-library__header p {
margin: 0.3rem 0 0;
color: var(--color-text-secondary);
}
.erotic-videos-library__count {
display: inline-flex;
min-width: 2.2rem;
min-height: 2.2rem;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(112, 60, 80, 0.08);
font-weight: 800;
}
.erotic-videos-library__scroll {
min-height: 0;
overflow: auto;
padding-right: 0.2rem;
}
.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__meta {
display: grid;
gap: 0.2rem;
}
.erotic-videos-card__meta span {
color: var(--color-text-secondary);
font-size: 0.82rem;
}
.erotic-videos-card__visibility {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.erotic-videos-card__visibility-badge {
display: inline-flex;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-pill);
background: rgba(66, 99, 78, 0.12);
color: #42634e;
font-size: 0.74rem;
font-weight: 700;
}
.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-videos-card__edit,
.erotic-videos-editor,
.erotic-videos-editor__actions,
.erotic-report-form,
.erotic-report-form__actions {
display: grid;
gap: 0.5rem;
}
.erotic-videos-empty {
display: grid;
gap: 0.35rem;
place-items: center;
min-height: 240px;
padding: 1.5rem;
text-align: center;
color: var(--color-text-secondary);
background: rgba(112, 60, 80, 0.05);
border-radius: var(--radius-md);
}
@media (max-width: 980px) {
.erotic-videos-workspace {
grid-template-columns: 1fr;
min-height: auto;
}
.erotic-videos-library {
grid-template-rows: auto auto;
}
.erotic-videos-library__scroll {
overflow: visible;
}
}
</style>