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:
@@ -29,6 +29,12 @@ class AdminController {
|
|||||||
this.getUser = this.getUser.bind(this);
|
this.getUser = this.getUser.bind(this);
|
||||||
this.getUsers = this.getUsers.bind(this);
|
this.getUsers = this.getUsers.bind(this);
|
||||||
this.updateUser = this.updateUser.bind(this);
|
this.updateUser = this.updateUser.bind(this);
|
||||||
|
this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this);
|
||||||
|
this.setAdultVerificationStatus = this.setAdultVerificationStatus.bind(this);
|
||||||
|
this.getAdultVerificationDocument = this.getAdultVerificationDocument.bind(this);
|
||||||
|
this.getEroticModerationReports = this.getEroticModerationReports.bind(this);
|
||||||
|
this.applyEroticModerationAction = this.applyEroticModerationAction.bind(this);
|
||||||
|
this.getEroticModerationPreview = this.getEroticModerationPreview.bind(this);
|
||||||
|
|
||||||
// Rights
|
// Rights
|
||||||
this.listRightTypes = this.listRightTypes.bind(this);
|
this.listRightTypes = this.listRightTypes.bind(this);
|
||||||
@@ -119,6 +125,97 @@ class AdminController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAdultVerificationRequests(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: requester } = req.headers;
|
||||||
|
const { status = 'pending' } = req.query;
|
||||||
|
const result = await AdminService.getAdultVerificationRequests(requester, status);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAdultVerificationStatus(req, res) {
|
||||||
|
const schema = Joi.object({
|
||||||
|
status: Joi.string().valid('approved', 'rejected', 'pending').required()
|
||||||
|
});
|
||||||
|
const { error, value } = schema.validate(req.body || {});
|
||||||
|
if (error) {
|
||||||
|
return res.status(400).json({ error: error.details[0].message });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { userid: requester } = req.headers;
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await AdminService.setAdultVerificationStatus(requester, id, value.status);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message === 'noaccess' ? 403 : (['notfound', 'notadult', 'wrongstatus', 'missingparamtype'].includes(err.message) ? 400 : 500);
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdultVerificationDocument(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: requester } = req.headers;
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await AdminService.getAdultVerificationDocument(requester, id);
|
||||||
|
res.setHeader('Content-Type', result.mimeType);
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.originalName)}"`);
|
||||||
|
res.sendFile(result.filePath);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message === 'noaccess' ? 403 : (['notfound', 'norequest', 'nofile'].includes(err.message) ? 404 : 500);
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEroticModerationReports(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: requester } = req.headers;
|
||||||
|
const { status = 'open' } = req.query;
|
||||||
|
const result = await AdminService.getEroticModerationReports(requester, status);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyEroticModerationAction(req, res) {
|
||||||
|
const schema = Joi.object({
|
||||||
|
action: Joi.string().valid('dismiss', 'hide_content', 'restore_content', 'delete_content', 'block_uploads', 'revoke_access').required(),
|
||||||
|
note: Joi.string().allow('', null).max(2000).optional()
|
||||||
|
});
|
||||||
|
const { error, value } = schema.validate(req.body || {});
|
||||||
|
if (error) {
|
||||||
|
return res.status(400).json({ error: error.details[0].message });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { userid: requester } = req.headers;
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await AdminService.applyEroticModerationAction(requester, Number(id), value.action, value.note || null);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message === 'noaccess' ? 403 : (['notfound', 'targetnotfound', 'wrongaction'].includes(err.message) ? 400 : 500);
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEroticModerationPreview(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: requester } = req.headers;
|
||||||
|
const { type, targetId } = req.params;
|
||||||
|
const result = await AdminService.getEroticModerationPreview(requester, type, Number(targetId));
|
||||||
|
res.setHeader('Content-Type', result.mimeType);
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.originalName)}"`);
|
||||||
|
res.sendFile(result.filePath);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message === 'noaccess' ? 403 : (['notfound', 'nofile', 'wrongtype'].includes(err.message) ? 404 : 500);
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Rights ---
|
// --- Rights ---
|
||||||
async listRightTypes(req, res) {
|
async listRightTypes(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -523,6 +620,7 @@ class AdminController {
|
|||||||
title: Joi.string().min(1).max(255).required(),
|
title: Joi.string().min(1).max(255).required(),
|
||||||
roomTypeId: Joi.number().integer().required(),
|
roomTypeId: Joi.number().integer().required(),
|
||||||
isPublic: Joi.boolean().required(),
|
isPublic: Joi.boolean().required(),
|
||||||
|
isAdultOnly: Joi.boolean().allow(null),
|
||||||
genderRestrictionId: Joi.number().integer().allow(null),
|
genderRestrictionId: Joi.number().integer().allow(null),
|
||||||
minAge: Joi.number().integer().min(0).allow(null),
|
minAge: Joi.number().integer().min(0).allow(null),
|
||||||
maxAge: Joi.number().integer().min(0).allow(null),
|
maxAge: Joi.number().integer().min(0).allow(null),
|
||||||
@@ -534,7 +632,7 @@ class AdminController {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return res.status(400).json({ error: error.details[0].message });
|
return res.status(400).json({ error: error.details[0].message });
|
||||||
}
|
}
|
||||||
const room = await AdminService.updateRoom(req.params.id, value);
|
const room = await AdminService.updateRoom(userId, req.params.id, value);
|
||||||
res.status(200).json(room);
|
res.status(200).json(room);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
@@ -553,6 +651,7 @@ class AdminController {
|
|||||||
title: Joi.string().min(1).max(255).required(),
|
title: Joi.string().min(1).max(255).required(),
|
||||||
roomTypeId: Joi.number().integer().required(),
|
roomTypeId: Joi.number().integer().required(),
|
||||||
isPublic: Joi.boolean().required(),
|
isPublic: Joi.boolean().required(),
|
||||||
|
isAdultOnly: Joi.boolean().allow(null),
|
||||||
genderRestrictionId: Joi.number().integer().allow(null),
|
genderRestrictionId: Joi.number().integer().allow(null),
|
||||||
minAge: Joi.number().integer().min(0).allow(null),
|
minAge: Joi.number().integer().min(0).allow(null),
|
||||||
maxAge: Joi.number().integer().min(0).allow(null),
|
maxAge: Joi.number().integer().min(0).allow(null),
|
||||||
@@ -579,7 +678,7 @@ class AdminController {
|
|||||||
if (!userId || !(await AdminService.hasUserAccess(userId, 'chatrooms'))) {
|
if (!userId || !(await AdminService.hasUserAccess(userId, 'chatrooms'))) {
|
||||||
return res.status(403).json({ error: 'Keine Berechtigung.' });
|
return res.status(403).json({ error: 'Keine Berechtigung.' });
|
||||||
}
|
}
|
||||||
await AdminService.deleteRoom(req.params.id);
|
await AdminService.deleteRoom(userId, req.params.id);
|
||||||
res.sendStatus(204);
|
res.sendStatus(204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ class ChatController {
|
|||||||
async getRoomList(req, res) {
|
async getRoomList(req, res) {
|
||||||
// Öffentliche Räume für Chat-Frontend
|
// Öffentliche Räume für Chat-Frontend
|
||||||
try {
|
try {
|
||||||
const rooms = await chatService.getRoomList();
|
const { userid: hashedUserId } = req.headers;
|
||||||
|
const adultOnly = String(req.query.adultOnly || '').toLowerCase() === 'true';
|
||||||
|
const rooms = await chatService.getRoomList(hashedUserId, { adultOnly });
|
||||||
res.status(200).json(rooms);
|
res.status(200).json(rooms);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
@@ -258,6 +258,14 @@ const menuStructure = {
|
|||||||
visible: ["mainadmin", "useradministration"],
|
visible: ["mainadmin", "useradministration"],
|
||||||
path: "/admin/users"
|
path: "/admin/users"
|
||||||
},
|
},
|
||||||
|
adultverification: {
|
||||||
|
visible: ["mainadmin", "useradministration"],
|
||||||
|
path: "/admin/users/adult-verification"
|
||||||
|
},
|
||||||
|
eroticmoderation: {
|
||||||
|
visible: ["mainadmin", "useradministration"],
|
||||||
|
path: "/admin/users/erotic-moderation"
|
||||||
|
},
|
||||||
userstatistics: {
|
userstatistics: {
|
||||||
visible: ["mainadmin"],
|
visible: ["mainadmin"],
|
||||||
path: "/admin/users/statistics"
|
path: "/admin/users/statistics"
|
||||||
@@ -343,7 +351,14 @@ class NavigationController {
|
|||||||
return age;
|
return age;
|
||||||
}
|
}
|
||||||
|
|
||||||
async filterMenu(menu, rights, age, userId) {
|
normalizeAdultVerificationStatus(value) {
|
||||||
|
if (['pending', 'approved', 'rejected'].includes(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async filterMenu(menu, rights, age, userId, adultVerificationStatus = 'none') {
|
||||||
const filteredMenu = {};
|
const filteredMenu = {};
|
||||||
try {
|
try {
|
||||||
const hasFalukantAccount = await this.hasFalukantAccount(userId);
|
const hasFalukantAccount = await this.hasFalukantAccount(userId);
|
||||||
@@ -357,8 +372,17 @@ class NavigationController {
|
|||||||
|| (value.visible.includes('hasfalukantaccount') && hasFalukantAccount)) {
|
|| (value.visible.includes('hasfalukantaccount') && hasFalukantAccount)) {
|
||||||
const { visible, ...itemWithoutVisible } = value;
|
const { visible, ...itemWithoutVisible } = value;
|
||||||
filteredMenu[key] = { ...itemWithoutVisible };
|
filteredMenu[key] = { ...itemWithoutVisible };
|
||||||
|
if (
|
||||||
|
value.visible.includes("over18")
|
||||||
|
&& age >= 18
|
||||||
|
&& adultVerificationStatus !== 'approved'
|
||||||
|
&& (value.path || value.action || value.view)
|
||||||
|
) {
|
||||||
|
filteredMenu[key].disabled = true;
|
||||||
|
filteredMenu[key].disabledReasonKey = 'socialnetwork.erotic.lockedShort';
|
||||||
|
}
|
||||||
if (value.children) {
|
if (value.children) {
|
||||||
filteredMenu[key].children = await this.filterMenu(value.children, rights, age, userId);
|
filteredMenu[key].children = await this.filterMenu(value.children, rights, age, userId, adultVerificationStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,20 +409,29 @@ class NavigationController {
|
|||||||
required: false
|
required: false
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
const userBirthdateParams = await UserParam.findAll({
|
const userParams = await UserParam.findAll({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: UserParamType,
|
model: UserParamType,
|
||||||
as: 'paramType',
|
as: 'paramType',
|
||||||
where: { description: 'birthdate' }
|
where: { description: ['birthdate', 'adult_verification_status'] }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
const birthDate = userBirthdateParams.length > 0 ? userBirthdateParams[0].value : (new Date()).toDateString();
|
let birthDate = (new Date()).toDateString();
|
||||||
|
let adultVerificationStatus = 'none';
|
||||||
|
for (const param of userParams) {
|
||||||
|
if (param.paramType?.description === 'birthdate' && param.value) {
|
||||||
|
birthDate = param.value;
|
||||||
|
}
|
||||||
|
if (param.paramType?.description === 'adult_verification_status') {
|
||||||
|
adultVerificationStatus = this.normalizeAdultVerificationStatus(param.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
const age = this.calculateAge(birthDate);
|
const age = this.calculateAge(birthDate);
|
||||||
const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean);
|
const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean);
|
||||||
const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id);
|
const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id, adultVerificationStatus);
|
||||||
|
|
||||||
// Vokabeltrainer: Sprachen werden im Frontend dynamisch geladen (wie Forum)
|
// Vokabeltrainer: Sprachen werden im Frontend dynamisch geladen (wie Forum)
|
||||||
// Keine children mehr, da das Menü nur 2 Ebenen unterstützt
|
// Keine children mehr, da das Menü nur 2 Ebenen unterstützt
|
||||||
|
|||||||
@@ -217,6 +217,25 @@ class SettingsController {
|
|||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async submitAdultVerificationRequest(req, res) {
|
||||||
|
try {
|
||||||
|
const hashedUserId = req.headers.userid;
|
||||||
|
const note = req.body?.note || '';
|
||||||
|
const file = req.file || null;
|
||||||
|
const result = await settingsService.submitAdultVerificationRequest(hashedUserId, { note }, file);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting adult verification request:', error);
|
||||||
|
const status = [
|
||||||
|
'User not found',
|
||||||
|
'Adult verification can only be requested by adult users',
|
||||||
|
'No verification document provided',
|
||||||
|
'Unsupported verification document type'
|
||||||
|
].includes(error.message) ? 400 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsController;
|
export default SettingsController;
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ class SocialNetworkController {
|
|||||||
this.changeImage = this.changeImage.bind(this);
|
this.changeImage = this.changeImage.bind(this);
|
||||||
this.getFoldersByUsername = this.getFoldersByUsername.bind(this);
|
this.getFoldersByUsername = this.getFoldersByUsername.bind(this);
|
||||||
this.deleteFolder = this.deleteFolder.bind(this);
|
this.deleteFolder = this.deleteFolder.bind(this);
|
||||||
|
this.getAdultFolders = this.getAdultFolders.bind(this);
|
||||||
|
this.createAdultFolder = this.createAdultFolder.bind(this);
|
||||||
|
this.getAdultFolderImageList = this.getAdultFolderImageList.bind(this);
|
||||||
|
this.uploadAdultImage = this.uploadAdultImage.bind(this);
|
||||||
|
this.getAdultImageByHash = this.getAdultImageByHash.bind(this);
|
||||||
|
this.changeAdultImage = this.changeAdultImage.bind(this);
|
||||||
|
this.listEroticVideos = this.listEroticVideos.bind(this);
|
||||||
|
this.uploadEroticVideo = this.uploadEroticVideo.bind(this);
|
||||||
|
this.getEroticVideoByHash = this.getEroticVideoByHash.bind(this);
|
||||||
|
this.reportEroticContent = this.reportEroticContent.bind(this);
|
||||||
this.createGuestbookEntry = this.createGuestbookEntry.bind(this);
|
this.createGuestbookEntry = this.createGuestbookEntry.bind(this);
|
||||||
this.getGuestbookEntries = this.getGuestbookEntries.bind(this);
|
this.getGuestbookEntries = this.getGuestbookEntries.bind(this);
|
||||||
this.deleteGuestbookEntry = this.deleteGuestbookEntry.bind(this);
|
this.deleteGuestbookEntry = this.deleteGuestbookEntry.bind(this);
|
||||||
@@ -187,6 +197,138 @@ class SocialNetworkController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAdultFolders(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.headers.userid;
|
||||||
|
const folders = await this.socialNetworkService.getAdultFolders(userId);
|
||||||
|
res.status(200).json(folders);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getAdultFolders:', error);
|
||||||
|
res.status(error.status || 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAdultFolder(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.headers.userid;
|
||||||
|
const folderData = req.body;
|
||||||
|
const { folderId } = req.params;
|
||||||
|
const folder = await this.socialNetworkService.createAdultFolder(userId, folderData, folderId);
|
||||||
|
res.status(201).json(folder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in createAdultFolder:', error);
|
||||||
|
res.status(error.status || 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdultFolderImageList(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.headers.userid;
|
||||||
|
const { folderId } = req.params;
|
||||||
|
const images = await this.socialNetworkService.getAdultFolderImageList(userId, folderId);
|
||||||
|
res.status(200).json(images);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getAdultFolderImageList:', error);
|
||||||
|
res.status(error.status || 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadAdultImage(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.headers.userid;
|
||||||
|
const file = req.file;
|
||||||
|
const formData = req.body;
|
||||||
|
const image = await this.socialNetworkService.uploadAdultImage(userId, file, formData);
|
||||||
|
res.status(201).json(image);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in uploadAdultImage:', error);
|
||||||
|
res.status(error.status || 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdultImageByHash(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.headers.userid;
|
||||||
|
const { hash } = req.params;
|
||||||
|
const filePath = await this.socialNetworkService.getAdultImageFilePath(userId, hash);
|
||||||
|
res.sendFile(filePath, err => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error sending adult file:', err);
|
||||||
|
res.status(500).json({ error: 'Error sending file' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getAdultImageByHash:', error);
|
||||||
|
res.status(error.status || 403).json({ error: error.message || 'Access denied or image not found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeAdultImage(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.headers.userid;
|
||||||
|
const { imageId } = req.params;
|
||||||
|
const { title, visibilities } = req.body;
|
||||||
|
const folderId = await this.socialNetworkService.changeAdultImage(userId, imageId, title, visibilities);
|
||||||
|
res.status(201).json(await this.socialNetworkService.getAdultFolderImageList(userId, folderId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in changeAdultImage:', error);
|
||||||
|
res.status(error.status || 403).json({ error: error.message || 'Access denied or image not found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEroticVideos(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.headers.userid;
|
||||||
|
const videos = await this.socialNetworkService.listEroticVideos(userId);
|
||||||
|
res.status(200).json(videos);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in listEroticVideos:', error);
|
||||||
|
res.status(error.status || 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadEroticVideo(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.headers.userid;
|
||||||
|
const file = req.file;
|
||||||
|
const formData = req.body;
|
||||||
|
const video = await this.socialNetworkService.uploadEroticVideo(userId, file, formData);
|
||||||
|
res.status(201).json(video);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in uploadEroticVideo:', error);
|
||||||
|
res.status(error.status || 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEroticVideoByHash(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.headers.userid;
|
||||||
|
const { hash } = req.params;
|
||||||
|
const { filePath, mimeType } = await this.socialNetworkService.getEroticVideoFilePath(userId, hash);
|
||||||
|
res.type(mimeType);
|
||||||
|
res.sendFile(filePath, err => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error sending adult video:', err);
|
||||||
|
res.status(500).json({ error: 'Error sending file' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getEroticVideoByHash:', error);
|
||||||
|
res.status(error.status || 403).json({ error: error.message || 'Access denied or video not found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reportEroticContent(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.headers.userid;
|
||||||
|
const result = await this.socialNetworkService.createEroticContentReport(userId, req.body || {});
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in reportEroticContent:', error);
|
||||||
|
res.status(error.status || 400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createGuestbookEntry(req, res) {
|
async createGuestbookEntry(req, res) {
|
||||||
try {
|
try {
|
||||||
const { htmlContent, recipientName } = req.body;
|
const { htmlContent, recipientName } = req.body;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** @type {import('sequelize-cli').Migration} */
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
{ schema: 'community', tableName: 'folder' },
|
||||||
|
'is_adult_area',
|
||||||
|
{
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
{ schema: 'community', tableName: 'image' },
|
||||||
|
'is_adult_content',
|
||||||
|
{
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.removeColumn({ schema: 'community', tableName: 'image' }, 'is_adult_content');
|
||||||
|
await queryInterface.removeColumn({ schema: 'community', tableName: 'folder' }, 'is_adult_area');
|
||||||
|
},
|
||||||
|
};
|
||||||
63
backend/migrations/20260326001000-create-erotic-video.cjs
Normal file
63
backend/migrations/20260326001000-create-erotic-video.cjs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** @type {import('sequelize-cli').Migration} */
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable(
|
||||||
|
{ schema: 'community', tableName: 'erotic_video' },
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
original_file_name: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
mime_type: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: { schema: 'community', tableName: 'user' },
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_video' });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** @type {import('sequelize-cli').Migration} */
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
{ schema: 'chat', tableName: 'room' },
|
||||||
|
'is_adult_only',
|
||||||
|
{
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.removeColumn({ schema: 'chat', tableName: 'room' }, 'is_adult_only');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** @type {import('sequelize-cli').Migration} */
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
{ schema: 'community', tableName: 'image' },
|
||||||
|
'is_moderated_hidden',
|
||||||
|
{
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
await queryInterface.addColumn(
|
||||||
|
{ schema: 'community', tableName: 'erotic_video' },
|
||||||
|
'is_moderated_hidden',
|
||||||
|
{
|
||||||
|
type: Sequelize.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
await queryInterface.createTable(
|
||||||
|
{ schema: 'community', tableName: 'erotic_content_report' },
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
reporter_id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
references: { model: { schema: 'community', tableName: 'user' }, key: 'id' },
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
target_type: {
|
||||||
|
type: Sequelize.STRING(20),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
target_id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
type: Sequelize.STRING(80),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: Sequelize.STRING(20),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'open'
|
||||||
|
},
|
||||||
|
action_taken: {
|
||||||
|
type: Sequelize.STRING(40),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
handled_by: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
references: { model: { schema: 'community', tableName: 'user' }, key: 'id' },
|
||||||
|
onDelete: 'SET NULL'
|
||||||
|
},
|
||||||
|
handled_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.literal('NOW()')
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.literal('NOW()')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_content_report' }).catch(() => {});
|
||||||
|
await queryInterface.removeColumn({ schema: 'community', tableName: 'erotic_video' }, 'is_moderated_hidden').catch(() => {});
|
||||||
|
await queryInterface.removeColumn({ schema: 'community', tableName: 'image' }, 'is_moderated_hidden').catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -18,6 +18,8 @@ import UserParamVisibilityType from './type/user_param_visibility.js';
|
|||||||
import UserParamVisibility from './community/user_param_visibility.js';
|
import UserParamVisibility from './community/user_param_visibility.js';
|
||||||
import Folder from './community/folder.js';
|
import Folder from './community/folder.js';
|
||||||
import Image from './community/image.js';
|
import Image from './community/image.js';
|
||||||
|
import EroticVideo from './community/erotic_video.js';
|
||||||
|
import EroticContentReport from './community/erotic_content_report.js';
|
||||||
import ImageVisibilityType from './type/image_visibility.js';
|
import ImageVisibilityType from './type/image_visibility.js';
|
||||||
import ImageVisibilityUser from './community/image_visibility_user.js';
|
import ImageVisibilityUser from './community/image_visibility_user.js';
|
||||||
import FolderImageVisibility from './community/folder_image_visibility.js';
|
import FolderImageVisibility from './community/folder_image_visibility.js';
|
||||||
@@ -209,6 +211,14 @@ export default function setupAssociations() {
|
|||||||
Image.belongsTo(User, { foreignKey: 'userId' });
|
Image.belongsTo(User, { foreignKey: 'userId' });
|
||||||
User.hasMany(Image, { foreignKey: 'userId' });
|
User.hasMany(Image, { foreignKey: 'userId' });
|
||||||
|
|
||||||
|
EroticVideo.belongsTo(User, { foreignKey: 'userId', as: 'owner' });
|
||||||
|
User.hasMany(EroticVideo, { foreignKey: 'userId', as: 'eroticVideos' });
|
||||||
|
|
||||||
|
EroticContentReport.belongsTo(User, { foreignKey: 'reporterId', as: 'reporter' });
|
||||||
|
User.hasMany(EroticContentReport, { foreignKey: 'reporterId', as: 'eroticContentReports' });
|
||||||
|
EroticContentReport.belongsTo(User, { foreignKey: 'handledBy', as: 'moderator' });
|
||||||
|
User.hasMany(EroticContentReport, { foreignKey: 'handledBy', as: 'handledEroticContentReports' });
|
||||||
|
|
||||||
// Image visibility associations
|
// Image visibility associations
|
||||||
Folder.belongsToMany(ImageVisibilityType, {
|
Folder.belongsToMany(ImageVisibilityType, {
|
||||||
through: FolderImageVisibility,
|
through: FolderImageVisibility,
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ const Room = sequelize.define('Room', {
|
|||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: true},
|
defaultValue: true},
|
||||||
|
isAdultOnly: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false},
|
||||||
genderRestrictionId: {
|
genderRestrictionId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: true},
|
allowNull: true},
|
||||||
|
|||||||
64
backend/models/community/erotic_content_report.js
Normal file
64
backend/models/community/erotic_content_report.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class EroticContentReport extends Model {}
|
||||||
|
|
||||||
|
EroticContentReport.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
reporterId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'reporter_id'
|
||||||
|
},
|
||||||
|
targetType: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'target_type'
|
||||||
|
},
|
||||||
|
targetId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'target_id'
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'open'
|
||||||
|
},
|
||||||
|
actionTaken: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'action_taken'
|
||||||
|
},
|
||||||
|
handledBy: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'handled_by'
|
||||||
|
},
|
||||||
|
handledAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'handled_at'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'EroticContentReport',
|
||||||
|
tableName: 'erotic_content_report',
|
||||||
|
schema: 'community',
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default EroticContentReport;
|
||||||
55
backend/models/community/erotic_video.js
Normal file
55
backend/models/community/erotic_video.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../utils/sequelize.js';
|
||||||
|
|
||||||
|
class EroticVideo extends Model {}
|
||||||
|
|
||||||
|
EroticVideo.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
originalFileName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'original_file_name'
|
||||||
|
},
|
||||||
|
hash: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
mimeType: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'mime_type'
|
||||||
|
},
|
||||||
|
isModeratedHidden: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
field: 'is_moderated_hidden'
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
field: 'user_id'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'EroticVideo',
|
||||||
|
tableName: 'erotic_video',
|
||||||
|
schema: 'community',
|
||||||
|
timestamps: true,
|
||||||
|
underscored: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default EroticVideo;
|
||||||
@@ -6,6 +6,11 @@ const Folder = sequelize.define('folder', {
|
|||||||
name: {
|
name: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false},
|
allowNull: false},
|
||||||
|
isAdultArea: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
parentId: {
|
parentId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: true
|
allowNull: true
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ const Image = sequelize.define('image', {
|
|||||||
title: {
|
title: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false},
|
allowNull: false},
|
||||||
|
isAdultContent: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
isModeratedHidden: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
description: {
|
description: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
allowNull: true},
|
allowNull: true},
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import UserParamVisibilityType from './type/user_param_visibility.js';
|
|||||||
import UserParamVisibility from './community/user_param_visibility.js';
|
import UserParamVisibility from './community/user_param_visibility.js';
|
||||||
import Folder from './community/folder.js';
|
import Folder from './community/folder.js';
|
||||||
import Image from './community/image.js';
|
import Image from './community/image.js';
|
||||||
|
import EroticVideo from './community/erotic_video.js';
|
||||||
|
import EroticContentReport from './community/erotic_content_report.js';
|
||||||
import ImageVisibilityType from './type/image_visibility.js';
|
import ImageVisibilityType from './type/image_visibility.js';
|
||||||
import ImageVisibilityUser from './community/image_visibility_user.js';
|
import ImageVisibilityUser from './community/image_visibility_user.js';
|
||||||
import FolderImageVisibility from './community/folder_image_visibility.js';
|
import FolderImageVisibility from './community/folder_image_visibility.js';
|
||||||
@@ -170,6 +172,8 @@ const models = {
|
|||||||
UserParamVisibility,
|
UserParamVisibility,
|
||||||
Folder,
|
Folder,
|
||||||
Image,
|
Image,
|
||||||
|
EroticVideo,
|
||||||
|
EroticContentReport,
|
||||||
ImageVisibilityType,
|
ImageVisibilityType,
|
||||||
ImageVisibilityUser,
|
ImageVisibilityUser,
|
||||||
FolderImageVisibility,
|
FolderImageVisibility,
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ router.delete('/chat/rooms/:id', authenticate, adminController.deleteRoom);
|
|||||||
router.get('/users/search', authenticate, adminController.searchUsers);
|
router.get('/users/search', authenticate, adminController.searchUsers);
|
||||||
router.get('/users/statistics', authenticate, adminController.getUserStatistics);
|
router.get('/users/statistics', authenticate, adminController.getUserStatistics);
|
||||||
router.get('/users/batch', authenticate, adminController.getUsers);
|
router.get('/users/batch', authenticate, adminController.getUsers);
|
||||||
|
router.get('/users/adult-verification', authenticate, adminController.getAdultVerificationRequests);
|
||||||
|
router.get('/users/:id/adult-verification/document', authenticate, adminController.getAdultVerificationDocument);
|
||||||
|
router.put('/users/:id/adult-verification', authenticate, adminController.setAdultVerificationStatus);
|
||||||
|
router.get('/users/erotic-moderation', authenticate, adminController.getEroticModerationReports);
|
||||||
|
router.get('/users/erotic-moderation/preview/:type/:targetId', authenticate, adminController.getEroticModerationPreview);
|
||||||
|
router.put('/users/erotic-moderation/:id', authenticate, adminController.applyEroticModerationAction);
|
||||||
router.get('/users/:id', authenticate, adminController.getUser);
|
router.get('/users/:id', authenticate, adminController.getUser);
|
||||||
router.put('/users/:id', authenticate, adminController.updateUser);
|
router.put('/users/:id', authenticate, adminController.updateUser);
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ router.post('/exit', chatController.removeUser);
|
|||||||
router.post('/initOneToOne', authenticate, chatController.initOneToOne);
|
router.post('/initOneToOne', authenticate, chatController.initOneToOne);
|
||||||
router.post('/oneToOne/sendMessage', authenticate, chatController.sendOneToOneMessage); // Neue Route zum Senden einer Nachricht
|
router.post('/oneToOne/sendMessage', authenticate, chatController.sendOneToOneMessage); // Neue Route zum Senden einer Nachricht
|
||||||
router.get('/oneToOne/messageHistory', authenticate, chatController.getOneToOneMessageHistory); // Neue Route zum Abrufen der Nachrichtengeschichte
|
router.get('/oneToOne/messageHistory', authenticate, chatController.getOneToOneMessageHistory); // Neue Route zum Abrufen der Nachrichtengeschichte
|
||||||
router.get('/rooms', chatController.getRoomList);
|
router.get('/rooms', authenticate, chatController.getRoomList);
|
||||||
router.get('/room-create-options', authenticate, chatController.getRoomCreateOptions);
|
router.get('/room-create-options', authenticate, chatController.getRoomCreateOptions);
|
||||||
router.get('/my-rooms', authenticate, chatController.getOwnRooms);
|
router.get('/my-rooms', authenticate, chatController.getOwnRooms);
|
||||||
router.delete('/my-rooms/:id', authenticate, chatController.deleteOwnRoom);
|
router.delete('/my-rooms/:id', authenticate, chatController.deleteOwnRoom);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import SettingsController from '../controllers/settingsController.js';
|
import SettingsController from '../controllers/settingsController.js';
|
||||||
import { authenticate } from '../middleware/authMiddleware.js';
|
import { authenticate } from '../middleware/authMiddleware.js';
|
||||||
|
import multer from 'multer';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const settingsController = new SettingsController();
|
const settingsController = new SettingsController();
|
||||||
|
const upload = multer();
|
||||||
|
|
||||||
router.post('/filter', authenticate, settingsController.filterSettings.bind(settingsController));
|
router.post('/filter', authenticate, settingsController.filterSettings.bind(settingsController));
|
||||||
router.post('/update', authenticate, settingsController.updateSetting.bind(settingsController));
|
router.post('/update', authenticate, settingsController.updateSetting.bind(settingsController));
|
||||||
@@ -21,5 +23,6 @@ router.get('/visibilities', authenticate, settingsController.getVisibilities.bin
|
|||||||
router.post('/update-visibility', authenticate, settingsController.updateVisibility.bind(settingsController));
|
router.post('/update-visibility', authenticate, settingsController.updateVisibility.bind(settingsController));
|
||||||
router.get('/llm', authenticate, settingsController.getLlmSettings.bind(settingsController));
|
router.get('/llm', authenticate, settingsController.getLlmSettings.bind(settingsController));
|
||||||
router.post('/llm', authenticate, settingsController.saveLlmSettings.bind(settingsController));
|
router.post('/llm', authenticate, settingsController.saveLlmSettings.bind(settingsController));
|
||||||
|
router.post('/adult-verification/request', authenticate, upload.single('document'), settingsController.submitAdultVerificationRequest.bind(settingsController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ router.post('/folders/:folderId', socialNetworkController.createFolder);
|
|||||||
router.get('/folders', socialNetworkController.getFolders);
|
router.get('/folders', socialNetworkController.getFolders);
|
||||||
router.get('/folder/:folderId', socialNetworkController.getFolderImageList);
|
router.get('/folder/:folderId', socialNetworkController.getFolderImageList);
|
||||||
router.post('/images', upload.single('image'), socialNetworkController.uploadImage);
|
router.post('/images', upload.single('image'), socialNetworkController.uploadImage);
|
||||||
|
router.post('/erotic/folders/:folderId', socialNetworkController.createAdultFolder);
|
||||||
|
router.get('/erotic/folders', socialNetworkController.getAdultFolders);
|
||||||
|
router.get('/erotic/folder/:folderId', socialNetworkController.getAdultFolderImageList);
|
||||||
|
router.post('/erotic/images', upload.single('image'), socialNetworkController.uploadAdultImage);
|
||||||
|
router.put('/erotic/images/:imageId', socialNetworkController.changeAdultImage);
|
||||||
|
router.get('/erotic/image/:hash', socialNetworkController.getAdultImageByHash);
|
||||||
|
router.get('/erotic/videos', socialNetworkController.listEroticVideos);
|
||||||
|
router.post('/erotic/videos', upload.single('video'), socialNetworkController.uploadEroticVideo);
|
||||||
|
router.get('/erotic/video/:hash', socialNetworkController.getEroticVideoByHash);
|
||||||
|
router.post('/erotic/report', socialNetworkController.reportEroticContent);
|
||||||
router.get('/images/:imageId', socialNetworkController.getImage);
|
router.get('/images/:imageId', socialNetworkController.getImage);
|
||||||
router.put('/images/:imageId', socialNetworkController.changeImage);
|
router.put('/images/:imageId', socialNetworkController.changeImage);
|
||||||
router.get('/imagevisibilities', socialNetworkController.getImageVisibilityTypes);
|
router.get('/imagevisibilities', socialNetworkController.getImageVisibilityTypes);
|
||||||
|
|||||||
@@ -24,12 +24,44 @@ import BranchType from "../models/falukant/type/branch.js";
|
|||||||
import RegionDistance from "../models/falukant/data/region_distance.js";
|
import RegionDistance from "../models/falukant/data/region_distance.js";
|
||||||
import Room from '../models/chat/room.js';
|
import Room from '../models/chat/room.js';
|
||||||
import UserParam from '../models/community/user_param.js';
|
import UserParam from '../models/community/user_param.js';
|
||||||
|
import Image from '../models/community/image.js';
|
||||||
|
import EroticVideo from '../models/community/erotic_video.js';
|
||||||
|
import EroticContentReport from '../models/community/erotic_content_report.js';
|
||||||
import TitleOfNobility from "../models/falukant/type/title_of_nobility.js";
|
import TitleOfNobility from "../models/falukant/type/title_of_nobility.js";
|
||||||
import { sequelize } from '../utils/sequelize.js';
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
import npcCreationJobService from './npcCreationJobService.js';
|
import npcCreationJobService from './npcCreationJobService.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
class AdminService {
|
class AdminService {
|
||||||
|
removeEroticStorageFile(type, hash) {
|
||||||
|
if (!hash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const storageFolder = type === 'image' ? 'erotic' : 'erotic-video';
|
||||||
|
const filePath = path.join(__dirname, '..', 'images', storageFolder, hash);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateAgeFromBirthdate(birthdate) {
|
||||||
|
if (!birthdate) return null;
|
||||||
|
const today = new Date();
|
||||||
|
const birthDateObj = new Date(birthdate);
|
||||||
|
let age = today.getFullYear() - birthDateObj.getFullYear();
|
||||||
|
const monthDiff = today.getMonth() - birthDateObj.getMonth();
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDateObj.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
async hasUserAccess(userId, section) {
|
async hasUserAccess(userId, section) {
|
||||||
const userRights = await UserRight.findAll({
|
const userRights = await UserRight.findAll({
|
||||||
include: [{
|
include: [{
|
||||||
@@ -232,6 +264,359 @@ class AdminService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAdultVerificationRequests(userId, status = 'pending') {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'useradministration'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await User.findAll({
|
||||||
|
attributes: ['id', 'hashedId', 'username', 'active'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: UserParam,
|
||||||
|
as: 'user_params',
|
||||||
|
required: false,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: { description: ['birthdate', 'adult_verification_status'] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['username', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = users.map((user) => {
|
||||||
|
const birthdateParam = user.user_params.find((param) => param.paramType?.description === 'birthdate');
|
||||||
|
const statusParam = user.user_params.find((param) => param.paramType?.description === 'adult_verification_status');
|
||||||
|
const requestParam = user.user_params.find((param) => param.paramType?.description === 'adult_verification_request');
|
||||||
|
const age = this.calculateAgeFromBirthdate(birthdateParam?.value);
|
||||||
|
const verificationStatus = ['pending', 'approved', 'rejected'].includes(statusParam?.value)
|
||||||
|
? statusParam.value
|
||||||
|
: 'none';
|
||||||
|
let verificationRequest = null;
|
||||||
|
try {
|
||||||
|
verificationRequest = requestParam?.value ? JSON.parse(requestParam.value) : null;
|
||||||
|
} catch {
|
||||||
|
verificationRequest = null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: user.hashedId,
|
||||||
|
username: user.username,
|
||||||
|
active: !!user.active,
|
||||||
|
age,
|
||||||
|
adultVerificationStatus: verificationStatus,
|
||||||
|
adultVerificationRequest: verificationRequest
|
||||||
|
};
|
||||||
|
}).filter((row) => row.age !== null && row.age >= 18);
|
||||||
|
|
||||||
|
if (status === 'all') {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.filter((row) => row.adultVerificationStatus === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAdultVerificationStatus(requesterId, targetHashedId, status) {
|
||||||
|
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
if (!['approved', 'rejected', 'pending'].includes(status)) {
|
||||||
|
throw new Error('wrongstatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: { hashedId: targetHashedId },
|
||||||
|
attributes: ['id']
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('notfound');
|
||||||
|
}
|
||||||
|
|
||||||
|
const birthdateParam = await UserParam.findOne({
|
||||||
|
where: { userId: user.id },
|
||||||
|
include: [{
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: { description: 'birthdate' }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
const age = this.calculateAgeFromBirthdate(birthdateParam?.value);
|
||||||
|
if (age === null || age < 18) {
|
||||||
|
throw new Error('notadult');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramType = await UserParamType.findOne({
|
||||||
|
where: { description: 'adult_verification_status' }
|
||||||
|
});
|
||||||
|
if (!paramType) {
|
||||||
|
throw new Error('missingparamtype');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await UserParam.findOne({
|
||||||
|
where: { userId: user.id, paramTypeId: paramType.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await existing.update({ value: status });
|
||||||
|
} else {
|
||||||
|
await UserParam.create({
|
||||||
|
userId: user.id,
|
||||||
|
paramTypeId: paramType.id,
|
||||||
|
value: status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdultVerificationDocument(requesterId, targetHashedId) {
|
||||||
|
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: { hashedId: targetHashedId },
|
||||||
|
attributes: ['id', 'username']
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('notfound');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestParam = await UserParam.findOne({
|
||||||
|
where: { userId: user.id },
|
||||||
|
include: [{
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: { description: 'adult_verification_request' }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
if (!requestParam?.value) {
|
||||||
|
throw new Error('norequest');
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestData;
|
||||||
|
try {
|
||||||
|
requestData = JSON.parse(requestParam.value);
|
||||||
|
} catch {
|
||||||
|
throw new Error('norequest');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, '..', 'images', 'adult-verification', requestData.fileName);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error('nofile');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
mimeType: requestData.mimeType || 'application/octet-stream',
|
||||||
|
originalName: requestData.originalName || `${user.username}-verification`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureUserParam(userId, description, value) {
|
||||||
|
const paramType = await UserParamType.findOne({
|
||||||
|
where: { description }
|
||||||
|
});
|
||||||
|
if (!paramType) {
|
||||||
|
throw new Error('missingparamtype');
|
||||||
|
}
|
||||||
|
const existing = await UserParam.findOne({
|
||||||
|
where: { userId, paramTypeId: paramType.id }
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await existing.update({ value });
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return await UserParam.create({
|
||||||
|
userId,
|
||||||
|
paramTypeId: paramType.id,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEroticModerationReports(userId, status = 'open') {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'useradministration'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = status === 'all' ? {} : { status };
|
||||||
|
const reports = await EroticContentReport.findAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'reporter',
|
||||||
|
attributes: ['id', 'hashedId', 'username']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'moderator',
|
||||||
|
attributes: ['id', 'hashedId', 'username'],
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const report of reports) {
|
||||||
|
let target = null;
|
||||||
|
if (report.targetType === 'image') {
|
||||||
|
target = await Image.findByPk(report.targetId, {
|
||||||
|
attributes: ['id', 'title', 'hash', 'userId', 'isModeratedHidden']
|
||||||
|
});
|
||||||
|
} else if (report.targetType === 'video') {
|
||||||
|
target = await EroticVideo.findByPk(report.targetId, {
|
||||||
|
attributes: ['id', 'title', 'hash', 'userId', 'isModeratedHidden']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const owner = target ? await User.findByPk(target.userId, {
|
||||||
|
attributes: ['hashedId', 'username']
|
||||||
|
}) : null;
|
||||||
|
rows.push({
|
||||||
|
id: report.id,
|
||||||
|
targetType: report.targetType,
|
||||||
|
targetId: report.targetId,
|
||||||
|
reason: report.reason,
|
||||||
|
note: report.note,
|
||||||
|
status: report.status,
|
||||||
|
actionTaken: report.actionTaken,
|
||||||
|
handledAt: report.handledAt,
|
||||||
|
createdAt: report.createdAt,
|
||||||
|
reporter: report.reporter,
|
||||||
|
moderator: report.moderator,
|
||||||
|
target: target ? {
|
||||||
|
id: target.id,
|
||||||
|
title: target.title,
|
||||||
|
hash: target.hash,
|
||||||
|
isModeratedHidden: !!target.isModeratedHidden
|
||||||
|
} : null,
|
||||||
|
owner
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyEroticModerationAction(requesterId, reportId, action, note = null) {
|
||||||
|
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
if (!['dismiss', 'hide_content', 'restore_content', 'delete_content', 'block_uploads', 'revoke_access'].includes(action)) {
|
||||||
|
throw new Error('wrongaction');
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderator = await User.findOne({
|
||||||
|
where: { hashedId: requesterId },
|
||||||
|
attributes: ['id']
|
||||||
|
});
|
||||||
|
const report = await EroticContentReport.findByPk(reportId);
|
||||||
|
if (!report) {
|
||||||
|
throw new Error('notfound');
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = null;
|
||||||
|
if (report.targetType === 'image') {
|
||||||
|
target = await Image.findByPk(report.targetId);
|
||||||
|
} else if (report.targetType === 'video') {
|
||||||
|
target = await EroticVideo.findByPk(report.targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'dismiss') {
|
||||||
|
await report.update({
|
||||||
|
status: 'dismissed',
|
||||||
|
actionTaken: 'dismiss',
|
||||||
|
handledBy: moderator?.id || null,
|
||||||
|
handledAt: new Date(),
|
||||||
|
note: note ?? report.note
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('targetnotfound');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'hide_content') {
|
||||||
|
await target.update({ isModeratedHidden: true });
|
||||||
|
} else if (action === 'restore_content') {
|
||||||
|
await target.update({ isModeratedHidden: false });
|
||||||
|
} else if (action === 'delete_content') {
|
||||||
|
this.removeEroticStorageFile(report.targetType, target.hash);
|
||||||
|
await target.destroy();
|
||||||
|
} else if (action === 'block_uploads') {
|
||||||
|
await this.ensureUserParam(target.userId, 'adult_upload_blocked', 'true');
|
||||||
|
} else if (action === 'revoke_access') {
|
||||||
|
await this.ensureUserParam(target.userId, 'adult_verification_status', 'rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
await report.update({
|
||||||
|
status: 'actioned',
|
||||||
|
actionTaken: action,
|
||||||
|
handledBy: moderator?.id || null,
|
||||||
|
handledAt: new Date(),
|
||||||
|
note: note ?? report.note
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEroticModerationPreview(requesterId, targetType, targetId) {
|
||||||
|
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === 'image') {
|
||||||
|
const target = await Image.findByPk(targetId, {
|
||||||
|
attributes: ['hash', 'originalFileName']
|
||||||
|
});
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('notfound');
|
||||||
|
}
|
||||||
|
const filePath = path.join(__dirname, '..', 'images', 'erotic', target.hash);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error('nofile');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
mimeType: this.getMimeTypeFromName(target.originalFileName) || 'image/jpeg',
|
||||||
|
originalName: target.originalFileName || target.hash
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === 'video') {
|
||||||
|
const target = await EroticVideo.findByPk(targetId, {
|
||||||
|
attributes: ['hash', 'mimeType', 'originalFileName']
|
||||||
|
});
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('notfound');
|
||||||
|
}
|
||||||
|
const filePath = path.join(__dirname, '..', 'images', 'erotic-video', target.hash);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error('nofile');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
mimeType: target.mimeType || this.getMimeTypeFromName(target.originalFileName) || 'application/octet-stream',
|
||||||
|
originalName: target.originalFileName || target.hash
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('wrongtype');
|
||||||
|
}
|
||||||
|
|
||||||
|
getMimeTypeFromName(fileName) {
|
||||||
|
const lower = String(fileName || '').toLowerCase();
|
||||||
|
if (lower.endsWith('.png')) return 'image/png';
|
||||||
|
if (lower.endsWith('.webp')) return 'image/webp';
|
||||||
|
if (lower.endsWith('.gif')) return 'image/gif';
|
||||||
|
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async getFalukantUserById(userId, hashedId) {
|
async getFalukantUserById(userId, hashedId) {
|
||||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
throw new Error('noaccess');
|
throw new Error('noaccess');
|
||||||
@@ -682,6 +1067,7 @@ class AdminService {
|
|||||||
'title',
|
'title',
|
||||||
'roomTypeId',
|
'roomTypeId',
|
||||||
'isPublic',
|
'isPublic',
|
||||||
|
'isAdultOnly',
|
||||||
'genderRestrictionId',
|
'genderRestrictionId',
|
||||||
'minAge',
|
'minAge',
|
||||||
'maxAge',
|
'maxAge',
|
||||||
@@ -1312,4 +1698,4 @@ class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AdminService();
|
export default new AdminService();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import amqp from 'amqplib/callback_api.js';
|
import amqp from 'amqplib/callback_api.js';
|
||||||
import User from '../models/community/user.js';
|
import User from '../models/community/user.js';
|
||||||
import Room from '../models/chat/room.js';
|
import Room from '../models/chat/room.js';
|
||||||
|
import UserParam from '../models/community/user_param.js';
|
||||||
|
import UserParamType from '../models/type/user_param.js';
|
||||||
|
|
||||||
const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
|
const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
|
||||||
const QUEUE = 'oneToOne_messages';
|
const QUEUE = 'oneToOne_messages';
|
||||||
@@ -166,17 +168,69 @@ class ChatService {
|
|||||||
(chat.user1Id === user2HashId && chat.user2Id === user1HashId)
|
(chat.user1Id === user2HashId && chat.user2Id === user1HashId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
calculateAge(birthdate) {
|
||||||
|
const birthDate = new Date(birthdate);
|
||||||
|
const ageDifMs = Date.now() - birthDate.getTime();
|
||||||
|
const ageDate = new Date(ageDifMs);
|
||||||
|
return Math.abs(ageDate.getUTCFullYear() - 1970);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeAdultVerificationStatus(value) {
|
||||||
|
if (!value) return 'none';
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
return ['pending', 'approved', 'rejected'].includes(normalized) ? normalized : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdultAccessState(hashedUserId) {
|
||||||
|
if (!hashedUserId) {
|
||||||
|
return { isAdult: false, adultVerificationStatus: 'none', adultAccessEnabled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ where: { hashedId: hashedUserId }, attributes: ['id'] });
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('user_not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = await UserParam.findAll({
|
||||||
|
where: { userId: user.id },
|
||||||
|
include: [{
|
||||||
|
model: UserParamType,
|
||||||
|
as: 'paramType',
|
||||||
|
where: { description: ['birthdate', 'adult_verification_status'] }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const birthdateParam = params.find(param => param.paramType?.description === 'birthdate');
|
||||||
|
const statusParam = params.find(param => param.paramType?.description === 'adult_verification_status');
|
||||||
|
const age = birthdateParam ? this.calculateAge(birthdateParam.value) : 0;
|
||||||
|
const adultVerificationStatus = this.normalizeAdultVerificationStatus(statusParam?.value);
|
||||||
|
return {
|
||||||
|
isAdult: age >= 18,
|
||||||
|
adultVerificationStatus,
|
||||||
|
adultAccessEnabled: age >= 18 && adultVerificationStatus === 'approved'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getRoomList() {
|
async getRoomList(hashedUserId, { adultOnly = false } = {}) {
|
||||||
// Nur öffentliche Räume, keine sensiblen Felder
|
// Nur öffentliche Räume, keine sensiblen Felder
|
||||||
const { default: Room } = await import('../models/chat/room.js');
|
const { default: Room } = await import('../models/chat/room.js');
|
||||||
const { default: RoomType } = await import('../models/chat/room_type.js');
|
const { default: RoomType } = await import('../models/chat/room_type.js');
|
||||||
|
const where = { isPublic: true, isAdultOnly: Boolean(adultOnly) };
|
||||||
|
|
||||||
|
if (adultOnly) {
|
||||||
|
const adultAccess = await this.getAdultAccessState(hashedUserId);
|
||||||
|
if (!adultAccess.adultAccessEnabled) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Room.findAll({
|
return Room.findAll({
|
||||||
attributes: [
|
attributes: [
|
||||||
'id', 'title', 'roomTypeId', 'isPublic', 'genderRestrictionId',
|
'id', 'title', 'roomTypeId', 'isPublic', 'isAdultOnly', 'genderRestrictionId',
|
||||||
'minAge', 'maxAge', 'friendsOfOwnerOnly', 'requiredUserRightId'
|
'minAge', 'maxAge', 'friendsOfOwnerOnly', 'requiredUserRightId'
|
||||||
],
|
],
|
||||||
where: { isPublic: true },
|
where,
|
||||||
include: [
|
include: [
|
||||||
{ model: RoomType, as: 'roomType' }
|
{ model: RoomType, as: 'roomType' }
|
||||||
]
|
]
|
||||||
@@ -215,7 +269,7 @@ class ChatService {
|
|||||||
|
|
||||||
return Room.findAll({
|
return Room.findAll({
|
||||||
where: { ownerId: user.id },
|
where: { ownerId: user.id },
|
||||||
attributes: ['id', 'title', 'isPublic', 'roomTypeId', 'ownerId'],
|
attributes: ['id', 'title', 'isPublic', 'isAdultOnly', 'roomTypeId', 'ownerId'],
|
||||||
order: [['title', 'ASC']]
|
order: [['title', 'ASC']]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ import ProductWeatherEffect from '../models/falukant/type/product_weather_effect
|
|||||||
import WeatherType from '../models/falukant/type/weather.js';
|
import WeatherType from '../models/falukant/type/weather.js';
|
||||||
import ReputationActionType from '../models/falukant/type/reputation_action.js';
|
import ReputationActionType from '../models/falukant/type/reputation_action.js';
|
||||||
import ReputationActionLog from '../models/falukant/log/reputation_action.js';
|
import ReputationActionLog from '../models/falukant/log/reputation_action.js';
|
||||||
|
import {
|
||||||
|
productionPieceCost,
|
||||||
|
productionCostTotal,
|
||||||
|
effectiveWorthPercent,
|
||||||
|
KNOWLEDGE_PRICE_FLOOR,
|
||||||
|
calcRegionalSellPriceSync,
|
||||||
|
} from '../utils/falukant/falukantProductEconomy.js';
|
||||||
|
|
||||||
function calcAge(birthdate) {
|
function calcAge(birthdate) {
|
||||||
const b = new Date(birthdate); b.setHours(0, 0);
|
const b = new Date(birthdate); b.setHours(0, 0);
|
||||||
@@ -97,54 +104,6 @@ async function getBranchOrFail(userId, branchId) {
|
|||||||
return branch;
|
return branch;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRODUCTION_COST_BASE = 6.0;
|
|
||||||
const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0;
|
|
||||||
const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035;
|
|
||||||
const PRODUCTION_HEADROOM_DISCOUNT_CAP = 0.14;
|
|
||||||
|
|
||||||
function productionPieceCost(certificate, category) {
|
|
||||||
const c = Math.max(1, Number(category) || 1);
|
|
||||||
const cert = Math.max(1, Number(certificate) || 1);
|
|
||||||
const raw = PRODUCTION_COST_BASE + (c * PRODUCTION_COST_PER_PRODUCT_CATEGORY);
|
|
||||||
const headroom = Math.max(0, cert - c);
|
|
||||||
const discount = Math.min(
|
|
||||||
headroom * PRODUCTION_HEADROOM_DISCOUNT_PER_STEP,
|
|
||||||
PRODUCTION_HEADROOM_DISCOUNT_CAP
|
|
||||||
);
|
|
||||||
return raw * (1 - discount);
|
|
||||||
}
|
|
||||||
|
|
||||||
function productionCostTotal(quantity, category, certificate) {
|
|
||||||
const q = Math.min(100, Math.max(1, Number(quantity) || 1));
|
|
||||||
return q * productionPieceCost(certificate, category);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Regionaler Nachfragewert: sehr niedrige Werte aus der DB sonst unspielbar; Decke nach oben bei 100. */
|
|
||||||
function effectiveWorthPercent(worthPercent) {
|
|
||||||
const w = Number(worthPercent);
|
|
||||||
if (Number.isNaN(w)) return 75;
|
|
||||||
return Math.min(100, Math.max(75, w));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Untergrenze für den Wissens-Multiplikator auf den Basispreis (0 = minAnteil, 100 = voller Basispreis). */
|
|
||||||
const KNOWLEDGE_PRICE_FLOOR = 0.7;
|
|
||||||
|
|
||||||
function calcSellPrice(product, knowledgeFactor = 0) {
|
|
||||||
const max = product.sellCost;
|
|
||||||
const min = max * KNOWLEDGE_PRICE_FLOOR;
|
|
||||||
return min + (max - min) * (knowledgeFactor / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Synchrone Preisberechnung, wenn worthPercent bereits bekannt ist (kein DB-Zugriff). */
|
|
||||||
function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent) {
|
|
||||||
if (product.sellCost === null || product.sellCost === undefined) return null;
|
|
||||||
const w = effectiveWorthPercent(worthPercent);
|
|
||||||
const basePrice = product.sellCost * (w / 100);
|
|
||||||
const min = basePrice * KNOWLEDGE_PRICE_FLOOR;
|
|
||||||
const max = basePrice;
|
|
||||||
return min + (max - min) * (knowledgeFactor / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
const POLITICAL_OFFICE_RANKS = {
|
const POLITICAL_OFFICE_RANKS = {
|
||||||
assessor: 1,
|
assessor: 1,
|
||||||
councillor: 1,
|
councillor: 1,
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import UserParamVisibilityType from '../models/type/user_param_visibility.js';
|
|||||||
import UserParamVisibility from '../models/community/user_param_visibility.js';
|
import UserParamVisibility from '../models/community/user_param_visibility.js';
|
||||||
import { encrypt } from '../utils/encryption.js';
|
import { encrypt } from '../utils/encryption.js';
|
||||||
import { sequelize } from '../utils/sequelize.js';
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
|
import fsPromises from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
/** Wie UserParam.value-Setter: bei Verschlüsselungsfehler leeren String speichern, nicht crashen. */
|
/** Wie UserParam.value-Setter: bei Verschlüsselungsfehler leeren String speichern, nicht crashen. */
|
||||||
function encryptUserParamValue(plain) {
|
function encryptUserParamValue(plain) {
|
||||||
@@ -24,6 +31,90 @@ function encryptUserParamValue(plain) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SettingsService extends BaseService{
|
class SettingsService extends BaseService{
|
||||||
|
parseAdultVerificationRequest(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeAdultVerificationStatus(value) {
|
||||||
|
if (['pending', 'approved', 'rejected'].includes(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdultAccessStateByUserId(userId) {
|
||||||
|
const userParams = await this.getUserParams(userId, ['birthdate', 'adult_verification_status', 'adult_verification_request']);
|
||||||
|
let birthdate = null;
|
||||||
|
let adultVerificationStatus = 'none';
|
||||||
|
let adultVerificationRequest = null;
|
||||||
|
for (const param of userParams) {
|
||||||
|
if (param.paramType.description === 'birthdate') {
|
||||||
|
birthdate = param.value;
|
||||||
|
}
|
||||||
|
if (param.paramType.description === 'adult_verification_status') {
|
||||||
|
adultVerificationStatus = this.normalizeAdultVerificationStatus(param.value);
|
||||||
|
}
|
||||||
|
if (param.paramType.description === 'adult_verification_request') {
|
||||||
|
adultVerificationRequest = this.parseAdultVerificationRequest(param.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const age = birthdate ? this.calculateAge(birthdate) : null;
|
||||||
|
const isAdult = age !== null && age >= 18;
|
||||||
|
return {
|
||||||
|
age,
|
||||||
|
isAdult,
|
||||||
|
adultVerificationStatus: isAdult ? adultVerificationStatus : 'none',
|
||||||
|
adultVerificationRequest: isAdult ? adultVerificationRequest : null,
|
||||||
|
adultAccessEnabled: isAdult && adultVerificationStatus === 'approved'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildAdultVerificationFilePath(fileName) {
|
||||||
|
return path.join(__dirname, '..', 'images', 'adult-verification', fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAdultVerificationDocument(file) {
|
||||||
|
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||||
|
if (!file || !file.buffer) {
|
||||||
|
throw new Error('No verification document provided');
|
||||||
|
}
|
||||||
|
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||||
|
throw new Error('Unsupported verification document type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(file.originalname || '').toLowerCase();
|
||||||
|
const safeExt = ext && ext.length <= 8 ? ext : (file.mimetype === 'application/pdf' ? '.pdf' : '.bin');
|
||||||
|
const fileName = `${uuidv4()}${safeExt}`;
|
||||||
|
const filePath = this.buildAdultVerificationFilePath(fileName);
|
||||||
|
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fsPromises.writeFile(filePath, file.buffer);
|
||||||
|
return { fileName, filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertUserParam(userId, description, value) {
|
||||||
|
const paramType = await UserParamType.findOne({ where: { description } });
|
||||||
|
if (!paramType) {
|
||||||
|
throw new Error(`Missing user param type: ${description}`);
|
||||||
|
}
|
||||||
|
const existingParam = await UserParam.findOne({
|
||||||
|
where: { userId, paramTypeId: paramType.id }
|
||||||
|
});
|
||||||
|
if (existingParam) {
|
||||||
|
await existingParam.update({ value });
|
||||||
|
return existingParam;
|
||||||
|
}
|
||||||
|
return UserParam.create({
|
||||||
|
userId,
|
||||||
|
paramTypeId: paramType.id,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getUserParams(userId, paramDescriptions) {
|
async getUserParams(userId, paramDescriptions) {
|
||||||
return await UserParam.findAll({
|
return await UserParam.findAll({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -299,10 +390,13 @@ class SettingsService extends BaseService{
|
|||||||
email = null;
|
email = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adultAccess = await this.getAdultAccessStateByUserId(user.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: email,
|
email: email,
|
||||||
showinsearch: user.searchable
|
showinsearch: user.searchable,
|
||||||
|
...adultAccess
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting account settings:', error);
|
console.error('Error getting account settings:', error);
|
||||||
@@ -317,6 +411,8 @@ class SettingsService extends BaseService{
|
|||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adultAccess = await this.getAdultAccessStateByUserId(user.id);
|
||||||
|
|
||||||
// Update username if provided
|
// Update username if provided
|
||||||
if (settings.username !== undefined) {
|
if (settings.username !== undefined) {
|
||||||
await user.update({ username: settings.username });
|
await user.update({ username: settings.username });
|
||||||
@@ -332,6 +428,17 @@ class SettingsService extends BaseService{
|
|||||||
await user.update({ searchable: settings.showinsearch });
|
await user.update({ searchable: settings.showinsearch });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.requestAdultVerification) {
|
||||||
|
if (!adultAccess.isAdult) {
|
||||||
|
throw new Error('Adult verification can only be requested by adult users');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = adultAccess.adultVerificationStatus === 'approved'
|
||||||
|
? 'approved'
|
||||||
|
: 'pending';
|
||||||
|
await this.upsertUserParam(user.id, 'adult_verification_status', normalizedValue);
|
||||||
|
}
|
||||||
|
|
||||||
// Update password if provided and not empty
|
// Update password if provided and not empty
|
||||||
if (settings.newpassword && settings.newpassword.trim() !== '') {
|
if (settings.newpassword && settings.newpassword.trim() !== '') {
|
||||||
if (!settings.oldpassword || settings.oldpassword.trim() === '') {
|
if (!settings.oldpassword || settings.oldpassword.trim() === '') {
|
||||||
@@ -357,6 +464,34 @@ class SettingsService extends BaseService{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async submitAdultVerificationRequest(hashedUserId, { note }, file) {
|
||||||
|
const user = await this.getUserByHashedId(hashedUserId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
const adultAccess = await this.getAdultAccessStateByUserId(user.id);
|
||||||
|
if (!adultAccess.isAdult) {
|
||||||
|
throw new Error('Adult verification can only be requested by adult users');
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
throw new Error('No verification document provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedFile = await this.saveAdultVerificationDocument(file);
|
||||||
|
const requestPayload = {
|
||||||
|
fileName: savedFile.fileName,
|
||||||
|
originalName: file.originalname,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
note: note || '',
|
||||||
|
submittedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.upsertUserParam(user.id, 'adult_verification_request', JSON.stringify(requestPayload));
|
||||||
|
await this.upsertUserParam(user.id, 'adult_verification_status', adultAccess.adultVerificationStatus === 'approved' ? 'approved' : 'pending');
|
||||||
|
|
||||||
|
return requestPayload;
|
||||||
|
}
|
||||||
|
|
||||||
async getVisibilities() {
|
async getVisibilities() {
|
||||||
return UserParamVisibilityType.findAll();
|
return UserParamVisibilityType.findAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import UserParamVisibility from '../models/community/user_param_visibility.js';
|
|||||||
import UserParamVisibilityType from '../models/type/user_param_visibility.js';
|
import UserParamVisibilityType from '../models/type/user_param_visibility.js';
|
||||||
import Folder from '../models/community/folder.js';
|
import Folder from '../models/community/folder.js';
|
||||||
import Image from '../models/community/image.js';
|
import Image from '../models/community/image.js';
|
||||||
|
import EroticVideo from '../models/community/erotic_video.js';
|
||||||
|
import EroticContentReport from '../models/community/erotic_content_report.js';
|
||||||
import ImageVisibilityType from '../models/type/image_visibility.js';
|
import ImageVisibilityType from '../models/type/image_visibility.js';
|
||||||
import FolderImageVisibility from '../models/community/folder_image_visibility.js';
|
import FolderImageVisibility from '../models/community/folder_image_visibility.js';
|
||||||
import ImageImageVisibility from '../models/community/image_image_visibility.js';
|
import ImageImageVisibility from '../models/community/image_image_visibility.js';
|
||||||
@@ -30,6 +32,150 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
class SocialNetworkService extends BaseService {
|
class SocialNetworkService extends BaseService {
|
||||||
|
normalizeAdultVerificationStatus(value) {
|
||||||
|
if (!value) return 'none';
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
return ['pending', 'approved', 'rejected'].includes(normalized) ? normalized : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdultAccessState(userId) {
|
||||||
|
const params = await this.getUserParams(userId, ['birthdate', 'adult_verification_status', 'adult_upload_blocked']);
|
||||||
|
const birthdateParam = params.find(param => param.paramType.description === 'birthdate');
|
||||||
|
const statusParam = params.find(param => param.paramType.description === 'adult_verification_status');
|
||||||
|
const uploadBlockedParam = params.find(param => param.paramType.description === 'adult_upload_blocked');
|
||||||
|
const age = birthdateParam ? this.calculateAge(birthdateParam.value) : 0;
|
||||||
|
const adultVerificationStatus = this.normalizeAdultVerificationStatus(statusParam?.value);
|
||||||
|
return {
|
||||||
|
age,
|
||||||
|
isAdult: age >= 18,
|
||||||
|
adultVerificationStatus,
|
||||||
|
adultAccessEnabled: age >= 18 && adultVerificationStatus === 'approved',
|
||||||
|
adultUploadBlocked: String(uploadBlockedParam?.value).toLowerCase() === 'true'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async requireAdultAreaAccessByHash(hashedId) {
|
||||||
|
const userId = await this.checkUserAccess(hashedId);
|
||||||
|
const adultAccess = await this.getAdultAccessState(userId);
|
||||||
|
if (!adultAccess.adultAccessEnabled) {
|
||||||
|
const error = new Error('Adult area access denied');
|
||||||
|
error.status = 403;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureAdultUploadsAllowed(userId) {
|
||||||
|
const adultAccess = await this.getAdultAccessState(userId);
|
||||||
|
if (adultAccess.adultUploadBlocked) {
|
||||||
|
const error = new Error('Adult uploads are blocked');
|
||||||
|
error.status = 403;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveEroticTarget(targetType, targetId) {
|
||||||
|
if (targetType === 'image') {
|
||||||
|
const image = await Image.findOne({
|
||||||
|
where: {
|
||||||
|
id: targetId,
|
||||||
|
isAdultContent: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!image) {
|
||||||
|
throw new Error('Target not found');
|
||||||
|
}
|
||||||
|
return { targetType, target: image, ownerId: image.userId };
|
||||||
|
}
|
||||||
|
if (targetType === 'video') {
|
||||||
|
const video = await EroticVideo.findByPk(targetId);
|
||||||
|
if (!video) {
|
||||||
|
throw new Error('Target not found');
|
||||||
|
}
|
||||||
|
return { targetType, target: video, ownerId: video.userId };
|
||||||
|
}
|
||||||
|
throw new Error('Unsupported target type');
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureRootFolder(userId) {
|
||||||
|
let rootFolder = await Folder.findOne({
|
||||||
|
where: { parentId: null, userId, isAdultArea: false },
|
||||||
|
include: [{
|
||||||
|
model: ImageVisibilityType,
|
||||||
|
through: { model: FolderImageVisibility },
|
||||||
|
attributes: ['id'],
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
if (rootFolder) {
|
||||||
|
return rootFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ where: { id: userId } });
|
||||||
|
const visibility = await ImageVisibilityType.findOne({ where: { description: 'everyone' } });
|
||||||
|
rootFolder = await Folder.create({
|
||||||
|
name: user.username,
|
||||||
|
parentId: null,
|
||||||
|
userId,
|
||||||
|
isAdultArea: false
|
||||||
|
});
|
||||||
|
if (visibility) {
|
||||||
|
await FolderImageVisibility.create({
|
||||||
|
folderId: rootFolder.id,
|
||||||
|
visibilityTypeId: visibility.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await Folder.findOne({
|
||||||
|
where: { id: rootFolder.id },
|
||||||
|
include: [{
|
||||||
|
model: ImageVisibilityType,
|
||||||
|
through: { model: FolderImageVisibility },
|
||||||
|
attributes: ['id'],
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureAdultRootFolder(userId) {
|
||||||
|
const rootFolder = await this.ensureRootFolder(userId);
|
||||||
|
let adultRoot = await Folder.findOne({
|
||||||
|
where: {
|
||||||
|
parentId: rootFolder.id,
|
||||||
|
userId,
|
||||||
|
isAdultArea: true,
|
||||||
|
name: 'Erotik'
|
||||||
|
},
|
||||||
|
include: [{
|
||||||
|
model: ImageVisibilityType,
|
||||||
|
through: { model: FolderImageVisibility },
|
||||||
|
attributes: ['id'],
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
if (adultRoot) {
|
||||||
|
return adultRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adultsVisibility = await ImageVisibilityType.findOne({ where: { description: 'adults' } });
|
||||||
|
adultRoot = await Folder.create({
|
||||||
|
name: 'Erotik',
|
||||||
|
parentId: rootFolder.id,
|
||||||
|
userId,
|
||||||
|
isAdultArea: true
|
||||||
|
});
|
||||||
|
if (adultsVisibility) {
|
||||||
|
await FolderImageVisibility.create({
|
||||||
|
folderId: adultRoot.id,
|
||||||
|
visibilityTypeId: adultsVisibility.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await Folder.findOne({
|
||||||
|
where: { id: adultRoot.id },
|
||||||
|
include: [{
|
||||||
|
model: ImageVisibilityType,
|
||||||
|
through: { model: FolderImageVisibility },
|
||||||
|
attributes: ['id'],
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async searchUsers({ hashedUserId, username, ageFrom, ageTo, genders }) {
|
async searchUsers({ hashedUserId, username, ageFrom, ageTo, genders }) {
|
||||||
const whereClause = this.buildSearchWhereClause(username);
|
const whereClause = this.buildSearchWhereClause(username);
|
||||||
const user = await this.loadUserByHash(hashedUserId);
|
const user = await this.loadUserByHash(hashedUserId);
|
||||||
@@ -49,15 +195,19 @@ class SocialNetworkService extends BaseService {
|
|||||||
return this.constructUserProfile(user, requestingUserId);
|
return this.constructUserProfile(user, requestingUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFolder(hashedUserId, data, folderId) {
|
async createFolder(hashedUserId, data, folderId, options = {}) {
|
||||||
await this.checkUserAccess(hashedUserId);
|
await this.checkUserAccess(hashedUserId);
|
||||||
const user = await this.loadUserByHash(hashedUserId);
|
const user = await this.loadUserByHash(hashedUserId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
}
|
}
|
||||||
|
const isAdultArea = Boolean(options.isAdultArea);
|
||||||
|
if (isAdultArea) {
|
||||||
|
await this.requireAdultAreaAccessByHash(hashedUserId);
|
||||||
|
}
|
||||||
console.log('given data', data, folderId);
|
console.log('given data', data, folderId);
|
||||||
const parentFolder = data.parentId ? await Folder.findOne({
|
const parentFolder = data.parentId ? await Folder.findOne({
|
||||||
where: { id: data.parentId, userId: user.id }
|
where: { id: data.parentId, userId: user.id, isAdultArea }
|
||||||
}) : null;
|
}) : null;
|
||||||
if (data.parentId && !parentFolder) {
|
if (data.parentId && !parentFolder) {
|
||||||
throw new Error('Parent folder not found');
|
throw new Error('Parent folder not found');
|
||||||
@@ -68,11 +218,12 @@ class SocialNetworkService extends BaseService {
|
|||||||
newFolder = await Folder.create({
|
newFolder = await Folder.create({
|
||||||
parentId: parentFolder.id || null,
|
parentId: parentFolder.id || null,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
name: data.name
|
name: data.name,
|
||||||
|
isAdultArea
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
newFolder = await Folder.findOne({
|
newFolder = await Folder.findOne({
|
||||||
where: { id: folderId, userId: user.id }
|
where: { id: folderId, userId: user.id, isAdultArea }
|
||||||
});
|
});
|
||||||
if (!newFolder) {
|
if (!newFolder) {
|
||||||
throw new Error('Folder not found or user does not own the folder');
|
throw new Error('Folder not found or user does not own the folder');
|
||||||
@@ -94,38 +245,8 @@ class SocialNetworkService extends BaseService {
|
|||||||
|
|
||||||
async getFolders(hashedId) {
|
async getFolders(hashedId) {
|
||||||
const userId = await this.checkUserAccess(hashedId);
|
const userId = await this.checkUserAccess(hashedId);
|
||||||
let rootFolder = await Folder.findOne({
|
const rootFolder = await this.ensureRootFolder(userId);
|
||||||
where: { parentId: null, userId },
|
const children = await this.getSubFolders(rootFolder.id, userId, false);
|
||||||
include: [{
|
|
||||||
model: ImageVisibilityType,
|
|
||||||
through: { model: FolderImageVisibility },
|
|
||||||
attributes: ['id'],
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
if (!rootFolder) {
|
|
||||||
const user = await User.findOne({ where: { id: userId } });
|
|
||||||
const visibility = await ImageVisibilityType.findOne({
|
|
||||||
where: { description: 'everyone' }
|
|
||||||
});
|
|
||||||
rootFolder = await Folder.create({
|
|
||||||
name: user.username,
|
|
||||||
parentId: null,
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
await FolderImageVisibility.create({
|
|
||||||
folderId: rootFolder.id,
|
|
||||||
visibilityTypeId: visibility.id
|
|
||||||
});
|
|
||||||
rootFolder = await Folder.findOne({
|
|
||||||
where: { id: rootFolder.id },
|
|
||||||
include: [{
|
|
||||||
model: ImageVisibilityType,
|
|
||||||
through: { model: FolderImageVisibility },
|
|
||||||
attributes: ['id'],
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const children = await this.getSubFolders(rootFolder.id, userId);
|
|
||||||
rootFolder = rootFolder.get();
|
rootFolder = rootFolder.get();
|
||||||
rootFolder.visibilityTypeIds = rootFolder.image_visibility_types.map(v => v.id);
|
rootFolder.visibilityTypeIds = rootFolder.image_visibility_types.map(v => v.id);
|
||||||
delete rootFolder.image_visibility_types;
|
delete rootFolder.image_visibility_types;
|
||||||
@@ -133,9 +254,9 @@ class SocialNetworkService extends BaseService {
|
|||||||
return rootFolder;
|
return rootFolder;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSubFolders(parentId, userId) {
|
async getSubFolders(parentId, userId, isAdultArea = false) {
|
||||||
const folders = await Folder.findAll({
|
const folders = await Folder.findAll({
|
||||||
where: { parentId, userId },
|
where: { parentId, userId, isAdultArea },
|
||||||
include: [{
|
include: [{
|
||||||
model: ImageVisibilityType,
|
model: ImageVisibilityType,
|
||||||
through: { model: FolderImageVisibility },
|
through: { model: FolderImageVisibility },
|
||||||
@@ -146,7 +267,7 @@ class SocialNetworkService extends BaseService {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
const children = await this.getSubFolders(folder.id, userId);
|
const children = await this.getSubFolders(folder.id, userId, isAdultArea);
|
||||||
const visibilityTypeIds = folder.image_visibility_types.map(v => v.id);
|
const visibilityTypeIds = folder.image_visibility_types.map(v => v.id);
|
||||||
folder.setDataValue('visibilityTypeIds', visibilityTypeIds);
|
folder.setDataValue('visibilityTypeIds', visibilityTypeIds);
|
||||||
folder.setDataValue('children', children);
|
folder.setDataValue('children', children);
|
||||||
@@ -160,13 +281,15 @@ class SocialNetworkService extends BaseService {
|
|||||||
const folder = await Folder.findOne({
|
const folder = await Folder.findOne({
|
||||||
where: {
|
where: {
|
||||||
id: folderId,
|
id: folderId,
|
||||||
userId
|
userId,
|
||||||
|
isAdultArea: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!folder) throw new Error('Folder not found');
|
if (!folder) throw new Error('Folder not found');
|
||||||
return await Image.findAll({
|
return await Image.findAll({
|
||||||
where: {
|
where: {
|
||||||
folderId: folder.id
|
folderId: folder.id,
|
||||||
|
isAdultContent: false
|
||||||
},
|
},
|
||||||
order: [
|
order: [
|
||||||
['title', 'asc']
|
['title', 'asc']
|
||||||
@@ -176,13 +299,13 @@ class SocialNetworkService extends BaseService {
|
|||||||
|
|
||||||
async uploadImage(hashedId, file, formData) {
|
async uploadImage(hashedId, file, formData) {
|
||||||
const userId = await this.getUserId(hashedId);
|
const userId = await this.getUserId(hashedId);
|
||||||
const processedImageName = await this.processAndUploadUserImage(file);
|
const processedImageName = await this.processAndUploadUserImage(file, 'user');
|
||||||
const newImage = await this.createImageRecord(formData, userId, file, processedImageName);
|
const newImage = await this.createImageRecord(formData, userId, file, processedImageName, { isAdultContent: false });
|
||||||
await this.saveImageVisibilities(newImage.id, formData.visibility);
|
await this.saveImageVisibilities(newImage.id, formData.visibility);
|
||||||
return newImage;
|
return newImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
async processAndUploadUserImage(file) {
|
async processAndUploadUserImage(file, storageType = 'user') {
|
||||||
try {
|
try {
|
||||||
const img = sharp(file.buffer);
|
const img = sharp(file.buffer);
|
||||||
const metadata = await img.metadata();
|
const metadata = await img.metadata();
|
||||||
@@ -199,7 +322,7 @@ class SocialNetworkService extends BaseService {
|
|||||||
withoutEnlargement: true
|
withoutEnlargement: true
|
||||||
});
|
});
|
||||||
const newFileName = this.generateUniqueFileName(file.originalname);
|
const newFileName = this.generateUniqueFileName(file.originalname);
|
||||||
const filePath = this.buildFilePath(newFileName, 'user');
|
const filePath = this.buildFilePath(newFileName, storageType);
|
||||||
await resizedImg.toFile(filePath);
|
await resizedImg.toFile(filePath);
|
||||||
return newFileName;
|
return newFileName;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -231,7 +354,7 @@ class SocialNetworkService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createImageRecord(formData, userId, file, fileName) {
|
async createImageRecord(formData, userId, file, fileName, options = {}) {
|
||||||
try {
|
try {
|
||||||
return await Image.create({
|
return await Image.create({
|
||||||
title: formData.title,
|
title: formData.title,
|
||||||
@@ -240,6 +363,7 @@ class SocialNetworkService extends BaseService {
|
|||||||
hash: fileName,
|
hash: fileName,
|
||||||
folderId: formData.folderId,
|
folderId: formData.folderId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
isAdultContent: Boolean(options.isAdultContent),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to create image record: ${error.message}`);
|
throw new Error(`Failed to create image record: ${error.message}`);
|
||||||
@@ -271,6 +395,7 @@ class SocialNetworkService extends BaseService {
|
|||||||
async getImage(imageId) {
|
async getImage(imageId) {
|
||||||
const image = await Image.findByPk(imageId);
|
const image = await Image.findByPk(imageId);
|
||||||
if (!image) throw new Error('Image not found');
|
if (!image) throw new Error('Image not found');
|
||||||
|
if (image.isAdultContent) throw new Error('Access denied');
|
||||||
await this.checkUserAccess(image.userId);
|
await this.checkUserAccess(image.userId);
|
||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
@@ -455,6 +580,9 @@ class SocialNetworkService extends BaseService {
|
|||||||
if (!image) {
|
if (!image) {
|
||||||
throw new Error('Image not found');
|
throw new Error('Image not found');
|
||||||
}
|
}
|
||||||
|
if (image.isAdultContent) {
|
||||||
|
throw new Error('Access denied');
|
||||||
|
}
|
||||||
const userId = await this.checkUserAccess(hashedUserId);
|
const userId = await this.checkUserAccess(hashedUserId);
|
||||||
const hasAccess = await this.checkUserImageAccess(userId, image.id);
|
const hasAccess = await this.checkUserImageAccess(userId, image.id);
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
@@ -467,6 +595,178 @@ class SocialNetworkService extends BaseService {
|
|||||||
return imagePath;
|
return imagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAdultFolders(hashedId) {
|
||||||
|
const userId = await this.requireAdultAreaAccessByHash(hashedId);
|
||||||
|
const rootFolder = await this.ensureAdultRootFolder(userId);
|
||||||
|
const children = await this.getSubFolders(rootFolder.id, userId, true);
|
||||||
|
const data = rootFolder.get();
|
||||||
|
data.visibilityTypeIds = data.image_visibility_types.map(v => v.id);
|
||||||
|
delete data.image_visibility_types;
|
||||||
|
data.children = children;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdultFolderImageList(hashedId, folderId) {
|
||||||
|
const userId = await this.requireAdultAreaAccessByHash(hashedId);
|
||||||
|
const folder = await Folder.findOne({
|
||||||
|
where: { id: folderId, userId, isAdultArea: true }
|
||||||
|
});
|
||||||
|
if (!folder) {
|
||||||
|
throw new Error('Folder not found');
|
||||||
|
}
|
||||||
|
return await Image.findAll({
|
||||||
|
where: {
|
||||||
|
folderId: folder.id,
|
||||||
|
isAdultContent: true,
|
||||||
|
userId
|
||||||
|
},
|
||||||
|
order: [['title', 'asc']]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAdultFolder(hashedId, data, folderId) {
|
||||||
|
await this.requireAdultAreaAccessByHash(hashedId);
|
||||||
|
if (!data.parentId) {
|
||||||
|
const userId = await this.checkUserAccess(hashedId);
|
||||||
|
const adultRoot = await this.ensureAdultRootFolder(userId);
|
||||||
|
data.parentId = adultRoot.id;
|
||||||
|
}
|
||||||
|
return this.createFolder(hashedId, data, folderId, { isAdultArea: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadAdultImage(hashedId, file, formData) {
|
||||||
|
const userId = await this.requireAdultAreaAccessByHash(hashedId);
|
||||||
|
await this.ensureAdultUploadsAllowed(userId);
|
||||||
|
const folder = await Folder.findOne({
|
||||||
|
where: {
|
||||||
|
id: formData.folderId,
|
||||||
|
userId,
|
||||||
|
isAdultArea: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!folder) {
|
||||||
|
throw new Error('Folder not found');
|
||||||
|
}
|
||||||
|
const processedImageName = await this.processAndUploadUserImage(file, 'erotic');
|
||||||
|
const newImage = await this.createImageRecord(formData, userId, file, processedImageName, { isAdultContent: true });
|
||||||
|
await this.saveImageVisibilities(newImage.id, formData.visibility);
|
||||||
|
return newImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdultImageFilePath(hashedId, hash) {
|
||||||
|
const userId = await this.requireAdultAreaAccessByHash(hashedId);
|
||||||
|
const image = await Image.findOne({
|
||||||
|
where: {
|
||||||
|
hash,
|
||||||
|
userId,
|
||||||
|
isAdultContent: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!image) {
|
||||||
|
throw new Error('Image not found');
|
||||||
|
}
|
||||||
|
if (image.isModeratedHidden) {
|
||||||
|
throw new Error('Image hidden by moderation');
|
||||||
|
}
|
||||||
|
const imagePath = this.buildFilePath(image.hash, 'erotic');
|
||||||
|
if (!fs.existsSync(imagePath)) {
|
||||||
|
throw new Error(`File "${imagePath}" not found`);
|
||||||
|
}
|
||||||
|
return imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEroticVideos(hashedId) {
|
||||||
|
const userId = await this.requireAdultAreaAccessByHash(hashedId);
|
||||||
|
return await EroticVideo.findAll({
|
||||||
|
where: { userId },
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadEroticVideo(hashedId, file, formData) {
|
||||||
|
const userId = await this.requireAdultAreaAccessByHash(hashedId);
|
||||||
|
await this.ensureAdultUploadsAllowed(userId);
|
||||||
|
if (!file) {
|
||||||
|
throw new Error('Video file is required');
|
||||||
|
}
|
||||||
|
const allowedMimeTypes = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
|
||||||
|
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||||
|
throw new Error('Unsupported video format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = this.generateUniqueFileName(file.originalname);
|
||||||
|
const filePath = this.buildFilePath(fileName, 'erotic-video');
|
||||||
|
await this.saveFile(file.buffer, filePath);
|
||||||
|
|
||||||
|
return await EroticVideo.create({
|
||||||
|
title: formData.title || file.originalname,
|
||||||
|
description: formData.description || null,
|
||||||
|
originalFileName: file.originalname,
|
||||||
|
hash: fileName,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEroticVideoFilePath(hashedId, hash) {
|
||||||
|
const userId = await this.requireAdultAreaAccessByHash(hashedId);
|
||||||
|
const video = await EroticVideo.findOne({
|
||||||
|
where: { hash, userId }
|
||||||
|
});
|
||||||
|
if (!video) {
|
||||||
|
throw new Error('Video not found');
|
||||||
|
}
|
||||||
|
if (video.isModeratedHidden) {
|
||||||
|
throw new Error('Video hidden by moderation');
|
||||||
|
}
|
||||||
|
const videoPath = this.buildFilePath(video.hash, 'erotic-video');
|
||||||
|
if (!fs.existsSync(videoPath)) {
|
||||||
|
throw new Error(`File "${videoPath}" not found`);
|
||||||
|
}
|
||||||
|
return { filePath: videoPath, mimeType: video.mimeType };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEroticContentReport(hashedId, payload) {
|
||||||
|
const reporterId = await this.requireAdultAreaAccessByHash(hashedId);
|
||||||
|
const targetType = String(payload.targetType || '').trim().toLowerCase();
|
||||||
|
const targetId = Number(payload.targetId);
|
||||||
|
const reason = String(payload.reason || '').trim().toLowerCase();
|
||||||
|
const note = payload.note ? String(payload.note).trim() : null;
|
||||||
|
|
||||||
|
if (!['image', 'video'].includes(targetType) || !Number.isInteger(targetId) || targetId <= 0) {
|
||||||
|
throw new Error('Invalid report target');
|
||||||
|
}
|
||||||
|
if (!['suspected_minor', 'non_consensual', 'violence', 'harassment', 'spam', 'other'].includes(reason)) {
|
||||||
|
throw new Error('Invalid report reason');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ownerId } = await this.resolveEroticTarget(targetType, targetId);
|
||||||
|
if (ownerId === reporterId) {
|
||||||
|
throw new Error('Own content cannot be reported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingOpen = await EroticContentReport.findOne({
|
||||||
|
where: {
|
||||||
|
reporterId,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
status: 'open'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (existingOpen) {
|
||||||
|
return existingOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await EroticContentReport.create({
|
||||||
|
reporterId,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
status: 'open'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Public variant used by blog: allow access if the image's folder is visible to 'everyone'.
|
// Public variant used by blog: allow access if the image's folder is visible to 'everyone'.
|
||||||
async getImageFilePathPublicByHash(hash) {
|
async getImageFilePathPublicByHash(hash) {
|
||||||
const image = await Image.findOne({ where: { hash } });
|
const image = await Image.findOne({ where: { hash } });
|
||||||
@@ -510,7 +810,7 @@ class SocialNetworkService extends BaseService {
|
|||||||
async changeImage(hashedUserId, imageId, title, visibilities) {
|
async changeImage(hashedUserId, imageId, title, visibilities) {
|
||||||
const userId = await this.checkUserAccess(hashedUserId);
|
const userId = await this.checkUserAccess(hashedUserId);
|
||||||
await this.checkUserImageAccess(userId, imageId);
|
await this.checkUserImageAccess(userId, imageId);
|
||||||
const image = await Image.findOne({ where: { id: imageId } });
|
const image = await Image.findOne({ where: { id: imageId, isAdultContent: false } });
|
||||||
if (!image) {
|
if (!image) {
|
||||||
throw new Error('image not found')
|
throw new Error('image not found')
|
||||||
}
|
}
|
||||||
@@ -522,13 +822,33 @@ class SocialNetworkService extends BaseService {
|
|||||||
return image.folderId;
|
return image.folderId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async changeAdultImage(hashedUserId, imageId, title, visibilities) {
|
||||||
|
const userId = await this.requireAdultAreaAccessByHash(hashedUserId);
|
||||||
|
const image = await Image.findOne({
|
||||||
|
where: {
|
||||||
|
id: imageId,
|
||||||
|
userId,
|
||||||
|
isAdultContent: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!image) {
|
||||||
|
throw new Error('image not found');
|
||||||
|
}
|
||||||
|
await image.update({ title });
|
||||||
|
await ImageImageVisibility.destroy({ where: { imageId } });
|
||||||
|
for (const visibility of visibilities) {
|
||||||
|
await ImageImageVisibility.create({ imageId, visibilityTypeId: visibility.id });
|
||||||
|
}
|
||||||
|
return image.folderId;
|
||||||
|
}
|
||||||
|
|
||||||
async getFoldersByUsername(username, hashedUserId) {
|
async getFoldersByUsername(username, hashedUserId) {
|
||||||
const user = await this.loadUserByName(username);
|
const user = await this.loadUserByName(username);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
}
|
}
|
||||||
const requestingUserId = await this.checkUserAccess(hashedUserId);
|
const requestingUserId = await this.checkUserAccess(hashedUserId);
|
||||||
let rootFolder = await Folder.findOne({ where: { parentId: null, userId: user.id } });
|
let rootFolder = await Folder.findOne({ where: { parentId: null, userId: user.id, isAdultArea: false } });
|
||||||
if (!rootFolder) {
|
if (!rootFolder) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -542,9 +862,9 @@ class SocialNetworkService extends BaseService {
|
|||||||
const folderIdString = String(folderId);
|
const folderIdString = String(folderId);
|
||||||
const requestingUserIdString = String(requestingUserId);
|
const requestingUserIdString = String(requestingUserId);
|
||||||
const requestingUser = await User.findOne({ where: { id: requestingUserIdString } });
|
const requestingUser = await User.findOne({ where: { id: requestingUserIdString } });
|
||||||
const isAdult = this.isUserAdult(requestingUser.id);
|
const isAdult = await this.isUserAdult(requestingUser.id);
|
||||||
const accessibleFolders = await Folder.findAll({
|
const accessibleFolders = await Folder.findAll({
|
||||||
where: { parentId: folderIdString },
|
where: { parentId: folderIdString, isAdultArea: false },
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: ImageVisibilityType,
|
model: ImageVisibilityType,
|
||||||
|
|||||||
5
backend/sql/add_adult_area_to_gallery.sql
Normal file
5
backend/sql/add_adult_area_to_gallery.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE community.folder
|
||||||
|
ADD COLUMN IF NOT EXISTS is_adult_area BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE community.image
|
||||||
|
ADD COLUMN IF NOT EXISTS is_adult_content BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
36
backend/sql/add_adult_content_moderation.sql
Normal file
36
backend/sql/add_adult_content_moderation.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
ALTER TABLE community.image
|
||||||
|
ADD COLUMN IF NOT EXISTS is_moderated_hidden BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE community.erotic_video
|
||||||
|
ADD COLUMN IF NOT EXISTS is_moderated_hidden BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS community.erotic_content_report (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
reporter_id INTEGER NOT NULL REFERENCES community."user"(id) ON DELETE CASCADE,
|
||||||
|
target_type VARCHAR(20) NOT NULL,
|
||||||
|
target_id INTEGER NOT NULL,
|
||||||
|
reason VARCHAR(80) NOT NULL,
|
||||||
|
note TEXT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||||
|
action_taken VARCHAR(40) NULL,
|
||||||
|
handled_by INTEGER NULL REFERENCES community."user"(id) ON DELETE SET NULL,
|
||||||
|
handled_at TIMESTAMP WITH TIME ZONE NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS erotic_content_report_status_idx
|
||||||
|
ON community.erotic_content_report (status, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS erotic_content_report_target_idx
|
||||||
|
ON community.erotic_content_report (target_type, target_id);
|
||||||
|
|
||||||
|
INSERT INTO type.user_param (description, datatype, settings_id, order_id, min_age)
|
||||||
|
SELECT 'adult_upload_blocked', 'bool', st.id, 999, 18
|
||||||
|
FROM type.settings st
|
||||||
|
WHERE st.name = 'account'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM type.user_param upt
|
||||||
|
WHERE upt.description = 'adult_upload_blocked'
|
||||||
|
);
|
||||||
21
backend/sql/add_adult_verification_user_param.sql
Normal file
21
backend/sql/add_adult_verification_user_param.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- Erotikbereich: Sichtbar ab 18, nutzbar erst nach Moderatorfreigabe
|
||||||
|
|
||||||
|
INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit)
|
||||||
|
SELECT 'adult_verification_status', 'string', s.id, 910, false, 18, NULL, NULL
|
||||||
|
FROM type.settings s
|
||||||
|
WHERE s.name = 'account'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM type.user_param p
|
||||||
|
WHERE p.description = 'adult_verification_status'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit)
|
||||||
|
SELECT 'adult_verification_request', 'string', s.id, 911, false, 18, NULL, NULL
|
||||||
|
FROM type.settings s
|
||||||
|
WHERE s.name = 'account'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM type.user_param p
|
||||||
|
WHERE p.description = 'adult_verification_request'
|
||||||
|
);
|
||||||
2
backend/sql/add_is_adult_only_to_chat_room.sql
Normal file
2
backend/sql/add_is_adult_only_to_chat_room.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE chat.room
|
||||||
|
ADD COLUMN IF NOT EXISTS is_adult_only BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
-- Karotte: Debug-Tempo und Preis an gleiche Basis wie andere Kat.-1-Waren (siehe initializeFalukantPredefines.js).
|
-- Karotte: Tempo und Preis wie andere Kat.-1-Waren (sell_cost 11).
|
||||||
-- Sicher für alle Installationen: nur production_time ohne optionale Spalten.
|
-- Invariante (siehe backend/utils/falukant/falukantProductEconomy.js): bei Zertifikat=Kategorie und
|
||||||
|
-- 100 % Wissen muss sell_cost mindestens ceil(Stückkosten * 100 / 75) sein (Kat. 1 → min. 10).
|
||||||
|
-- Nach manuell zu niedrigem sell_cost (z. B. Erlös ~3) ausführen.
|
||||||
|
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
@@ -7,32 +9,12 @@ UPDATE falukant_type.product
|
|||||||
SET production_time = 2
|
SET production_time = 2
|
||||||
WHERE label_tr = 'carrot';
|
WHERE label_tr = 'carrot';
|
||||||
|
|
||||||
COMMIT;
|
-- Basispreis angleichen (ohne Steuer-Aufschreibung; ggf. danach update_product_sell_costs.sql)
|
||||||
|
|
||||||
-- Optional (wenn Migration mit original_sell_cost läuft): in derselben Session ausführen
|
|
||||||
/*
|
|
||||||
UPDATE falukant_type.product
|
UPDATE falukant_type.product
|
||||||
SET original_sell_cost = 6
|
SET sell_cost = 11
|
||||||
WHERE label_tr = 'carrot';
|
WHERE label_tr = 'carrot';
|
||||||
|
|
||||||
WITH RECURSIVE ancestors AS (
|
COMMIT;
|
||||||
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
|
||||||
UNION ALL
|
|
||||||
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
|
||||||
FROM falukant_data.region r
|
|
||||||
JOIN ancestors a ON r.id = a.parent_id
|
|
||||||
), totals AS (
|
|
||||||
SELECT start_id, COALESCE(SUM(tax_percent), 0) AS total FROM ancestors GROUP BY start_id
|
|
||||||
), mm AS (
|
|
||||||
SELECT COALESCE(MAX(total), 0) AS max_total FROM totals
|
|
||||||
)
|
|
||||||
UPDATE falukant_type.product p
|
|
||||||
SET sell_cost = CEIL(p.original_sell_cost * (
|
|
||||||
CASE WHEN (1 - mm.max_total / 100) <= 0 THEN 1 ELSE (1 / (1 - mm.max_total / 100)) END
|
|
||||||
))
|
|
||||||
FROM mm
|
|
||||||
WHERE p.label_tr = 'carrot' AND p.original_sell_cost IS NOT NULL;
|
|
||||||
*/
|
|
||||||
|
|
||||||
-- Ohne original_sell_cost: grob sell_cost = 6 (wie Milch/Brot; ggf. anpassen)
|
-- Optional: Spalte original_sell_cost mitpflegen, falls ihr die MAX-STRATEGY aus update_product_sell_costs.sql nutzt
|
||||||
-- UPDATE falukant_type.product SET sell_cost = 6 WHERE label_tr = 'carrot';
|
-- UPDATE falukant_type.product SET original_sell_cost = 11 WHERE label_tr = 'carrot';
|
||||||
|
|||||||
11
backend/sql/create_erotic_video.sql
Normal file
11
backend/sql/create_erotic_video.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS community.erotic_video (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
original_file_name VARCHAR(255) NOT NULL,
|
||||||
|
hash VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
mime_type VARCHAR(255) NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES community."user"(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -4,7 +4,7 @@ UPDATE falukant_type.product
|
|||||||
SET sell_cost = CASE label_tr
|
SET sell_cost = CASE label_tr
|
||||||
WHEN 'wheat' THEN 11
|
WHEN 'wheat' THEN 11
|
||||||
WHEN 'grain' THEN 11
|
WHEN 'grain' THEN 11
|
||||||
WHEN 'carrot' THEN 8
|
WHEN 'carrot' THEN 11
|
||||||
WHEN 'fish' THEN 11
|
WHEN 'fish' THEN 11
|
||||||
WHEN 'meat' THEN 11
|
WHEN 'meat' THEN 11
|
||||||
WHEN 'leather' THEN 11
|
WHEN 'leather' THEN 11
|
||||||
|
|||||||
96
backend/utils/falukant/falukantProductEconomy.js
Normal file
96
backend/utils/falukant/falukantProductEconomy.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Zentrale Produktions- und Preisformeln (muss mit der Spielwirtschaft konsistent bleiben).
|
||||||
|
* Wird von falukantService und der Produkt-Initialisierung genutzt.
|
||||||
|
*
|
||||||
|
* Mindest-Erlös (Ertrags-Tabelle, Branch): bei 100 % Produktwissen ist der Verkaufspreis
|
||||||
|
* das obere Ende der Spanne = basePrice = sellCost * (effectiveWorth/100), mit
|
||||||
|
* effectiveWorth >= 75 (siehe effectiveWorthPercent in falukantService).
|
||||||
|
* Engster Fall für Gewinn/Stück: Zertifikat = Produktkategorie (kein Headroom-Rabatt auf
|
||||||
|
* Stückkosten) und regionale Nachfrage am Boden (75 %).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PRODUCTION_COST_BASE = 6.0;
|
||||||
|
export const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0;
|
||||||
|
export const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035;
|
||||||
|
export const PRODUCTION_HEADROOM_DISCOUNT_CAP = 0.14;
|
||||||
|
|
||||||
|
export function productionPieceCost(certificate, category) {
|
||||||
|
const c = Math.max(1, Number(category) || 1);
|
||||||
|
const cert = Math.max(1, Number(certificate) || 1);
|
||||||
|
const raw = PRODUCTION_COST_BASE + (c * PRODUCTION_COST_PER_PRODUCT_CATEGORY);
|
||||||
|
const headroom = Math.max(0, cert - c);
|
||||||
|
const discount = Math.min(
|
||||||
|
headroom * PRODUCTION_HEADROOM_DISCOUNT_PER_STEP,
|
||||||
|
PRODUCTION_HEADROOM_DISCOUNT_CAP
|
||||||
|
);
|
||||||
|
return raw * (1 - discount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productionCostTotal(quantity, category, certificate) {
|
||||||
|
const q = Math.min(100, Math.max(1, Number(quantity) || 1));
|
||||||
|
return q * productionPieceCost(certificate, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveWorthPercent(worthPercent) {
|
||||||
|
const w = Number(worthPercent);
|
||||||
|
if (Number.isNaN(w)) return 75;
|
||||||
|
return Math.min(100, Math.max(75, w));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Untergrenze für den Wissens-Multiplikator auf den regionalen Basispreis. */
|
||||||
|
export const KNOWLEDGE_PRICE_FLOOR = 0.7;
|
||||||
|
|
||||||
|
export function calcSellPrice(product, knowledgeFactor = 0) {
|
||||||
|
const max = product.sellCost;
|
||||||
|
const min = max * KNOWLEDGE_PRICE_FLOOR;
|
||||||
|
return min + (max - min) * (knowledgeFactor / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent) {
|
||||||
|
if (product.sellCost === null || product.sellCost === undefined) return null;
|
||||||
|
const w = effectiveWorthPercent(worthPercent);
|
||||||
|
const basePrice = product.sellCost * (w / 100);
|
||||||
|
const min = basePrice * KNOWLEDGE_PRICE_FLOOR;
|
||||||
|
const max = basePrice;
|
||||||
|
return min + (max - min) * (knowledgeFactor / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Untergrenze für worthPercent nach effectiveWorthPercent (75–100). */
|
||||||
|
export const EFFECTIVE_WORTH_PERCENT_MIN = 75;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimaler ganzzahliger Basis-sell_cost (vor Steuer-/Regions-Faktoren in der DB),
|
||||||
|
* sodass bei Zertifikat = Produktkategorie, 100 % Wissen und 75 % Nachfrage
|
||||||
|
* der Erlös pro Stück >= Stückkosten (kein struktureller Verlust in der Ertrags-Tabelle).
|
||||||
|
*/
|
||||||
|
export function minBaseSellCostForTightProduction(category) {
|
||||||
|
const c = Math.max(1, Number(category) || 1);
|
||||||
|
const cost = productionPieceCost(c, c);
|
||||||
|
return Math.ceil((cost * 100) / EFFECTIVE_WORTH_PERCENT_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft Vordefinierungen; meldet Abweichungen nur per warn (kein Throw), damit Deployments
|
||||||
|
* mit alter DB nicht brechen — Balance-Anpassung erfolgt bewusst im Code/SQL.
|
||||||
|
*/
|
||||||
|
export function validateProductBaseSellCosts(products, { warn = console.warn } = {}) {
|
||||||
|
const issues = [];
|
||||||
|
for (const p of products) {
|
||||||
|
const min = minBaseSellCostForTightProduction(p.category);
|
||||||
|
if (Number(p.sellCost) < min) {
|
||||||
|
issues.push({
|
||||||
|
labelTr: p.labelTr,
|
||||||
|
category: p.category,
|
||||||
|
sellCost: p.sellCost,
|
||||||
|
minRequired: min,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (issues.length && typeof warn === 'function') {
|
||||||
|
warn(
|
||||||
|
'[falukantProductEconomy] sell_cost unter Mindestbedarf (Zertifikat=Kategorie, 100% Wissen, 75% Nachfrage):',
|
||||||
|
issues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import FalukantStockType from "../../models/falukant/type/stock.js";
|
|||||||
import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js";
|
import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js";
|
||||||
import TitleRequirement from "../../models/falukant/type/title_requirement.js";
|
import TitleRequirement from "../../models/falukant/type/title_requirement.js";
|
||||||
import { sequelize } from "../sequelize.js";
|
import { sequelize } from "../sequelize.js";
|
||||||
|
import { validateProductBaseSellCosts } from "./falukantProductEconomy.js";
|
||||||
|
|
||||||
export const initializeFalukantPredefines = async () => {
|
export const initializeFalukantPredefines = async () => {
|
||||||
await initializeFalukantFirstnames();
|
await initializeFalukantFirstnames();
|
||||||
@@ -250,7 +251,7 @@ async function initializeFalukantProducts() {
|
|||||||
const baseProducts = [
|
const baseProducts = [
|
||||||
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 11 },
|
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 11 },
|
||||||
{ labelTr: 'grain', category: 1, productionTime: 2, sellCost: 11 },
|
{ labelTr: 'grain', category: 1, productionTime: 2, sellCost: 11 },
|
||||||
{ labelTr: 'carrot', category: 1, productionTime: 2, sellCost: 8 },
|
{ labelTr: 'carrot', category: 1, productionTime: 2, sellCost: 11 },
|
||||||
{ labelTr: 'fish', category: 1, productionTime: 2, sellCost: 11 },
|
{ labelTr: 'fish', category: 1, productionTime: 2, sellCost: 11 },
|
||||||
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 11 },
|
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 11 },
|
||||||
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 11 },
|
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 11 },
|
||||||
@@ -282,6 +283,8 @@ async function initializeFalukantProducts() {
|
|||||||
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 78 },
|
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 78 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
validateProductBaseSellCosts(baseProducts);
|
||||||
|
|
||||||
const productsToInsert = baseProducts.map(p => ({
|
const productsToInsert = baseProducts.map(p => ({
|
||||||
...p,
|
...p,
|
||||||
sellCostMinNeutral: Math.ceil(p.sellCost * factorMin),
|
sellCostMinNeutral: Math.ceil(p.sellCost * factorMin),
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ const initializeTypes = async () => {
|
|||||||
willChildren: { type: 'bool', setting: 'flirt', minAge: 14 },
|
willChildren: { type: 'bool', setting: 'flirt', minAge: 14 },
|
||||||
smokes: { type: 'singleselect', setting: 'flirt', minAge: 14},
|
smokes: { type: 'singleselect', setting: 'flirt', minAge: 14},
|
||||||
drinks: { type: 'singleselect', setting: 'flirt', minAge: 14 },
|
drinks: { type: 'singleselect', setting: 'flirt', minAge: 14 },
|
||||||
|
adult_verification_status: { type: 'string', setting: 'account', minAge: 18 },
|
||||||
|
adult_verification_request: { type: 'string', setting: 'account', minAge: 18 },
|
||||||
|
adult_upload_blocked: { type: 'bool', setting: 'account', minAge: 18 },
|
||||||
llm_settings: { type: 'string', setting: 'languageAssistant' },
|
llm_settings: { type: 'string', setting: 'languageAssistant' },
|
||||||
llm_api_key: { type: 'string', setting: 'languageAssistant' },
|
llm_api_key: { type: 'string', setting: 'languageAssistant' },
|
||||||
};
|
};
|
||||||
|
|||||||
406
docs/ADULT_SOCIAL_EROTIC_CONCEPT.md
Normal file
406
docs/ADULT_SOCIAL_EROTIC_CONCEPT.md
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
# yourPart: Konzept für den Erotikbereich
|
||||||
|
|
||||||
|
## 1. Ausgangspunkt
|
||||||
|
|
||||||
|
Im Menü ist die klare Trennung bereits vorgesehen:
|
||||||
|
|
||||||
|
- `Social Network -> Galerie`
|
||||||
|
- `Social Network -> Erotik -> Bilder`
|
||||||
|
- `Social Network -> Erotik -> Videos`
|
||||||
|
|
||||||
|
Zusätzlich existiert im Chat-Umfeld bereits die Idee `Erotikchat`.
|
||||||
|
|
||||||
|
Damit sollte der 18+-Bereich **nicht** als bloßer Filter der normalen Galerie gedacht werden, sondern als **eigener Social-Bereich für Erwachsene**.
|
||||||
|
|
||||||
|
## 2. Zielbild
|
||||||
|
|
||||||
|
Der Erotikbereich ist ein eigener, abgegrenzter Teil des Social Networks für volljährige Nutzer.
|
||||||
|
|
||||||
|
Wichtig für den Zugang:
|
||||||
|
|
||||||
|
- **Erotik -> Bilder**
|
||||||
|
- **Erotik -> Videos**
|
||||||
|
- später zusätzlich **Erotik -> Chat** oder klar markierte 18+-Chaträume
|
||||||
|
|
||||||
|
Der Erotikbereich soll:
|
||||||
|
|
||||||
|
- ab **18 Jahren** im Menü sichtbar sein
|
||||||
|
- aber erst nach **Moderatorfreigabe** wirklich nutzbar sein
|
||||||
|
- technisch und visuell **klar vom normalen Social-Bereich getrennt** sein
|
||||||
|
- nicht versehentlich in allgemeine Feeds, Galerien oder Raumlisten hineinlaufen
|
||||||
|
- serverseitig abgesichert sein
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- **nicht** die gesamte Plattform wird auf Erwachsene beschränkt
|
||||||
|
- **nicht** das gesamte Social Network wird auf Erwachsene beschränkt
|
||||||
|
- ausschließlich die Module unter `Social Network -> Erotik -> ...` sind volljährigen Nutzern vorbehalten
|
||||||
|
- normale Bereiche wie Suche, Forum, Galerie, Freunde, Tagebuch und Chat bleiben davon getrennt
|
||||||
|
|
||||||
|
## 3. Bestand heute
|
||||||
|
|
||||||
|
Vorhanden:
|
||||||
|
|
||||||
|
- Menüstruktur für `Erotik -> Bilder` und `Erotik -> Videos` in [navigationController.js](/mnt/share/torsten/Programs/YourPart3/backend/controllers/navigationController.js)
|
||||||
|
- Navigationstexte in [navigation.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/de/navigation.json)
|
||||||
|
- normale Galerie in [GalleryView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/social/GalleryView.vue)
|
||||||
|
- Mehrraum-Chat in [MultiChatDialog.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/dialogues/chat/MultiChatDialog.vue)
|
||||||
|
- vorhandene Erwachsenensichtbarkeiten in der Galerie (`adults`, `friends-and-adults`)
|
||||||
|
|
||||||
|
Noch nicht fertig:
|
||||||
|
|
||||||
|
- echte Moderationsfreischaltung für Erwachsene
|
||||||
|
- eigene Erotik-Bilderansicht
|
||||||
|
- eigenes Erotik-Video-Modul
|
||||||
|
- 18+-Chatanbindung
|
||||||
|
- harte serverseitige Sperren für nicht berechtigte Nutzer
|
||||||
|
- Moderation speziell für Adult-Inhalte
|
||||||
|
|
||||||
|
## 4. Grundentscheidung
|
||||||
|
|
||||||
|
Erotik wird als **eigener Bereich** modelliert, nicht als Untermenge der Standard-Galerie.
|
||||||
|
|
||||||
|
Das bedeutet:
|
||||||
|
|
||||||
|
- normale Galerie bleibt normaler Social-Bereich
|
||||||
|
- Erotik-Bilder sind ein eigenes Modul
|
||||||
|
- Erotik-Videos sind ein eigenes Modul
|
||||||
|
- späterer Erotik-Chat ist ebenfalls ein eigenes Modul oder klar abgegrenzte Raumgruppe
|
||||||
|
|
||||||
|
Vorteile:
|
||||||
|
|
||||||
|
- klare UX
|
||||||
|
- weniger Vermischung
|
||||||
|
- einfachere Berechtigungslogik
|
||||||
|
- sauberere Moderation
|
||||||
|
- spätere Erweiterung auf Videos ohne Umbau
|
||||||
|
|
||||||
|
## 5. Zugangsmodell
|
||||||
|
|
||||||
|
## 5.1 Volljährigkeit
|
||||||
|
|
||||||
|
Nur Nutzer mit:
|
||||||
|
|
||||||
|
- `Alter >= 18`
|
||||||
|
|
||||||
|
dürfen den Erotikbereich überhaupt sehen.
|
||||||
|
|
||||||
|
## 5.2 Moderationsfreigabe
|
||||||
|
|
||||||
|
Zusätzlich braucht es eine echte Freischaltung:
|
||||||
|
|
||||||
|
- `adultVerificationStatus = none | pending | approved | rejected`
|
||||||
|
|
||||||
|
Dabei gilt:
|
||||||
|
|
||||||
|
- volljährig allein reicht nicht für die Nutzung
|
||||||
|
- erst `approved` schaltet Bilder, Videos und später Chats frei
|
||||||
|
- die Freigabe erfolgt durch Moderation auf Basis eines Nachweises
|
||||||
|
|
||||||
|
## 5.3 Sicht im Menü
|
||||||
|
|
||||||
|
Empfehlung:
|
||||||
|
|
||||||
|
- unter 18: `Erotik` erscheint gar nicht
|
||||||
|
- ab 18 ohne Freigabe: `Erotik` erscheint, die Unterpunkte sind sichtbar, aber gesperrt
|
||||||
|
- ab 18 mit `pending`: sichtbar, weiterhin gesperrt
|
||||||
|
- ab 18 mit `approved`: normal nutzbar
|
||||||
|
- ab 18 mit `rejected`: sichtbar, aber weiter gesperrt mit Hinweis auf erneute Anfrage
|
||||||
|
|
||||||
|
Alle anderen Social-Network-Bereiche bleiben unverändert erreichbar, sofern ihre eigenen Altersregeln nichts anderes verlangen.
|
||||||
|
|
||||||
|
## 6. Fachmodell
|
||||||
|
|
||||||
|
## 6.1 Nutzer
|
||||||
|
|
||||||
|
Benötigte logische Zustände:
|
||||||
|
|
||||||
|
- `isAdult`
|
||||||
|
- `adultVerificationStatus`
|
||||||
|
- optional später zusätzlich `adultModeEnabled` als freiwilliger Opt-in nach Freigabe
|
||||||
|
|
||||||
|
`isAdult` sollte aus vorhandenen Altersdaten abgeleitet werden, nicht frei gesetzt.
|
||||||
|
|
||||||
|
## 6.2 Erotik-Bilder
|
||||||
|
|
||||||
|
Eigenes Inhaltsmodell:
|
||||||
|
|
||||||
|
- Bild gehört zum Erotikbereich
|
||||||
|
- zusätzlich Sichtbarkeit wie bisher möglich
|
||||||
|
|
||||||
|
Empfohlene Felder:
|
||||||
|
|
||||||
|
- `isAdultContent` oder `contentRating = adult`
|
||||||
|
- optional `adultVisibility`
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- das ist mehr als bloß `adults` als Sichtbarkeit
|
||||||
|
- wir müssen auch fachlich erkennen können, dass der Inhalt **zum Erotikbereich** gehört
|
||||||
|
|
||||||
|
## 6.3 Erotik-Videos
|
||||||
|
|
||||||
|
Eigenes Inhaltsmodell analog zu Bildern:
|
||||||
|
|
||||||
|
- Video gehört zum Erotikbereich
|
||||||
|
- eigenes Metadatenmodell
|
||||||
|
- später Vorschaubild, Dauer, Format, Moderationsstatus
|
||||||
|
|
||||||
|
Empfohlene Felder:
|
||||||
|
|
||||||
|
- `isAdultContent`
|
||||||
|
- `processingStatus`
|
||||||
|
- `thumbnail`
|
||||||
|
|
||||||
|
## 6.4 Erotik-Chat
|
||||||
|
|
||||||
|
Für Chat reicht fachlich:
|
||||||
|
|
||||||
|
- bestehender Raumtyp `chat`
|
||||||
|
- plus Flag `isAdultOnly`
|
||||||
|
|
||||||
|
Optional zusätzlich:
|
||||||
|
|
||||||
|
- Raumtyp `erotic_chat`
|
||||||
|
|
||||||
|
## 7. Module
|
||||||
|
|
||||||
|
## 7.1 Erotik -> Bilder
|
||||||
|
|
||||||
|
Eigene View:
|
||||||
|
|
||||||
|
- zeigt nur Inhalte aus dem Erotikbereich
|
||||||
|
- kein Vermischen mit normaler Galerie
|
||||||
|
|
||||||
|
Funktionen:
|
||||||
|
|
||||||
|
- hochladen
|
||||||
|
- organisieren
|
||||||
|
- ansehen
|
||||||
|
- Sichtbarkeit steuern
|
||||||
|
- melden
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
|
||||||
|
- keine Ausgabe an nicht berechtigte Nutzer
|
||||||
|
- keine Thumbnails für nicht berechtigte Nutzer
|
||||||
|
- Direktaufruf serverseitig blocken
|
||||||
|
|
||||||
|
## 7.2 Erotik -> Videos
|
||||||
|
|
||||||
|
Eigene View:
|
||||||
|
|
||||||
|
- separat von Bildern
|
||||||
|
- gleiche Erwachsenensperren
|
||||||
|
|
||||||
|
Funktionen:
|
||||||
|
|
||||||
|
- Video-Upload
|
||||||
|
- Videoliste
|
||||||
|
- Vorschau
|
||||||
|
- Wiedergabe
|
||||||
|
- melden
|
||||||
|
|
||||||
|
Erste Ausbaustufe:
|
||||||
|
|
||||||
|
- einfache Liste
|
||||||
|
- keine komplexe Transcoding- oder Streaminglogik nötig, falls noch nicht vorhanden
|
||||||
|
|
||||||
|
## 7.3 Erotik -> Chat
|
||||||
|
|
||||||
|
Nicht zwingend sofort als eigener Menüpunkt nötig, aber fachlich vorbereiten.
|
||||||
|
|
||||||
|
Variante A:
|
||||||
|
|
||||||
|
- eigener Menüpunkt `Erotikchat`
|
||||||
|
|
||||||
|
Variante B:
|
||||||
|
|
||||||
|
- innerhalb des Mehrraum-Chats klar abgetrennte `18+`-Raumgruppe
|
||||||
|
|
||||||
|
Empfehlung:
|
||||||
|
|
||||||
|
- später eigener Einstieg oder klarer Erwachsenentab
|
||||||
|
- nicht bloß unauffällige Räume in der normalen Liste
|
||||||
|
|
||||||
|
## 8. Galerie- und Videologik
|
||||||
|
|
||||||
|
## 8.1 Keine Vermischung
|
||||||
|
|
||||||
|
Normale Galerie:
|
||||||
|
|
||||||
|
- zeigt keine Adult-Inhalte
|
||||||
|
|
||||||
|
Erotik-Bilder:
|
||||||
|
|
||||||
|
- zeigen nur Adult-Bilder
|
||||||
|
|
||||||
|
Erotik-Videos:
|
||||||
|
|
||||||
|
- zeigen nur Adult-Videos
|
||||||
|
|
||||||
|
## 8.2 Uploadregeln
|
||||||
|
|
||||||
|
Nur erlaubt für:
|
||||||
|
|
||||||
|
- `isAdult = true`
|
||||||
|
- `adultVerificationStatus = approved`
|
||||||
|
|
||||||
|
Beim Upload muss der Bereich eindeutig sein:
|
||||||
|
|
||||||
|
- normales Bild
|
||||||
|
- Erotik-Bild
|
||||||
|
- normales Video
|
||||||
|
- Erotik-Video
|
||||||
|
|
||||||
|
## 8.3 Vorschaulogik
|
||||||
|
|
||||||
|
Nicht berechtigte Nutzer dürfen:
|
||||||
|
|
||||||
|
- weder Originaldateien
|
||||||
|
- noch Vorschaubilder
|
||||||
|
- noch Metadatenlisten
|
||||||
|
|
||||||
|
erhalten.
|
||||||
|
|
||||||
|
## 9. Chatlogik
|
||||||
|
|
||||||
|
## 9.1 Raumlistenfilter
|
||||||
|
|
||||||
|
Nicht berechtigte Nutzer:
|
||||||
|
|
||||||
|
- sehen keine Adult-Räume
|
||||||
|
|
||||||
|
Berechtigte Nutzer:
|
||||||
|
|
||||||
|
- sehen Adult-Räume in klarer Erwachsenengruppe
|
||||||
|
|
||||||
|
## 9.2 Beitritt
|
||||||
|
|
||||||
|
Server prüft beim Join:
|
||||||
|
|
||||||
|
- Nutzer volljährig
|
||||||
|
- Moderation hat den Bereich freigeschaltet
|
||||||
|
- Raum `isAdultOnly`
|
||||||
|
|
||||||
|
## 9.3 Random Chat
|
||||||
|
|
||||||
|
Erste Version:
|
||||||
|
|
||||||
|
- kein erotischer Random Chat
|
||||||
|
|
||||||
|
Begründung:
|
||||||
|
|
||||||
|
- höheres Missbrauchsrisiko
|
||||||
|
- kompliziertere Consent- und Moderationslage
|
||||||
|
|
||||||
|
## 10. Moderation
|
||||||
|
|
||||||
|
Adult-Bereich braucht eigene Moderationslogik.
|
||||||
|
|
||||||
|
## 10.1 Meldegründe
|
||||||
|
|
||||||
|
- Minderjährigkeitsverdacht
|
||||||
|
- unerlaubte Inhalte
|
||||||
|
- Belästigung
|
||||||
|
- Druck / Nötigung
|
||||||
|
- Gewalt-/Missbrauchskontext
|
||||||
|
- Spam / Scam
|
||||||
|
|
||||||
|
## 10.2 Adminsicht
|
||||||
|
|
||||||
|
Admins brauchen:
|
||||||
|
|
||||||
|
- Adult-Kennzeichnung an Bildern
|
||||||
|
- Adult-Kennzeichnung an Videos
|
||||||
|
- Adult-Kennzeichnung an Räumen
|
||||||
|
- schnelle Sperrung einzelner Inhalte
|
||||||
|
- schnelle Sperrung von Uploadrechten
|
||||||
|
|
||||||
|
## 10.3 Nulltoleranz
|
||||||
|
|
||||||
|
Nicht erlaubt:
|
||||||
|
|
||||||
|
- Minderjährige oder minderjährig wirkende Sexualdarstellung
|
||||||
|
- Gewalt-/Missbrauchsdarstellung
|
||||||
|
- Umgehung von Altersgrenzen
|
||||||
|
|
||||||
|
## 11. Technische Umsetzung
|
||||||
|
|
||||||
|
## 11.1 Backend
|
||||||
|
|
||||||
|
Benötigt:
|
||||||
|
|
||||||
|
- Prüfung `isAdult`
|
||||||
|
- Prüfung `adultVerificationStatus`
|
||||||
|
- Filterung von Erotik-Menü/API-Daten
|
||||||
|
- getrennte Endpunkte oder klare Adult-Filter für Bilder
|
||||||
|
- eigenes Video-Modul oder klare Adult-Video-Endpunkte
|
||||||
|
- Chatraumfilter für `isAdultOnly`
|
||||||
|
|
||||||
|
## 11.2 Frontend
|
||||||
|
|
||||||
|
Benötigt:
|
||||||
|
|
||||||
|
- Sicht auf Freischaltungsstatus und Anfrage
|
||||||
|
- eigene Views:
|
||||||
|
- `ErotikBilderView`
|
||||||
|
- `ErotikVideosView`
|
||||||
|
- klare Zugangshinweise bei gesperrtem Bereich
|
||||||
|
- später Adult-Chat-Einstieg
|
||||||
|
|
||||||
|
## 11.3 Serverseitige Pflicht
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- Frontend-Sperren reichen nie aus
|
||||||
|
- jede Dateiausgabe und jeder Chatzugang muss serverseitig geprüft werden
|
||||||
|
|
||||||
|
## 12. Umsetzungsphasen
|
||||||
|
|
||||||
|
## Phase A1: Zugang
|
||||||
|
|
||||||
|
- `isAdult` sauber ableiten
|
||||||
|
- `adultVerificationStatus = none | pending | approved | rejected`
|
||||||
|
- Einstellungs-UI und Freischaltungsansicht
|
||||||
|
- Menü ab 18 sichtbar, bis Freigabe gesperrt
|
||||||
|
- serverseitige Sperren für Adult-Routen
|
||||||
|
|
||||||
|
## Phase A2: Erotik-Bilder
|
||||||
|
|
||||||
|
- eigener Erotik-Bilderpfad
|
||||||
|
- Adult-Kennzeichnung für Bilder
|
||||||
|
- Upload- und Anzeige-Logik
|
||||||
|
|
||||||
|
## Phase A3: Erotik-Videos
|
||||||
|
|
||||||
|
- eigenes Videomodul
|
||||||
|
- Adult-Kennzeichnung für Videos
|
||||||
|
- Upload und Anzeige
|
||||||
|
|
||||||
|
## Phase A4: Erotik-Chat
|
||||||
|
|
||||||
|
- Adult-Raumflag
|
||||||
|
- Raumlistenfilter
|
||||||
|
- Join-Sperren
|
||||||
|
- klarer UI-Einstieg
|
||||||
|
|
||||||
|
## Phase A5: Moderation
|
||||||
|
|
||||||
|
- Meldegründe
|
||||||
|
- Adminsicht
|
||||||
|
- Sperrpfade
|
||||||
|
|
||||||
|
## 13. Empfehlung für den Start
|
||||||
|
|
||||||
|
Die erste sinnvolle, kontrollierbare Version ist:
|
||||||
|
|
||||||
|
- `A1` Zugang
|
||||||
|
- `A2` Erotik-Bilder
|
||||||
|
|
||||||
|
Danach:
|
||||||
|
|
||||||
|
- `A3` Erotik-Videos
|
||||||
|
- `A4` Erotik-Chat
|
||||||
|
|
||||||
|
So nutzt ihr die bereits vorhandene Menüstruktur sauber aus und baut nicht auf halbe Übergangslösungen wie bloße Galeriefilter.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import apiClient from "@/utils/axios.js";
|
import apiClient from "@/utils/axios.js";
|
||||||
|
|
||||||
export const fetchPublicRooms = async () => {
|
export const fetchPublicRooms = async (options = {}) => {
|
||||||
const response = await apiClient.get("/api/chat/rooms");
|
const response = await apiClient.get("/api/chat/rooms", { params: options });
|
||||||
return response.data; // expecting array of { id, title, ... }
|
return response.data; // expecting array of { id, title, ... }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
v-for="(item, key) in menu"
|
v-for="(item, key) in menu"
|
||||||
:key="key"
|
:key="key"
|
||||||
class="mainmenuitem"
|
class="mainmenuitem"
|
||||||
:class="{ 'mainmenuitem--active': isItemActive(item), 'mainmenuitem--expanded': isMainExpanded(key) }"
|
:class="{ 'mainmenuitem--active': isItemActive(item), 'mainmenuitem--expanded': isMainExpanded(key), 'mainmenuitem--disabled': item.disabled }"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
|
:title="item.disabled ? $t(item.disabledReasonKey || 'socialnetwork.erotic.lockedShort') : undefined"
|
||||||
:aria-haspopup="hasTopLevelSubmenu(item) ? 'menu' : undefined"
|
:aria-haspopup="hasTopLevelSubmenu(item) ? 'menu' : undefined"
|
||||||
:aria-expanded="hasTopLevelSubmenu(item) ? String(isMainExpanded(key)) : undefined"
|
:aria-expanded="hasTopLevelSubmenu(item) ? String(isMainExpanded(key)) : undefined"
|
||||||
@click="handleItem(item, $event, key)"
|
@click="handleItem(item, $event, key)"
|
||||||
@@ -35,7 +36,8 @@
|
|||||||
:key="subkey"
|
:key="subkey"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
:class="{ 'submenu1__item--expanded': isSubExpanded(`${key}:${subkey}`) }"
|
:class="{ 'submenu1__item--expanded': isSubExpanded(`${key}:${subkey}`), 'submenu-item--disabled': subitem.disabled }"
|
||||||
|
:title="subitem.disabled ? $t(subitem.disabledReasonKey || 'socialnetwork.erotic.lockedShort') : undefined"
|
||||||
@click="handleSubItem(subitem, subkey, key, $event)"
|
@click="handleSubItem(subitem, subkey, key, $event)"
|
||||||
@keydown.enter.prevent="handleSubItem(subitem, subkey, key, $event)"
|
@keydown.enter.prevent="handleSubItem(subitem, subkey, key, $event)"
|
||||||
@keydown.space.prevent="handleSubItem(subitem, subkey, key, $event)"
|
@keydown.space.prevent="handleSubItem(subitem, subkey, key, $event)"
|
||||||
@@ -109,6 +111,8 @@
|
|||||||
:key="subsubkey"
|
:key="subsubkey"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
:class="{ 'submenu-item--disabled': subsubitem.disabled }"
|
||||||
|
:title="subsubitem.disabled ? $t(subsubitem.disabledReasonKey || 'socialnetwork.erotic.lockedShort') : undefined"
|
||||||
@click="handleItem(subsubitem, $event)"
|
@click="handleItem(subsubitem, $event)"
|
||||||
@keydown.enter.prevent="handleItem(subsubitem, $event)"
|
@keydown.enter.prevent="handleItem(subsubitem, $event)"
|
||||||
@keydown.space.prevent="handleItem(subsubitem, $event)"
|
@keydown.space.prevent="handleItem(subsubitem, $event)"
|
||||||
@@ -357,14 +361,20 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
openMultiChat() {
|
openMultiChat() {
|
||||||
// Räume können später dynamisch geladen werden, hier als Platzhalter ein Beispiel:
|
|
||||||
const exampleRooms = [
|
|
||||||
{ id: 1, title: 'Allgemein' },
|
|
||||||
{ id: 2, title: 'Rollenspiel' }
|
|
||||||
];
|
|
||||||
const ref = this.$root.$refs.multiChatDialog;
|
const ref = this.$root.$refs.multiChatDialog;
|
||||||
if (ref && typeof ref.open === 'function') {
|
if (ref && typeof ref.open === 'function') {
|
||||||
ref.open(exampleRooms);
|
ref.open();
|
||||||
|
} else if (ref?.$refs?.dialog && typeof ref.$refs.dialog.open === 'function') {
|
||||||
|
ref.$refs.dialog.open();
|
||||||
|
} else {
|
||||||
|
console.error('MultiChatDialog nicht bereit oder ohne open()');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openEroticChat() {
|
||||||
|
const ref = this.$root.$refs.multiChatDialog;
|
||||||
|
if (ref && typeof ref.open === 'function') {
|
||||||
|
ref.open(null, { adultOnly: true });
|
||||||
} else if (ref?.$refs?.dialog && typeof ref.$refs.dialog.open === 'function') {
|
} else if (ref?.$refs?.dialog && typeof ref.$refs.dialog.open === 'function') {
|
||||||
ref.$refs.dialog.open();
|
ref.$refs.dialog.open();
|
||||||
} else {
|
} else {
|
||||||
@@ -452,6 +462,10 @@ export default {
|
|||||||
handleItem(item, event, key = null) {
|
handleItem(item, event, key = null) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (item?.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (key && this.hasTopLevelSubmenu(item)) {
|
if (key && this.hasTopLevelSubmenu(item)) {
|
||||||
if (this.isMobileNav) {
|
if (this.isMobileNav) {
|
||||||
this.toggleMain(key);
|
this.toggleMain(key);
|
||||||
@@ -603,6 +617,18 @@ ul {
|
|||||||
border-color: rgba(248, 162, 43, 0.2);
|
border-color: rgba(248, 162, 43, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mainmenuitem--disabled,
|
||||||
|
.submenu-item--disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainmenuitem--disabled:hover {
|
||||||
|
transform: none;
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.mainmenuitem--active {
|
.mainmenuitem--active {
|
||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgba(255, 255, 255, 0.72);
|
||||||
border-color: rgba(248, 162, 43, 0.22);
|
border-color: rgba(248, 162, 43, 0.22);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<th>{{ $t('falukant.branch.revenue.perMinute') }}</th>
|
<th>{{ $t('falukant.branch.revenue.perMinute') }}</th>
|
||||||
<th>{{ $t('falukant.branch.revenue.profitAbsolute') }}</th>
|
<th>{{ $t('falukant.branch.revenue.profitAbsolute') }}</th>
|
||||||
<th>{{ $t('falukant.branch.revenue.profitPerMinute') }}</th>
|
<th>{{ $t('falukant.branch.revenue.profitPerMinute') }}</th>
|
||||||
<th>Bessere Preise</th>
|
<th>{{ $t('falukant.branch.revenue.betterPrices') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -29,12 +29,20 @@
|
|||||||
<td>{{ calculateProductProfit(product).perMinute }}</td>
|
<td>{{ calculateProductProfit(product).perMinute }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div v-if="getBetterPrices(product.id) && getBetterPrices(product.id).length > 0" class="price-cities">
|
<div v-if="getBetterPrices(product.id) && getBetterPrices(product.id).length > 0" class="price-cities">
|
||||||
<span v-for="city in getBetterPrices(product.id)" :key="city.regionId"
|
<template v-for="(city, idx) in getBetterPrices(product.id)" :key="city.regionId">
|
||||||
:class="['city-price', getCityPriceClass(city.branchType)]"
|
<span
|
||||||
:title="`${city.regionName}: ${formatPrice(city.price)}`">
|
v-if="idx > 0"
|
||||||
<span class="city-name">{{ city.regionName }}</span>
|
class="city-price-sep"
|
||||||
<span class="city-price-value">({{ formatPrice(city.price) }})</span>
|
aria-hidden="true"
|
||||||
</span>
|
>, </span>
|
||||||
|
<span
|
||||||
|
:class="['city-price', getCityPriceClass(city.branchType)]"
|
||||||
|
:title="`${city.regionName}: ${formatPrice(city.price)}`"
|
||||||
|
>
|
||||||
|
<span class="city-name">{{ city.regionName }}</span>
|
||||||
|
<span class="city-price-value">({{ formatPrice(city.price) }})</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="no-better-prices">—</span>
|
<span v-else class="no-better-prices">—</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -188,7 +196,14 @@
|
|||||||
.price-cities {
|
.price-cities {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.3em;
|
align-items: baseline;
|
||||||
|
gap: 0.15em 0.35em;
|
||||||
|
line-height: 1.35;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.city-price-sep {
|
||||||
|
color: #666;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.city-price {
|
.city-price {
|
||||||
padding: 0.2em 0.4em;
|
padding: 0.2em 0.4em;
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
<input type="checkbox" v-model="localRoom.isPublic" />
|
<input type="checkbox" v-model="localRoom.isPublic" />
|
||||||
{{ $t('admin.chatrooms.isPublic') }}
|
{{ $t('admin.chatrooms.isPublic') }}
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="localRoom.isAdultOnly" />
|
||||||
|
{{ $t('admin.chatrooms.isAdultOnly') }}
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" v-model="showGenderRestriction" />
|
<input type="checkbox" v-model="showGenderRestriction" />
|
||||||
{{ $t('admin.chatrooms.genderRestriction.show') }}
|
{{ $t('admin.chatrooms.genderRestriction.show') }}
|
||||||
@@ -84,7 +88,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
dialog: null,
|
dialog: null,
|
||||||
localRoom: this.room ? { ...this.room } : { title: '', isPublic: true },
|
localRoom: this.room ? { ...this.room } : { title: '', isPublic: true, isAdultOnly: false },
|
||||||
roomTypes: [],
|
roomTypes: [],
|
||||||
genderRestrictions: [],
|
genderRestrictions: [],
|
||||||
userRights: [],
|
userRights: [],
|
||||||
@@ -102,7 +106,7 @@ export default {
|
|||||||
watch: {
|
watch: {
|
||||||
room: {
|
room: {
|
||||||
handler(newVal) {
|
handler(newVal) {
|
||||||
this.localRoom = newVal ? { ...newVal } : { title: '', isPublic: true };
|
this.localRoom = newVal ? { ...newVal } : { title: '', isPublic: true, isAdultOnly: false };
|
||||||
this.showGenderRestriction = !!(newVal && newVal.genderRestrictionId);
|
this.showGenderRestriction = !!(newVal && newVal.genderRestrictionId);
|
||||||
this.showMinAge = !!(newVal && newVal.minAge);
|
this.showMinAge = !!(newVal && newVal.minAge);
|
||||||
this.showMaxAge = !!(newVal && newVal.maxAge);
|
this.showMaxAge = !!(newVal && newVal.maxAge);
|
||||||
@@ -137,7 +141,7 @@ export default {
|
|||||||
this.fetchGenderRestrictions(),
|
this.fetchGenderRestrictions(),
|
||||||
this.fetchUserRights()
|
this.fetchUserRights()
|
||||||
]);
|
]);
|
||||||
this.localRoom = roomData ? { ...roomData } : { title: '', isPublic: true };
|
this.localRoom = roomData ? { ...roomData } : { title: '', isPublic: true, isAdultOnly: false };
|
||||||
this.dialog.open();
|
this.dialog.open();
|
||||||
},
|
},
|
||||||
closeDialog() {
|
closeDialog() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<DialogWidget ref="dialog" :title="$t('chat.multichat.title')" :modal="false" :show-close="true"
|
<DialogWidget ref="dialog" :title="dialogTitle" :modal="false" :show-close="true"
|
||||||
@close="onDialogClose" width="75vw" height="75vh" name="MultiChatDialog" icon="multichat24.png">
|
@close="onDialogClose" width="75vw" height="75vh" name="MultiChatDialog" icon="multichat24.png">
|
||||||
<div class="dialog-widget-content">
|
<div class="dialog-widget-content">
|
||||||
<div class="multi-chat-top">
|
<div class="multi-chat-top">
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<select v-model="selectedRoom" class="room-select">
|
<select v-model="selectedRoom" class="room-select">
|
||||||
<option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.title }}</option>
|
<option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.title }}</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="create-room-toggle-btn" type="button" @click="toggleRoomCreatePanel">
|
<button v-if="!adultOnlyMode" class="create-room-toggle-btn" type="button" @click="toggleRoomCreatePanel">
|
||||||
{{ showRoomCreatePanel ? $t('chat.multichat.createRoom.toggleShowChat') : $t('chat.multichat.createRoom.toggleCreateRoom') }}
|
{{ showRoomCreatePanel ? $t('chat.multichat.createRoom.toggleShowChat') : $t('chat.multichat.createRoom.toggleCreateRoom') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,6 +246,9 @@ export default {
|
|||||||
components: { DialogWidget },
|
components: { DialogWidget },
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(['user', 'menu']),
|
...mapGetters(['user', 'menu']),
|
||||||
|
dialogTitle() {
|
||||||
|
return this.adultOnlyMode ? this.$t('chat.multichat.eroticTitle') : this.$t('chat.multichat.title');
|
||||||
|
},
|
||||||
isAdmin() {
|
isAdmin() {
|
||||||
// Infer admin via presence of administration section in menu (server filters by rights)
|
// Infer admin via presence of administration section in menu (server filters by rights)
|
||||||
try {
|
try {
|
||||||
@@ -318,6 +321,7 @@ export default {
|
|||||||
announcedRoomEnter: false,
|
announcedRoomEnter: false,
|
||||||
showColorPicker: false,
|
showColorPicker: false,
|
||||||
showRoomCreatePanel: false,
|
showRoomCreatePanel: false,
|
||||||
|
adultOnlyMode: false,
|
||||||
selectedColor: '#000000',
|
selectedColor: '#000000',
|
||||||
lastColor: '#000000',
|
lastColor: '#000000',
|
||||||
hexInput: '#000000',
|
hexInput: '#000000',
|
||||||
@@ -690,7 +694,8 @@ export default {
|
|||||||
return map[code] || 'unknown reason';
|
return map[code] || 'unknown reason';
|
||||||
},
|
},
|
||||||
// Wird extern aufgerufen um den Dialog zu öffnen
|
// Wird extern aufgerufen um den Dialog zu öffnen
|
||||||
open(rooms) {
|
open(rooms, options = {}) {
|
||||||
|
this.adultOnlyMode = Boolean(options?.adultOnly);
|
||||||
// Falls externe Räume übergeben wurden, nutzen; sonst vom Server laden
|
// Falls externe Räume übergeben wurden, nutzen; sonst vom Server laden
|
||||||
if (Array.isArray(rooms) && rooms.length) {
|
if (Array.isArray(rooms) && rooms.length) {
|
||||||
this.initializeRooms(rooms);
|
this.initializeRooms(rooms);
|
||||||
@@ -729,7 +734,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async loadRooms() {
|
async loadRooms() {
|
||||||
try {
|
try {
|
||||||
const data = await fetchPublicRooms();
|
const data = await fetchPublicRooms(this.adultOnlyMode ? { adultOnly: true } : {});
|
||||||
this.initializeRooms(Array.isArray(data) ? data : []);
|
this.initializeRooms(Array.isArray(data) ? data : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fehler beim Laden der Räume', e);
|
console.error('Fehler beim Laden der Räume', e);
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ export default {
|
|||||||
return {
|
return {
|
||||||
folderTitle: '',
|
folderTitle: '',
|
||||||
visibilityOptions: [],
|
visibilityOptions: [],
|
||||||
|
allVisibilityOptions: [],
|
||||||
selectedVisibility: [],
|
selectedVisibility: [],
|
||||||
parentFolder: {id: null, name: ''},
|
parentFolder: {id: null, name: ''},
|
||||||
folderId: 0
|
folderId: 0,
|
||||||
|
eroticMode: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -69,6 +71,9 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
open(folder = null) {
|
open(folder = null) {
|
||||||
|
this.visibilityOptions = this.eroticMode
|
||||||
|
? this.allVisibilityOptions.filter(option => option.description !== 'everyone')
|
||||||
|
: [...this.allVisibilityOptions];
|
||||||
if (folder) {
|
if (folder) {
|
||||||
this.folderTitle = folder.name;
|
this.folderTitle = folder.name;
|
||||||
this.selectedVisibility = this.visibilityOptions.filter(option =>
|
this.selectedVisibility = this.visibilityOptions.filter(option =>
|
||||||
@@ -83,7 +88,10 @@ export default {
|
|||||||
async loadVisibilityOptions() {
|
async loadVisibilityOptions() {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/api/socialnetwork/imagevisibilities');
|
const response = await apiClient.get('/api/socialnetwork/imagevisibilities');
|
||||||
this.visibilityOptions = response.data;
|
this.allVisibilityOptions = response.data;
|
||||||
|
this.visibilityOptions = this.eroticMode
|
||||||
|
? response.data.filter(option => option.description !== 'everyone')
|
||||||
|
: [...response.data];
|
||||||
if (this.selectedVisibility.length) {
|
if (this.selectedVisibility.length) {
|
||||||
this.selectedVisibility = this.visibilityOptions.filter(option =>
|
this.selectedVisibility = this.visibilityOptions.filter(option =>
|
||||||
this.selectedVisibility.map(v => v.id).includes(option.id)
|
this.selectedVisibility.map(v => v.id).includes(option.id)
|
||||||
@@ -103,10 +111,11 @@ export default {
|
|||||||
visibilities: this.selectedVisibility.map(item => item.id),
|
visibilities: this.selectedVisibility.map(item => item.id),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
const basePath = this.eroticMode ? '/api/socialnetwork/erotic/folders' : '/api/socialnetwork/folders';
|
||||||
if (this.parentFolder.id) {
|
if (this.parentFolder.id) {
|
||||||
await apiClient.post(`/api/socialnetwork/folders/${this.parentFolder.id}`, payload);
|
await apiClient.post(`${basePath}/${this.parentFolder.id}`, payload);
|
||||||
} else {
|
} else {
|
||||||
await apiClient.post(`/api/socialnetwork/folders/${this.folderId}`, payload);
|
await apiClient.post(`${basePath}/${this.folderId}`, payload);
|
||||||
}
|
}
|
||||||
EventBus.emit('folderCreated');
|
EventBus.emit('folderCreated');
|
||||||
this.closeDialog();
|
this.closeDialog();
|
||||||
|
|||||||
@@ -30,6 +30,86 @@
|
|||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"search": "Suchen"
|
"search": "Suchen"
|
||||||
},
|
},
|
||||||
|
"adultVerification": {
|
||||||
|
"title": "[Admin] - Erotik-Freigaben",
|
||||||
|
"intro": "Volljährige Nutzer können den Erotikbereich beantragen. Hier werden Anfragen geprüft und freigegeben oder abgelehnt.",
|
||||||
|
"username": "Benutzer",
|
||||||
|
"age": "Alter",
|
||||||
|
"statusLabel": "Status",
|
||||||
|
"requestLabel": "Nachweis",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"approve": "Freigeben",
|
||||||
|
"reject": "Ablehnen",
|
||||||
|
"resetPending": "Auf Prüfung setzen",
|
||||||
|
"openDocument": "Dokument ansehen",
|
||||||
|
"empty": "Keine passenden Anfragen gefunden.",
|
||||||
|
"loadError": "Die Freigaben konnten nicht geladen werden.",
|
||||||
|
"updateError": "Der Status konnte nicht geändert werden.",
|
||||||
|
"documentError": "Das Dokument konnte nicht geöffnet werden.",
|
||||||
|
"filters": {
|
||||||
|
"pending": "Offen",
|
||||||
|
"approved": "Freigegeben",
|
||||||
|
"rejected": "Abgelehnt",
|
||||||
|
"all": "Alle"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"none": "Nicht angefragt",
|
||||||
|
"pending": "In Prüfung",
|
||||||
|
"approved": "Freigegeben",
|
||||||
|
"rejected": "Abgelehnt"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"approved": "Freigabe erteilt.",
|
||||||
|
"rejected": "Freigabe abgelehnt.",
|
||||||
|
"pending": "Anfrage wieder auf Prüfung gesetzt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eroticModeration": {
|
||||||
|
"title": "[Admin] - Erotik-Moderation",
|
||||||
|
"intro": "Gemeldete Erotikbilder und -videos können hier geprüft, verborgen, gelöscht oder gegen den Account eskaliert werden.",
|
||||||
|
"empty": "Keine passenden Meldungen gefunden.",
|
||||||
|
"loadError": "Die Meldungen konnten nicht geladen werden.",
|
||||||
|
"actionError": "Die Moderationsaktion konnte nicht ausgeführt werden.",
|
||||||
|
"actionSuccess": "Die Moderationsaktion wurde gespeichert.",
|
||||||
|
"target": "Ziel",
|
||||||
|
"owner": "Besitzer",
|
||||||
|
"reporter": "Meldender",
|
||||||
|
"reason": "Grund",
|
||||||
|
"statusLabel": "Status",
|
||||||
|
"meta": "Zeit / Maßnahme",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"image": "Bild",
|
||||||
|
"video": "Video",
|
||||||
|
"hidden": "Verborgen",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"previewError": "Die Vorschau konnte nicht geladen werden.",
|
||||||
|
"dismiss": "Zurückweisen",
|
||||||
|
"hide": "Verbergen",
|
||||||
|
"restore": "Wieder freigeben",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"blockUploads": "Uploads sperren",
|
||||||
|
"revokeAccess": "Erotikzugang entziehen",
|
||||||
|
"notePrompt": "Notiz zur Moderationsentscheidung",
|
||||||
|
"actionLabels": {
|
||||||
|
"dismiss": "Zurückgewiesen",
|
||||||
|
"hide_content": "Verborgen",
|
||||||
|
"restore_content": "Freigegeben",
|
||||||
|
"delete_content": "Gelöscht",
|
||||||
|
"block_uploads": "Uploads gesperrt",
|
||||||
|
"revoke_access": "Zugang entzogen"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"open": "Offen",
|
||||||
|
"actioned": "Bearbeitet",
|
||||||
|
"dismissed": "Zurückgewiesen",
|
||||||
|
"all": "Alle"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"open": "Offen",
|
||||||
|
"actioned": "Bearbeitet",
|
||||||
|
"dismissed": "Zurückgewiesen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"rights": {
|
"rights": {
|
||||||
"add": "Recht hinzufügen",
|
"add": "Recht hinzufügen",
|
||||||
"select": "Bitte wählen",
|
"select": "Bitte wählen",
|
||||||
@@ -151,6 +231,7 @@
|
|||||||
"edit": "Chatraum bearbeiten",
|
"edit": "Chatraum bearbeiten",
|
||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
"isPublic": "Öffentlich sichtbar",
|
"isPublic": "Öffentlich sichtbar",
|
||||||
|
"isAdultOnly": "Nur Erotikbereich",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"genderRestriction": {
|
"genderRestriction": {
|
||||||
"show": "Geschlechtsbeschränkung aktivieren",
|
"show": "Geschlechtsbeschränkung aktivieren",
|
||||||
@@ -346,4 +427,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"multichat": {
|
"multichat": {
|
||||||
"title": "Multi-Chat",
|
"title": "Multi-Chat",
|
||||||
|
"eroticTitle": "Erotikchat",
|
||||||
"autoscroll": "Automatisch scrollen",
|
"autoscroll": "Automatisch scrollen",
|
||||||
"options": "Optionen",
|
"options": "Optionen",
|
||||||
"send": "Senden",
|
"send": "Senden",
|
||||||
@@ -155,4 +156,4 @@
|
|||||||
"selfstopped": "Du hast das Gespräch verlassen."
|
"selfstopped": "Du hast das Gespräch verlassen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -415,7 +415,8 @@
|
|||||||
"collapse": "Erträge ausblenden",
|
"collapse": "Erträge ausblenden",
|
||||||
"knowledge": "Produktwissen",
|
"knowledge": "Produktwissen",
|
||||||
"profitAbsolute": "Gesamtgewinn",
|
"profitAbsolute": "Gesamtgewinn",
|
||||||
"profitPerMinute": "Gewinn pro Minute"
|
"profitPerMinute": "Gewinn pro Minute",
|
||||||
|
"betterPrices": "Bessere Preise"
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"title": "Lager",
|
"title": "Lager",
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
"userrights": "Benutzerrechte",
|
"userrights": "Benutzerrechte",
|
||||||
"m-users": {
|
"m-users": {
|
||||||
"userlist": "Benutzerliste",
|
"userlist": "Benutzerliste",
|
||||||
|
"adultverification": "Erotik-Freigaben",
|
||||||
|
"eroticmoderation": "Erotik-Moderation",
|
||||||
"userstatistics": "Benutzerstatistiken",
|
"userstatistics": "Benutzerstatistiken",
|
||||||
"userrights": "Benutzerrechte"
|
"userrights": "Benutzerrechte"
|
||||||
},
|
},
|
||||||
@@ -115,4 +117,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,34 @@
|
|||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"showinsearch": "In Usersuchen anzeigen",
|
"showinsearch": "In Usersuchen anzeigen",
|
||||||
"changeaction": "Benutzerdaten ändern",
|
"changeaction": "Benutzerdaten ändern",
|
||||||
"oldpassword": "Altes Passwort (benötigt)"
|
"oldpassword": "Altes Passwort (benötigt)",
|
||||||
|
"adultAccessTitle": "Erotikbereich",
|
||||||
|
"adultAccessIntro": "Der Erotikbereich ist nur für volljährige Nutzer gedacht und wird zusätzlich durch Moderatoren freigeschaltet.",
|
||||||
|
"requestAdultVerification": "Freischaltung anfragen",
|
||||||
|
"requestAdultVerificationSuccess": "Die Freischaltung wurde angefragt.",
|
||||||
|
"requestAdultVerificationError": "Die Freischaltung konnte nicht angefragt werden.",
|
||||||
|
"adultStatus": {
|
||||||
|
"ineligible": {
|
||||||
|
"title": "Nicht verfügbar",
|
||||||
|
"body": "Der Erotikbereich ist nur für volljährige Nutzer sichtbar."
|
||||||
|
},
|
||||||
|
"none": {
|
||||||
|
"title": "Noch nicht freigeschaltet",
|
||||||
|
"body": "Der Bereich ist sichtbar, aber bis zur Prüfung durch einen Moderator gesperrt."
|
||||||
|
},
|
||||||
|
"pending": {
|
||||||
|
"title": "Prüfung läuft",
|
||||||
|
"body": "Deine Anfrage liegt zur Moderationsprüfung vor. Bis zur Freigabe bleibt der Bereich gesperrt."
|
||||||
|
},
|
||||||
|
"approved": {
|
||||||
|
"title": "Freigeschaltet",
|
||||||
|
"body": "Der Erotikbereich ist für deinen Account freigeschaltet."
|
||||||
|
},
|
||||||
|
"rejected": {
|
||||||
|
"title": "Freischaltung abgelehnt",
|
||||||
|
"body": "Die letzte Anfrage wurde nicht freigegeben. Du kannst eine neue Anfrage stellen."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"languageAssistant": {
|
"languageAssistant": {
|
||||||
"eyebrow": "Einstellungen",
|
"eyebrow": "Einstellungen",
|
||||||
|
|||||||
@@ -248,8 +248,72 @@
|
|||||||
"withdrawn": "Du hast Deine Freundschaftsanfrage zurückgezogen.",
|
"withdrawn": "Du hast Deine Freundschaftsanfrage zurückgezogen.",
|
||||||
"denied": "Du hast die Freundschaftsanfrage abgelehnt.",
|
"denied": "Du hast die Freundschaftsanfrage abgelehnt.",
|
||||||
"accepted": "Die Freundschaft wurde geschlossen."
|
"accepted": "Die Freundschaft wurde geschlossen."
|
||||||
}
|
},
|
||||||
,
|
"erotic": {
|
||||||
|
"eyebrow": "Erotik",
|
||||||
|
"accessTitle": "Freischaltung für den Erotikbereich",
|
||||||
|
"accessIntro": "Bilder, Videos und später auch Chatbereiche werden ab 18 angezeigt, aber erst nach Moderationsfreigabe nutzbar.",
|
||||||
|
"lockedShort": "Dieser Bereich wird erst nach Moderatorfreigabe nutzbar.",
|
||||||
|
"requestVerification": "Freischaltung anfragen",
|
||||||
|
"requestSent": "Die Freischaltung wurde angefragt.",
|
||||||
|
"requestError": "Die Freischaltung konnte nicht angefragt werden.",
|
||||||
|
"requestInfoTitle": "Eingereichter Nachweis",
|
||||||
|
"documentLabel": "Nachweisdatei",
|
||||||
|
"noteLabel": "Kurze Notiz für die Moderation",
|
||||||
|
"settingsLink": "Account-Einstellungen öffnen",
|
||||||
|
"picturesTitle": "Erotikbilder",
|
||||||
|
"picturesIntro": "Eigene Inhalte bleiben strikt vom normalen Galeriebereich getrennt. Hier verwaltest du nur Bilder für den freigeschalteten Erotikbereich.",
|
||||||
|
"uploadTitle": "Erotikbild hochladen",
|
||||||
|
"noimages": "In diesem Erotikordner befinden sich zur Zeit keine Bilder.",
|
||||||
|
"videosTitle": "Erotikvideos",
|
||||||
|
"videosIntro": "Eigene Videos werden getrennt vom normalen Social-Bereich verwaltet. Diese erste Version konzentriert sich auf Upload, Liste und Wiedergabe.",
|
||||||
|
"videoUploadTitle": "Erotikvideo hochladen",
|
||||||
|
"videoUploadHint": "Die erste Ausbaustufe speichert Videos direkt und verzichtet noch auf Transcoding oder Streamingprofile.",
|
||||||
|
"videoDescription": "Beschreibung",
|
||||||
|
"videoFile": "Videodatei",
|
||||||
|
"myVideos": "Meine Videos",
|
||||||
|
"noVideos": "Du hast noch keine Erotikvideos hochgeladen.",
|
||||||
|
"reportAction": "Melden",
|
||||||
|
"reportNote": "Kurze Notiz für die Moderation",
|
||||||
|
"submitReport": "Meldung absenden",
|
||||||
|
"reportSubmitted": "Die Meldung wurde aufgenommen.",
|
||||||
|
"reportError": "Die Meldung konnte nicht gespeichert werden.",
|
||||||
|
"moderationHidden": "Von Moderation verborgen",
|
||||||
|
"hiddenByModeration": "Dieser Inhalt wurde vorläufig durch die Moderation verborgen.",
|
||||||
|
"reportReasons": {
|
||||||
|
"suspected_minor": "Verdacht auf Minderjährigkeit",
|
||||||
|
"non_consensual": "Nicht einvernehmlicher Inhalt",
|
||||||
|
"violence": "Gewalt oder Missbrauch",
|
||||||
|
"harassment": "Belästigung oder Druck",
|
||||||
|
"spam": "Spam oder Scam",
|
||||||
|
"other": "Sonstiges"
|
||||||
|
},
|
||||||
|
"intro": "Der Bereich ist freigeschaltet. Die eigentlichen Bilder- und Videomodule folgen im nächsten Schritt.",
|
||||||
|
"enabledTitle": "Zugang freigeschaltet",
|
||||||
|
"enabledBody": "Dein Account ist für den Erotikbereich freigegeben. Hier entsteht jetzt die getrennte Bilder- und Videoansicht.",
|
||||||
|
"roadmapTitle": "Als Nächstes",
|
||||||
|
"roadmapModeration": "getrennte Moderation und Meldewege",
|
||||||
|
"roadmapUpload": "eigene Upload- und Verwaltungsansichten",
|
||||||
|
"roadmapSeparation": "saubere Trennung von normaler Galerie und Erotikbereich",
|
||||||
|
"status": {
|
||||||
|
"none": {
|
||||||
|
"title": "Noch nicht freigeschaltet",
|
||||||
|
"body": "Der Bereich ist sichtbar, bleibt aber bis zur Moderatorfreigabe gesperrt."
|
||||||
|
},
|
||||||
|
"pending": {
|
||||||
|
"title": "Prüfung läuft",
|
||||||
|
"body": "Deine Anfrage liegt zur Moderationsprüfung vor."
|
||||||
|
},
|
||||||
|
"approved": {
|
||||||
|
"title": "Freigeschaltet",
|
||||||
|
"body": "Der Erotikbereich ist für deinen Account bereits freigeschaltet."
|
||||||
|
},
|
||||||
|
"rejected": {
|
||||||
|
"title": "Freischaltung abgelehnt",
|
||||||
|
"body": "Die letzte Anfrage wurde abgelehnt. Du kannst eine neue Anfrage stellen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"title": "Vokabeltrainer",
|
"title": "Vokabeltrainer",
|
||||||
"description": "Lege Sprachen an (oder abonniere sie) und teile sie mit Freunden.",
|
"description": "Lege Sprachen an (oder abonniere sie) und teile sie mit Freunden.",
|
||||||
|
|||||||
@@ -30,6 +30,86 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"search": "Search"
|
"search": "Search"
|
||||||
},
|
},
|
||||||
|
"adultVerification": {
|
||||||
|
"title": "[Admin] - Erotic approvals",
|
||||||
|
"intro": "Adult users can request access to the erotic area. Requests can be reviewed, approved or rejected here.",
|
||||||
|
"username": "User",
|
||||||
|
"age": "Age",
|
||||||
|
"statusLabel": "Status",
|
||||||
|
"requestLabel": "Proof",
|
||||||
|
"actions": "Actions",
|
||||||
|
"approve": "Approve",
|
||||||
|
"reject": "Reject",
|
||||||
|
"resetPending": "Set pending",
|
||||||
|
"openDocument": "Open document",
|
||||||
|
"empty": "No matching requests found.",
|
||||||
|
"loadError": "Could not load approvals.",
|
||||||
|
"updateError": "Could not update the status.",
|
||||||
|
"documentError": "Could not open the document.",
|
||||||
|
"filters": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"approved": "Approved",
|
||||||
|
"rejected": "Rejected",
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"none": "Not requested",
|
||||||
|
"pending": "Pending",
|
||||||
|
"approved": "Approved",
|
||||||
|
"rejected": "Rejected"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"approved": "Approval granted.",
|
||||||
|
"rejected": "Approval rejected.",
|
||||||
|
"pending": "Request set back to pending."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eroticModeration": {
|
||||||
|
"title": "[Admin] - Erotic moderation",
|
||||||
|
"intro": "Reported erotic pictures and videos can be reviewed, hidden, deleted, or escalated against the account here.",
|
||||||
|
"empty": "No matching reports found.",
|
||||||
|
"loadError": "The reports could not be loaded.",
|
||||||
|
"actionError": "The moderation action could not be completed.",
|
||||||
|
"actionSuccess": "The moderation action was saved.",
|
||||||
|
"target": "Target",
|
||||||
|
"owner": "Owner",
|
||||||
|
"reporter": "Reporter",
|
||||||
|
"reason": "Reason",
|
||||||
|
"statusLabel": "Status",
|
||||||
|
"meta": "Time / action",
|
||||||
|
"actions": "Actions",
|
||||||
|
"image": "Image",
|
||||||
|
"video": "Video",
|
||||||
|
"hidden": "Hidden",
|
||||||
|
"preview": "Preview",
|
||||||
|
"previewError": "The preview could not be loaded.",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"hide": "Hide",
|
||||||
|
"restore": "Restore",
|
||||||
|
"delete": "Delete",
|
||||||
|
"blockUploads": "Block uploads",
|
||||||
|
"revokeAccess": "Revoke erotic access",
|
||||||
|
"notePrompt": "Note for this moderation action",
|
||||||
|
"actionLabels": {
|
||||||
|
"dismiss": "Dismissed",
|
||||||
|
"hide_content": "Hidden",
|
||||||
|
"restore_content": "Restored",
|
||||||
|
"delete_content": "Deleted",
|
||||||
|
"block_uploads": "Uploads blocked",
|
||||||
|
"revoke_access": "Access revoked"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"open": "Open",
|
||||||
|
"actioned": "Actioned",
|
||||||
|
"dismissed": "Dismissed",
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"open": "Open",
|
||||||
|
"actioned": "Actioned",
|
||||||
|
"dismissed": "Dismissed"
|
||||||
|
}
|
||||||
|
},
|
||||||
"rights": {
|
"rights": {
|
||||||
"add": "Add right",
|
"add": "Add right",
|
||||||
"select": "Please select",
|
"select": "Please select",
|
||||||
@@ -178,6 +258,7 @@
|
|||||||
"edit": "Edit Chat Room",
|
"edit": "Edit Chat Room",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"isPublic": "Publicly Visible",
|
"isPublic": "Publicly Visible",
|
||||||
|
"isAdultOnly": "Erotic area only",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"genderRestriction": {
|
"genderRestriction": {
|
||||||
"show": "Enable Gender Restriction",
|
"show": "Enable Gender Restriction",
|
||||||
@@ -318,4 +399,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"multichat": {
|
"multichat": {
|
||||||
"title": "Multi Chat",
|
"title": "Multi Chat",
|
||||||
|
"eroticTitle": "Erotic chat",
|
||||||
"autoscroll": "Auto scroll",
|
"autoscroll": "Auto scroll",
|
||||||
"options": "Options",
|
"options": "Options",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
@@ -155,4 +156,4 @@
|
|||||||
"selfstopped": "You left the conversation."
|
"selfstopped": "You left the conversation."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,6 +374,27 @@
|
|||||||
"active": "Active",
|
"active": "Active",
|
||||||
"noProductions": "No running productions."
|
"noProductions": "No running productions."
|
||||||
},
|
},
|
||||||
|
"columns": {
|
||||||
|
"city": "City",
|
||||||
|
"type": "Type"
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"production": "Production",
|
||||||
|
"store": "Sales",
|
||||||
|
"fullstack": "Production with sales"
|
||||||
|
},
|
||||||
|
"revenue": {
|
||||||
|
"title": "Product revenue",
|
||||||
|
"product": "Product",
|
||||||
|
"absolute": "Revenue (absolute)",
|
||||||
|
"perMinute": "Revenue per minute",
|
||||||
|
"expand": "Show revenue",
|
||||||
|
"collapse": "Hide revenue",
|
||||||
|
"knowledge": "Product knowledge",
|
||||||
|
"profitAbsolute": "Total profit",
|
||||||
|
"profitPerMinute": "Profit per minute",
|
||||||
|
"betterPrices": "Better prices elsewhere"
|
||||||
|
},
|
||||||
"vehicles": {
|
"vehicles": {
|
||||||
"cargo_cart": "Cargo cart",
|
"cargo_cart": "Cargo cart",
|
||||||
"ox_cart": "Ox cart",
|
"ox_cart": "Ox cart",
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
"userrights": "User rights",
|
"userrights": "User rights",
|
||||||
"m-users": {
|
"m-users": {
|
||||||
"userlist": "User list",
|
"userlist": "User list",
|
||||||
|
"adultverification": "Erotic approvals",
|
||||||
|
"eroticmoderation": "Erotic moderation",
|
||||||
"userstatistics": "User statistics",
|
"userstatistics": "User statistics",
|
||||||
"userrights": "User rights"
|
"userrights": "User rights"
|
||||||
},
|
},
|
||||||
@@ -114,4 +116,4 @@
|
|||||||
"church": "Church"
|
"church": "Church"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,34 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"showinsearch": "Show in User Search",
|
"showinsearch": "Show in User Search",
|
||||||
"changeaction": "Change User Data",
|
"changeaction": "Change User Data",
|
||||||
"oldpassword": "Old Password (required)"
|
"oldpassword": "Old Password (required)",
|
||||||
|
"adultAccessTitle": "Erotic area",
|
||||||
|
"adultAccessIntro": "The erotic area is intended only for adult users and also requires moderator approval.",
|
||||||
|
"requestAdultVerification": "Request access",
|
||||||
|
"requestAdultVerificationSuccess": "Access request submitted.",
|
||||||
|
"requestAdultVerificationError": "Access request could not be submitted.",
|
||||||
|
"adultStatus": {
|
||||||
|
"ineligible": {
|
||||||
|
"title": "Not available",
|
||||||
|
"body": "The erotic area is only visible to adult users."
|
||||||
|
},
|
||||||
|
"none": {
|
||||||
|
"title": "Not unlocked yet",
|
||||||
|
"body": "The area is visible, but it stays locked until a moderator approves it."
|
||||||
|
},
|
||||||
|
"pending": {
|
||||||
|
"title": "Review pending",
|
||||||
|
"body": "Your request is waiting for moderation review. The area stays locked until then."
|
||||||
|
},
|
||||||
|
"approved": {
|
||||||
|
"title": "Unlocked",
|
||||||
|
"body": "The erotic area is enabled for your account."
|
||||||
|
},
|
||||||
|
"rejected": {
|
||||||
|
"title": "Request denied",
|
||||||
|
"body": "The last request was not approved. You can submit a new request."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"languageAssistant": {
|
"languageAssistant": {
|
||||||
"eyebrow": "Settings",
|
"eyebrow": "Settings",
|
||||||
|
|||||||
@@ -248,8 +248,72 @@
|
|||||||
"withdrawn": "You have withdrawn your friendship request.",
|
"withdrawn": "You have withdrawn your friendship request.",
|
||||||
"denied": "You have denied the friendship request.",
|
"denied": "You have denied the friendship request.",
|
||||||
"accepted": "The friendship has been established."
|
"accepted": "The friendship has been established."
|
||||||
}
|
},
|
||||||
,
|
"erotic": {
|
||||||
|
"eyebrow": "Erotic",
|
||||||
|
"accessTitle": "Unlock the erotic area",
|
||||||
|
"accessIntro": "Pictures, videos and later chat areas are visible from age 18, but only usable after moderator approval.",
|
||||||
|
"lockedShort": "This area becomes usable only after moderator approval.",
|
||||||
|
"requestVerification": "Request access",
|
||||||
|
"requestSent": "The access request has been submitted.",
|
||||||
|
"requestError": "The access request could not be submitted.",
|
||||||
|
"requestInfoTitle": "Submitted proof",
|
||||||
|
"documentLabel": "Verification document",
|
||||||
|
"noteLabel": "Short note for moderation",
|
||||||
|
"settingsLink": "Open account settings",
|
||||||
|
"picturesTitle": "Erotic pictures",
|
||||||
|
"picturesIntro": "Your content stays strictly separate from the normal gallery. Manage only images for the unlocked erotic area here.",
|
||||||
|
"uploadTitle": "Upload erotic picture",
|
||||||
|
"noimages": "There are currently no pictures in this erotic folder.",
|
||||||
|
"videosTitle": "Erotic videos",
|
||||||
|
"videosIntro": "Your own videos are managed separately from the normal social area. This first version focuses on upload, list and playback.",
|
||||||
|
"videoUploadTitle": "Upload erotic video",
|
||||||
|
"videoUploadHint": "The first rollout stores videos directly and intentionally skips transcoding or streaming profiles for now.",
|
||||||
|
"videoDescription": "Description",
|
||||||
|
"videoFile": "Video file",
|
||||||
|
"myVideos": "My videos",
|
||||||
|
"noVideos": "You have not uploaded any erotic videos yet.",
|
||||||
|
"reportAction": "Report",
|
||||||
|
"reportNote": "Short note for moderation",
|
||||||
|
"submitReport": "Submit report",
|
||||||
|
"reportSubmitted": "The report has been submitted.",
|
||||||
|
"reportError": "The report could not be saved.",
|
||||||
|
"moderationHidden": "Hidden by moderation",
|
||||||
|
"hiddenByModeration": "This content has been temporarily hidden by moderation.",
|
||||||
|
"reportReasons": {
|
||||||
|
"suspected_minor": "Suspected minor",
|
||||||
|
"non_consensual": "Non-consensual content",
|
||||||
|
"violence": "Violence or abuse",
|
||||||
|
"harassment": "Harassment or pressure",
|
||||||
|
"spam": "Spam or scam",
|
||||||
|
"other": "Other"
|
||||||
|
},
|
||||||
|
"intro": "The area is unlocked. The actual picture and video modules follow in the next step.",
|
||||||
|
"enabledTitle": "Access unlocked",
|
||||||
|
"enabledBody": "Your account is enabled for the erotic area. The separate picture and video views are built next.",
|
||||||
|
"roadmapTitle": "Next up",
|
||||||
|
"roadmapModeration": "separate moderation and reporting paths",
|
||||||
|
"roadmapUpload": "dedicated upload and management views",
|
||||||
|
"roadmapSeparation": "clear separation from the normal gallery",
|
||||||
|
"status": {
|
||||||
|
"none": {
|
||||||
|
"title": "Not unlocked yet",
|
||||||
|
"body": "The area is visible, but stays locked until moderator approval."
|
||||||
|
},
|
||||||
|
"pending": {
|
||||||
|
"title": "Review pending",
|
||||||
|
"body": "Your request is waiting for moderation review."
|
||||||
|
},
|
||||||
|
"approved": {
|
||||||
|
"title": "Unlocked",
|
||||||
|
"body": "The erotic area is already unlocked for your account."
|
||||||
|
},
|
||||||
|
"rejected": {
|
||||||
|
"title": "Request denied",
|
||||||
|
"body": "The last request was denied. You can submit a new request."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"title": "Vocabulary trainer",
|
"title": "Vocabulary trainer",
|
||||||
"description": "Create languages (or subscribe to them) and share them with friends.",
|
"description": "Create languages (or subscribe to them) and share them with friends.",
|
||||||
|
|||||||
@@ -30,6 +30,86 @@
|
|||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
"search": "Buscar"
|
"search": "Buscar"
|
||||||
},
|
},
|
||||||
|
"adultVerification": {
|
||||||
|
"title": "[Admin] - Aprobaciones eróticas",
|
||||||
|
"intro": "Los usuarios adultos pueden solicitar acceso al área erótica. Aquí se revisan, aprueban o rechazan las solicitudes.",
|
||||||
|
"username": "Usuario",
|
||||||
|
"age": "Edad",
|
||||||
|
"statusLabel": "Estado",
|
||||||
|
"requestLabel": "Prueba",
|
||||||
|
"actions": "Acciones",
|
||||||
|
"approve": "Aprobar",
|
||||||
|
"reject": "Rechazar",
|
||||||
|
"resetPending": "Poner en revisión",
|
||||||
|
"openDocument": "Abrir documento",
|
||||||
|
"empty": "No se han encontrado solicitudes.",
|
||||||
|
"loadError": "No se pudieron cargar las aprobaciones.",
|
||||||
|
"updateError": "No se pudo actualizar el estado.",
|
||||||
|
"documentError": "No se pudo abrir el documento.",
|
||||||
|
"filters": {
|
||||||
|
"pending": "Pendientes",
|
||||||
|
"approved": "Aprobadas",
|
||||||
|
"rejected": "Rechazadas",
|
||||||
|
"all": "Todas"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"none": "No solicitada",
|
||||||
|
"pending": "En revisión",
|
||||||
|
"approved": "Aprobada",
|
||||||
|
"rejected": "Rechazada"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"approved": "Aprobación concedida.",
|
||||||
|
"rejected": "Aprobación rechazada.",
|
||||||
|
"pending": "La solicitud se ha vuelto a poner en revisión."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eroticModeration": {
|
||||||
|
"title": "[Admin] - Moderación erótica",
|
||||||
|
"intro": "Aquí se pueden revisar, ocultar, eliminar o escalar imágenes y vídeos eróticos denunciados.",
|
||||||
|
"empty": "No se encontraron denuncias.",
|
||||||
|
"loadError": "No se pudieron cargar las denuncias.",
|
||||||
|
"actionError": "No se pudo ejecutar la acción de moderación.",
|
||||||
|
"actionSuccess": "La acción de moderación fue guardada.",
|
||||||
|
"target": "Objetivo",
|
||||||
|
"owner": "Propietario",
|
||||||
|
"reporter": "Denunciante",
|
||||||
|
"reason": "Motivo",
|
||||||
|
"statusLabel": "Estado",
|
||||||
|
"meta": "Hora / acción",
|
||||||
|
"actions": "Acciones",
|
||||||
|
"image": "Imagen",
|
||||||
|
"video": "Vídeo",
|
||||||
|
"hidden": "Oculto",
|
||||||
|
"preview": "Vista previa",
|
||||||
|
"previewError": "No se pudo cargar la vista previa.",
|
||||||
|
"dismiss": "Descartar",
|
||||||
|
"hide": "Ocultar",
|
||||||
|
"restore": "Restaurar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"blockUploads": "Bloquear subidas",
|
||||||
|
"revokeAccess": "Retirar acceso erótico",
|
||||||
|
"notePrompt": "Nota para esta decisión de moderación",
|
||||||
|
"actionLabels": {
|
||||||
|
"dismiss": "Descartado",
|
||||||
|
"hide_content": "Oculto",
|
||||||
|
"restore_content": "Restaurado",
|
||||||
|
"delete_content": "Eliminado",
|
||||||
|
"block_uploads": "Subidas bloqueadas",
|
||||||
|
"revoke_access": "Acceso retirado"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"open": "Abierto",
|
||||||
|
"actioned": "Procesado",
|
||||||
|
"dismissed": "Descartado",
|
||||||
|
"all": "Todos"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"open": "Abierto",
|
||||||
|
"actioned": "Procesado",
|
||||||
|
"dismissed": "Descartado"
|
||||||
|
}
|
||||||
|
},
|
||||||
"rights": {
|
"rights": {
|
||||||
"add": "Añadir permiso",
|
"add": "Añadir permiso",
|
||||||
"select": "Por favor, selecciona",
|
"select": "Por favor, selecciona",
|
||||||
@@ -151,6 +231,7 @@
|
|||||||
"edit": "Editar sala de chat",
|
"edit": "Editar sala de chat",
|
||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
"isPublic": "Visible públicamente",
|
"isPublic": "Visible públicamente",
|
||||||
|
"isAdultOnly": "Solo área erótica",
|
||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
"genderRestriction": {
|
"genderRestriction": {
|
||||||
"show": "Activar restricción de género",
|
"show": "Activar restricción de género",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"multichat": {
|
"multichat": {
|
||||||
"title": "Multi-Chat",
|
"title": "Multi-Chat",
|
||||||
|
"eroticTitle": "Chat erótico",
|
||||||
"autoscroll": "Desplazamiento automático",
|
"autoscroll": "Desplazamiento automático",
|
||||||
"options": "Opciones",
|
"options": "Opciones",
|
||||||
"send": "Enviar",
|
"send": "Enviar",
|
||||||
|
|||||||
@@ -400,7 +400,8 @@
|
|||||||
"collapse": "Ocultar ingresos",
|
"collapse": "Ocultar ingresos",
|
||||||
"knowledge": "Conocimiento del producto",
|
"knowledge": "Conocimiento del producto",
|
||||||
"profitAbsolute": "Beneficio total",
|
"profitAbsolute": "Beneficio total",
|
||||||
"profitPerMinute": "Beneficio por minuto"
|
"profitPerMinute": "Beneficio por minuto",
|
||||||
|
"betterPrices": "Mejores precios"
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"title": "Almacén",
|
"title": "Almacén",
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
"userrights": "Permisos de usuario",
|
"userrights": "Permisos de usuario",
|
||||||
"m-users": {
|
"m-users": {
|
||||||
"userlist": "Lista de usuarios",
|
"userlist": "Lista de usuarios",
|
||||||
|
"adultverification": "Aprobaciones eróticas",
|
||||||
|
"eroticmoderation": "Moderación erótica",
|
||||||
"userstatistics": "Estadísticas de usuarios",
|
"userstatistics": "Estadísticas de usuarios",
|
||||||
"userrights": "Permisos de usuario"
|
"userrights": "Permisos de usuario"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -148,7 +148,34 @@
|
|||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"showinsearch": "Mostrar en búsquedas de usuarios",
|
"showinsearch": "Mostrar en búsquedas de usuarios",
|
||||||
"changeaction": "Actualizar datos de usuario",
|
"changeaction": "Actualizar datos de usuario",
|
||||||
"oldpassword": "Contraseña anterior (obligatoria)"
|
"oldpassword": "Contraseña anterior (obligatoria)",
|
||||||
|
"adultAccessTitle": "Área erótica",
|
||||||
|
"adultAccessIntro": "El área erótica está destinada solo a usuarios adultos y además requiere aprobación de moderación.",
|
||||||
|
"requestAdultVerification": "Solicitar acceso",
|
||||||
|
"requestAdultVerificationSuccess": "La solicitud de acceso se ha enviado.",
|
||||||
|
"requestAdultVerificationError": "No se pudo enviar la solicitud de acceso.",
|
||||||
|
"adultStatus": {
|
||||||
|
"ineligible": {
|
||||||
|
"title": "No disponible",
|
||||||
|
"body": "El área erótica solo es visible para usuarios adultos."
|
||||||
|
},
|
||||||
|
"none": {
|
||||||
|
"title": "Aún no desbloqueado",
|
||||||
|
"body": "El área es visible, pero seguirá bloqueada hasta que un moderador la apruebe."
|
||||||
|
},
|
||||||
|
"pending": {
|
||||||
|
"title": "Revisión pendiente",
|
||||||
|
"body": "Tu solicitud está pendiente de revisión. El área seguirá bloqueada hasta entonces."
|
||||||
|
},
|
||||||
|
"approved": {
|
||||||
|
"title": "Desbloqueado",
|
||||||
|
"body": "El área erótica está habilitada para tu cuenta."
|
||||||
|
},
|
||||||
|
"rejected": {
|
||||||
|
"title": "Solicitud rechazada",
|
||||||
|
"body": "La última solicitud no fue aprobada. Puedes enviar una nueva."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"languageAssistant": {
|
"languageAssistant": {
|
||||||
"eyebrow": "Ajustes",
|
"eyebrow": "Ajustes",
|
||||||
|
|||||||
@@ -249,6 +249,71 @@
|
|||||||
"denied": "Has rechazado la solicitud de amistad.",
|
"denied": "Has rechazado la solicitud de amistad.",
|
||||||
"accepted": "Se ha aceptado la amistad."
|
"accepted": "Se ha aceptado la amistad."
|
||||||
},
|
},
|
||||||
|
"erotic": {
|
||||||
|
"eyebrow": "Erótico",
|
||||||
|
"accessTitle": "Desbloqueo del área erótica",
|
||||||
|
"accessIntro": "Las imágenes, los vídeos y más adelante los chats se muestran a partir de los 18 años, pero solo se pueden usar tras la aprobación de moderación.",
|
||||||
|
"lockedShort": "Esta área solo estará disponible tras la aprobación de moderación.",
|
||||||
|
"requestVerification": "Solicitar acceso",
|
||||||
|
"requestSent": "La solicitud de acceso se ha enviado.",
|
||||||
|
"requestError": "No se pudo enviar la solicitud de acceso.",
|
||||||
|
"requestInfoTitle": "Prueba enviada",
|
||||||
|
"documentLabel": "Documento de verificación",
|
||||||
|
"noteLabel": "Breve nota para moderación",
|
||||||
|
"settingsLink": "Abrir ajustes de la cuenta",
|
||||||
|
"picturesTitle": "Imágenes eróticas",
|
||||||
|
"picturesIntro": "Tus contenidos permanecen estrictamente separados de la galería normal. Aquí gestionas solo imágenes del área erótica desbloqueada.",
|
||||||
|
"uploadTitle": "Subir imagen erótica",
|
||||||
|
"noimages": "Actualmente no hay imágenes en esta carpeta erótica.",
|
||||||
|
"videosTitle": "Vídeos eróticos",
|
||||||
|
"videosIntro": "Tus propios vídeos se gestionan por separado del área social normal. Esta primera versión se centra en subida, lista y reproducción.",
|
||||||
|
"videoUploadTitle": "Subir vídeo erótico",
|
||||||
|
"videoUploadHint": "La primera fase guarda los vídeos directamente y por ahora evita transcodificación o perfiles de streaming.",
|
||||||
|
"videoDescription": "Descripción",
|
||||||
|
"videoFile": "Archivo de vídeo",
|
||||||
|
"myVideos": "Mis vídeos",
|
||||||
|
"noVideos": "Todavía no has subido vídeos eróticos.",
|
||||||
|
"reportAction": "Denunciar",
|
||||||
|
"reportNote": "Nota breve para moderación",
|
||||||
|
"submitReport": "Enviar denuncia",
|
||||||
|
"reportSubmitted": "La denuncia fue enviada.",
|
||||||
|
"reportError": "No se pudo guardar la denuncia.",
|
||||||
|
"moderationHidden": "Oculto por moderación",
|
||||||
|
"hiddenByModeration": "Este contenido fue ocultado temporalmente por la moderación.",
|
||||||
|
"reportReasons": {
|
||||||
|
"suspected_minor": "Sospecha de minoría de edad",
|
||||||
|
"non_consensual": "Contenido no consentido",
|
||||||
|
"violence": "Violencia o abuso",
|
||||||
|
"harassment": "Acoso o presión",
|
||||||
|
"spam": "Spam o estafa",
|
||||||
|
"other": "Otro"
|
||||||
|
},
|
||||||
|
"intro": "El área está desbloqueada. Los módulos reales de imágenes y vídeos llegarán en el siguiente paso.",
|
||||||
|
"enabledTitle": "Acceso desbloqueado",
|
||||||
|
"enabledBody": "Tu cuenta está habilitada para el área erótica. Las vistas separadas de imágenes y vídeos se construirán a continuación.",
|
||||||
|
"roadmapTitle": "Próximamente",
|
||||||
|
"roadmapModeration": "moderación y vías de reporte separadas",
|
||||||
|
"roadmapUpload": "vistas propias para subir y gestionar contenido",
|
||||||
|
"roadmapSeparation": "separación clara de la galería normal",
|
||||||
|
"status": {
|
||||||
|
"none": {
|
||||||
|
"title": "Aún no desbloqueado",
|
||||||
|
"body": "El área es visible, pero seguirá bloqueada hasta la aprobación de moderación."
|
||||||
|
},
|
||||||
|
"pending": {
|
||||||
|
"title": "Revisión pendiente",
|
||||||
|
"body": "Tu solicitud está pendiente de revisión por moderación."
|
||||||
|
},
|
||||||
|
"approved": {
|
||||||
|
"title": "Desbloqueado",
|
||||||
|
"body": "El área erótica ya está desbloqueada para tu cuenta."
|
||||||
|
},
|
||||||
|
"rejected": {
|
||||||
|
"title": "Solicitud rechazada",
|
||||||
|
"body": "La última solicitud fue rechazada. Puedes enviar una nueva."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"title": "Entrenador de vocabulario",
|
"title": "Entrenador de vocabulario",
|
||||||
"description": "Crea idiomas (o suscríbete) y compártelos con tus amigos.",
|
"description": "Crea idiomas (o suscríbete) y compártelos con tus amigos.",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const AdminFalukantCreateNPCView = () => import('../views/admin/falukant/CreateN
|
|||||||
const AdminMinigamesView = () => import('../views/admin/MinigamesView.vue');
|
const AdminMinigamesView = () => import('../views/admin/MinigamesView.vue');
|
||||||
const AdminTaxiToolsView = () => import('../views/admin/TaxiToolsView.vue');
|
const AdminTaxiToolsView = () => import('../views/admin/TaxiToolsView.vue');
|
||||||
const AdminUsersView = () => import('../views/admin/UsersView.vue');
|
const AdminUsersView = () => import('../views/admin/UsersView.vue');
|
||||||
|
const AdminAdultVerificationView = () => import('../views/admin/AdultVerificationView.vue');
|
||||||
|
const AdminEroticModerationView = () => import('../views/admin/EroticModerationView.vue');
|
||||||
const UserStatisticsView = () => import('../views/admin/UserStatisticsView.vue');
|
const UserStatisticsView = () => import('../views/admin/UserStatisticsView.vue');
|
||||||
const ServicesStatusView = () => import('../views/admin/ServicesStatusView.vue');
|
const ServicesStatusView = () => import('../views/admin/ServicesStatusView.vue');
|
||||||
|
|
||||||
@@ -31,6 +33,18 @@ const adminRoutes = [
|
|||||||
component: UserStatisticsView,
|
component: UserStatisticsView,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/users/adult-verification',
|
||||||
|
name: 'AdminAdultVerification',
|
||||||
|
component: AdminAdultVerificationView,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/users/erotic-moderation',
|
||||||
|
name: 'AdminEroticModeration',
|
||||||
|
component: AdminEroticModerationView,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/contacts',
|
path: '/admin/contacts',
|
||||||
name: 'AdminContacts',
|
name: 'AdminContacts',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import minigamesRoutes from './minigamesRoutes';
|
|||||||
import personalRoutes from './personalRoutes';
|
import personalRoutes from './personalRoutes';
|
||||||
import marketingRoutes from './marketingRoutes';
|
import marketingRoutes from './marketingRoutes';
|
||||||
import { applyRouteSeo, buildAbsoluteUrl } from '../utils/seo';
|
import { applyRouteSeo, buildAbsoluteUrl } from '../utils/seo';
|
||||||
|
import apiClient from '../utils/axios';
|
||||||
|
|
||||||
const HomeView = () => import('../views/HomeView.vue');
|
const HomeView = () => import('../views/HomeView.vue');
|
||||||
|
|
||||||
@@ -58,18 +59,30 @@ const router = createRouter({
|
|||||||
routes
|
routes
|
||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
if (to.matched.some(record => record.meta.requiresAuth)) {
|
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||||
if (!store.getters.isLoggedIn) {
|
if (!store.getters.isLoggedIn) {
|
||||||
next('/');
|
return next('/');
|
||||||
} else if (!store.getters.user.active) {
|
} else if (!store.getters.user.active) {
|
||||||
next('/activate');
|
return next('/activate');
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.matched.some(record => record.meta.requiresAdultArea)) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/api/settings/account', { userId: store.getters.user.id });
|
||||||
|
if (!response.data?.isAdult) {
|
||||||
|
return next('/');
|
||||||
|
}
|
||||||
|
if (!response.data?.adultAccessEnabled) {
|
||||||
|
return next('/socialnetwork/erotic/access');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return next('/socialnetwork/erotic/access');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.afterEach((to) => {
|
router.afterEach((to) => {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const VocabChapterView = () => import('../views/social/VocabChapterView.vue');
|
|||||||
const VocabCourseListView = () => import('../views/social/VocabCourseListView.vue');
|
const VocabCourseListView = () => import('../views/social/VocabCourseListView.vue');
|
||||||
const VocabCourseView = () => import('../views/social/VocabCourseView.vue');
|
const VocabCourseView = () => import('../views/social/VocabCourseView.vue');
|
||||||
const VocabLessonView = () => import('../views/social/VocabLessonView.vue');
|
const VocabLessonView = () => import('../views/social/VocabLessonView.vue');
|
||||||
|
const EroticAccessView = () => import('../views/social/EroticAccessView.vue');
|
||||||
|
const EroticPicturesView = () => import('../views/social/EroticPicturesView.vue');
|
||||||
|
const EroticVideosView = () => import('../views/social/EroticVideosView.vue');
|
||||||
|
|
||||||
const socialRoutes = [
|
const socialRoutes = [
|
||||||
{
|
{
|
||||||
@@ -39,6 +42,24 @@ const socialRoutes = [
|
|||||||
component: GalleryView,
|
component: GalleryView,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/socialnetwork/erotic/access',
|
||||||
|
name: 'EroticAccess',
|
||||||
|
component: EroticAccessView,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/socialnetwork/erotic/pictures',
|
||||||
|
name: 'EroticPictures',
|
||||||
|
component: EroticPicturesView,
|
||||||
|
meta: { requiresAuth: true, requiresAdultArea: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/socialnetwork/erotic/videos',
|
||||||
|
name: 'EroticVideos',
|
||||||
|
component: EroticVideosView,
|
||||||
|
meta: { requiresAuth: true, requiresAdultArea: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/socialnetwork/forum/:id',
|
path: '/socialnetwork/forum/:id',
|
||||||
name: 'Forum',
|
name: 'Forum',
|
||||||
|
|||||||
258
frontend/src/views/admin/AdultVerificationView.vue
Normal file
258
frontend/src/views/admin/AdultVerificationView.vue
Normal 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>
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<th>{{ $t('admin.chatrooms.roomName') }}</th>
|
<th>{{ $t('admin.chatrooms.roomName') }}</th>
|
||||||
<th>{{ $t('admin.chatrooms.type') }}</th>
|
<th>{{ $t('admin.chatrooms.type') }}</th>
|
||||||
<th>{{ $t('admin.chatrooms.isPublic') }}</th>
|
<th>{{ $t('admin.chatrooms.isPublic') }}</th>
|
||||||
|
<th>{{ $t('admin.chatrooms.isAdultOnly') }}</th>
|
||||||
<th>{{ $t('admin.chatrooms.actions') }}</th>
|
<th>{{ $t('admin.chatrooms.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
<td>{{ room.title }}</td>
|
<td>{{ room.title }}</td>
|
||||||
<td>{{ getRoomTypeLabel(room) }}</td>
|
<td>{{ getRoomTypeLabel(room) }}</td>
|
||||||
<td>{{ room.isPublic ? $t('common.yes') : $t('common.no') }}</td>
|
<td>{{ room.isPublic ? $t('common.yes') : $t('common.no') }}</td>
|
||||||
|
<td>{{ room.isAdultOnly ? $t('common.yes') : $t('common.no') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button @click="editRoom(room)">{{ $t('common.edit') }}</button>
|
<button @click="editRoom(room)">{{ $t('common.edit') }}</button>
|
||||||
<button @click="deleteRoom(room)">{{ $t('common.delete') }}</button>
|
<button @click="deleteRoom(room)">{{ $t('common.delete') }}</button>
|
||||||
|
|||||||
244
frontend/src/views/admin/EroticModerationView.vue
Normal file
244
frontend/src/views/admin/EroticModerationView.vue
Normal 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>
|
||||||
@@ -394,6 +394,7 @@ const CERTIFICATE_PRODUCT_LEVELS = [
|
|||||||
{ level: 5, products: ['horse', 'ox'] },
|
{ 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_BASE = 6.0;
|
||||||
const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0;
|
const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0;
|
||||||
const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035;
|
const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035;
|
||||||
@@ -761,7 +762,7 @@ export default {
|
|||||||
// Fallback auf Standard-Berechnung
|
// Fallback auf Standard-Berechnung
|
||||||
const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0;
|
const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0;
|
||||||
const maxPrice = product.sellCost;
|
const maxPrice = product.sellCost;
|
||||||
const minPrice = maxPrice * 0.6;
|
const minPrice = maxPrice * 0.7;
|
||||||
prices[product.id] = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
|
prices[product.id] = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -845,7 +846,7 @@ export default {
|
|||||||
// Fallback auf Standard-Berechnung
|
// Fallback auf Standard-Berechnung
|
||||||
const knowledgeFactor = product.knowledges[0].knowledge || 0;
|
const knowledgeFactor = product.knowledges[0].knowledge || 0;
|
||||||
const maxPrice = product.sellCost;
|
const maxPrice = product.sellCost;
|
||||||
const minPrice = maxPrice * 0.6;
|
const minPrice = maxPrice * 0.7;
|
||||||
revenuePerUnit = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
|
revenuePerUnit = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,19 @@
|
|||||||
<span>{{ $t("settings.account.showinsearch") }}</span>
|
<span>{{ $t("settings.account.showinsearch") }}</span>
|
||||||
</label>
|
</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">
|
<div class="account-settings__actions">
|
||||||
<button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button>
|
<button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,6 +82,9 @@ export default {
|
|||||||
newpasswordretype: "",
|
newpasswordretype: "",
|
||||||
showInSearch: false,
|
showInSearch: false,
|
||||||
oldpassword: "",
|
oldpassword: "",
|
||||||
|
isAdult: false,
|
||||||
|
adultVerificationStatus: "none",
|
||||||
|
adultVerificationRequest: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -81,6 +97,21 @@ export default {
|
|||||||
},
|
},
|
||||||
passwordsMatch() {
|
passwordsMatch() {
|
||||||
return this.newpassword === this.newpasswordretype;
|
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: {
|
methods: {
|
||||||
@@ -137,13 +168,17 @@ export default {
|
|||||||
console.error('Fehler beim Speichern der Account-Einstellungen:', error);
|
console.error('Fehler beim Speichern der Account-Einstellungen:', error);
|
||||||
showApiError(this, error, 'Ein Fehler ist beim Speichern der Account-Einstellungen aufgetreten.');
|
showApiError(this, error, 'Ein Fehler ist beim Speichern der Account-Einstellungen aufgetreten.');
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
|
const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
|
||||||
this.username = response.data.username;
|
this.username = response.data.username;
|
||||||
this.showInSearch = response.data.showinsearch;
|
this.showInSearch = response.data.showinsearch;
|
||||||
this.email = response.data.email;
|
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
|
// Stelle sicher, dass Passwort-Felder leer sind
|
||||||
this.newpassword = '';
|
this.newpassword = '';
|
||||||
@@ -228,6 +263,29 @@ export default {
|
|||||||
margin-top: 18px;
|
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) {
|
@media (max-width: 760px) {
|
||||||
.account-settings__grid {
|
.account-settings__grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
201
frontend/src/views/social/EroticAccessView.vue
Normal file
201
frontend/src/views/social/EroticAccessView.vue
Normal 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>
|
||||||
116
frontend/src/views/social/EroticMediaPlaceholderView.vue
Normal file
116
frontend/src/views/social/EroticMediaPlaceholderView.vue
Normal 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>
|
||||||
361
frontend/src/views/social/EroticPicturesView.vue
Normal file
361
frontend/src/views/social/EroticPicturesView.vue
Normal 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 ? '▲' : '▼' }}</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>
|
||||||
269
frontend/src/views/social/EroticVideosView.vue
Normal file
269
frontend/src/views/social/EroticVideosView.vue
Normal 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>
|
||||||
@@ -166,6 +166,7 @@ export default {
|
|||||||
Object.assign(this.$root.$refs.createFolderDialog, {
|
Object.assign(this.$root.$refs.createFolderDialog, {
|
||||||
parentFolder: parentFolder,
|
parentFolder: parentFolder,
|
||||||
folderId: 0,
|
folderId: 0,
|
||||||
|
eroticMode: false,
|
||||||
});
|
});
|
||||||
this.$root.$refs.createFolderDialog.open();
|
this.$root.$refs.createFolderDialog.open();
|
||||||
},
|
},
|
||||||
@@ -246,6 +247,7 @@ export default {
|
|||||||
Object.assign(this.$root.$refs.createFolderDialog, {
|
Object.assign(this.$root.$refs.createFolderDialog, {
|
||||||
parentFolder: parentFolder,
|
parentFolder: parentFolder,
|
||||||
folderId: folder.id,
|
folderId: folder.id,
|
||||||
|
eroticMode: false,
|
||||||
});
|
});
|
||||||
this.$root.$refs.createFolderDialog.open(folder);
|
this.$root.$refs.createFolderDialog.open(folder);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user