Verschiebung nach anderem RePo - nun pro Projekt getrennt

This commit is contained in:
2026-04-01 10:41:19 +02:00
commit 7b9eda1d62
1048 changed files with 93351 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
# Datasource
spring.datasource.url=jdbc:mysql://localhost:3306/xxx_sphere?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
spring.datasource.username=${DB_USER:xxx}
spring.datasource.password=${DB_PASSWORD:xxx}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=VARCHAR
# Mail
#spring.mail.host=${MAIL_HOST:localhost}
#spring.mail.port=${MAIL_PORT:25}
#spring.mail.username=${MAIL_USER:}
#spring.mail.password=${MAIL_PASSWORD:}
#spring.mail.properties.mail.smtp.auth=false
#spring.mail.properties.mail.smtp.starttls.enable=false
# Mailpit
spring.mail.host=smtp-relay.brevo.com
spring.mail.port=587
spring.mail.username=a6b17a001@smtp-brevo.com
spring.mail.password=xsmtpsib-77b691d562154574133d12b09d44a06e166d30091aac6642480771a0ae463a79-8yH3jHOd4nMMAwuS
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
# JWT Keystore
jwt.keystore.path=classpath:xxx.jks
jwt.keystore.password=${JWT_KEYSTORE_PASSWORD:XUR!Rv&f$j3UsqD&}
jwt.keystore.alias=xxx
# App
app.base-url=http://localhost:8080
# Theme – alle Farben hier ändern, Email-Style passt sich automatisch an
app.theme.color-bg=#1a1a2e
app.theme.color-card=#16213e
app.theme.color-primary=#e94560
app.theme.color-secondary=#0f3460
app.theme.color-text=#eeeeee
app.theme.color-muted=#888888
app.theme.color-success=#2ecc71
# Logging
logging.level.de.oaa.xxx=DEBUG
# Spring 6.2.3 Bug: NPE in DisconnectedClientHelper bei AsyncRequestTimeoutException (SSE-Reconnect) – harmlos
logging.level.org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver=ERROR
logging.level.org.apache.catalina.core.AsyncContextImpl=ERROR
# Server
server.port=8080
server.servlet.context-path=/
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=5s
# Jackson – Datumsformat als ISO-8601 String statt numerischem Array
spring.jackson.serialization.write-dates-as-timestamps=false
# Multipart upload
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB

View File

@@ -0,0 +1,13 @@
● -- Person zur admin-Tabelle als SUPERADMIN hinzufügen (über E-Mail-Adresse)
INSERT INTO admin (admin_id, user_id, rolle, created_at)
SELECT UUID(), u.user_id, 'SUPERADMIN', NOW()
FROM user u
WHERE u.email = 'email@beispiel.de';
-- Falls der User bereits ein (normaler) Admin ist, Rolle upgraden:
UPDATE admin a
JOIN user u ON a.user_id = u.user_id
SET a.rolle = 'SUPERADMIN'
WHERE u.email = 'email@beispiel.de';
--Einfach email@beispiel.de durch die Ziel-E-Mail ersetzen. Das erste Statement fügt einen neuen Admin-Eintrag ein, das zweite upgraded einen bestehenden. Nur eines von beiden ausführen je nach Fall.

View File

@@ -0,0 +1,588 @@
-- ============================================================
-- Testdaten: Aufgabengruppen (generiert aus DefaultFiller)
-- Toys und *Toy-Join-Tabellen werden ignoriert.
-- UUID-Speicherung: varchar(36) als plain UUID-String
-- Spaltennamen: SpringPhysicalNamingStrategy → snake_case
-- ============================================================
SET NAMES utf8mb4;
-- ── Aufgabengruppen ──────────────────────────────────────────
INSERT IGNORE INTO aufgaben_gruppe (gruppen_id, name, beschreibung, user_id, private_gruppe, bild, von) VALUES
('10000000-0000-0000-0000-000000000001', 'Keuschhaltung weiblich', 'Enthält verschiedene Aufgaben für Keuschhaltung von weiblichen Spielpartnern', NULL, 0, NULL, NULL),
('10000000-0000-0000-0000-000000000002', 'Keuschhaltung männlich', 'Enthält verschiedene Aufgaben für Keuschhaltung von männlichen Spielpartnern', NULL, 0, NULL, NULL),
('10000000-0000-0000-0000-000000000003', 'Plugs', 'Enthält verschiedene Aufgaben für das Tragen von Buttplugs über einen gewissen Zeitraum.', NULL, 0, NULL, NULL),
('10000000-0000-0000-0000-000000000004', 'Knebel', 'Enthält verschiedene Aufgaben für das Tragen von Knebeln über einen gewissen Zeitraum.', NULL, 0, NULL, NULL),
('10000000-0000-0000-0000-000000000005', 'Strafen', 'Enthält verschiedene Bestrafungen', NULL, 0, NULL, NULL),
('10000000-0000-0000-0000-000000000006', 'Aufgaben', 'Enthält verschiedene Sex-Aufgaben.', NULL, 0, NULL, NULL);
-- ── Sperren ──────────────────────────────────────────────────
-- Gruppe: Keuschhaltung weiblich
INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES
('20000000-0000-0000-0000-000000000001', 'Voll-KG',
'{PASSIV} trägt fortan einen Voll-KG, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000001'),
('20000000-0000-0000-0000-000000000002', 'Voll-KG + Vaginaldildo',
'{PASSIV} trägt fortan einen Voll-KG mit Vaginaldildo, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000001'),
('20000000-0000-0000-0000-000000000003', 'Voll-KG + Analdildo',
'{PASSIV} trägt fortan einen Voll-KG mit Analdildo, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000001'),
('20000000-0000-0000-0000-000000000004', 'Voll-KG + Doubleplugged',
'{PASSIV} trägt fortan einen Voll-KG mit Vaginal- und Analdildo, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000001');
-- Gruppe: Keuschhaltung männlich
INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES
('20000000-0000-0000-0000-000000000005', 'Peniskäfig',
'{PASSIV} trägt fortan einen Peniskäfig, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von seinem Peniskäfig zu befreien',
10, 30, '10000000-0000-0000-0000-000000000002'),
('20000000-0000-0000-0000-000000000006', 'Voll-KG',
'{PASSIV} trägt fortan einen Voll-KG, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von seinem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000002'),
('20000000-0000-0000-0000-000000000007', 'Voll-KG + Analdildo',
'{PASSIV} trägt fortan einen Voll-KG mit Analdildo, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von seinem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000002');
-- Gruppe: Plugs
INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES
('20000000-0000-0000-0000-000000000008', 'Plug klein',
'{AKTIV} führt {PASSIV} einen kleinen Buttplug in anal ein, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien',
10, 30, '10000000-0000-0000-0000-000000000003'),
('20000000-0000-0000-0000-000000000009', 'Plug mittel',
'{AKTIV} führt {PASSIV} einen mittelgroßen Buttplug anal ein, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien',
10, 30, '10000000-0000-0000-0000-000000000003'),
('20000000-0000-0000-0000-000000000010', 'Plug groß',
'{AKTIV} führt {PASSIV} einen großen Buttplug anal ein, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien',
10, 30, '10000000-0000-0000-0000-000000000003'),
('20000000-0000-0000-0000-000000000011', 'Elektro-Plug anal',
'{AKTIV} führt {PASSIV} einen Elekro-Plug anal ein, dieser ist bis auf weiteres zu tragen. {AKTIV} darf {PASSIV} leichte Stromstöße verpassen',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien',
10, 30, '10000000-0000-0000-0000-000000000003'),
('20000000-0000-0000-0000-000000000012', 'Elektro-Plug vaginal',
'{AKTIV} führt {PASSIV} einen Elekto-Plug vaginal ein, dieser ist bis auf weiteres zu tragen. {AKTIV} darf {PASSIV} leichte Stromstöße verpassen',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien',
10, 30, '10000000-0000-0000-0000-000000000003');
-- Gruppe: Knebel
INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES
('20000000-0000-0000-0000-000000000013', 'Ballknebel',
'{AKTIV}, lege {PASSIV} einen Ballknebel an, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.',
10, 30, '10000000-0000-0000-0000-000000000004'),
('20000000-0000-0000-0000-000000000014', 'Penisknebel',
'{AKTIV}, lege {PASSIV} einen Dildoknebel an, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.',
10, 30, '10000000-0000-0000-0000-000000000004'),
('20000000-0000-0000-0000-000000000015', 'Aufblasbarer Knebel',
'{AKTIV}, lege {PASSIV} einen aufblasbaren Knebel an und pumpe diesen soweit auf, dass {PASSIV} noch halbwegs gut atmen kann, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.',
5, 15, '10000000-0000-0000-0000-000000000004'),
('20000000-0000-0000-0000-000000000016', 'Isolationsmaske',
'{AKTIV}, lege {PASSIV} eine Isolationsmaske an, diese ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.',
5, 15, '10000000-0000-0000-0000-000000000004');
-- sperre_sperre_fuer (war @CollectionTable name="sperre_sperreFuer" → snake_case)
INSERT IGNORE INTO sperre_sperre_fuer (sperre_id, werkzeug) VALUES
('20000000-0000-0000-0000-000000000001', 'VAGINA'),
('20000000-0000-0000-0000-000000000002', 'VAGINA'),
('20000000-0000-0000-0000-000000000003', 'VAGINA'),
('20000000-0000-0000-0000-000000000003', 'ANUS'),
('20000000-0000-0000-0000-000000000004', 'VAGINA'),
('20000000-0000-0000-0000-000000000004', 'ANUS'),
('20000000-0000-0000-0000-000000000005', 'PENIS'),
('20000000-0000-0000-0000-000000000006', 'PENIS'),
('20000000-0000-0000-0000-000000000007', 'PENIS'),
('20000000-0000-0000-0000-000000000007', 'ANUS'),
('20000000-0000-0000-0000-000000000008', 'ANUS'),
('20000000-0000-0000-0000-000000000009', 'ANUS'),
('20000000-0000-0000-0000-000000000010', 'ANUS'),
('20000000-0000-0000-0000-000000000011', 'ANUS'),
('20000000-0000-0000-0000-000000000012', 'VAGINA'),
('20000000-0000-0000-0000-000000000013', 'MUND'),
('20000000-0000-0000-0000-000000000014', 'MUND'),
('20000000-0000-0000-0000-000000000015', 'MUND'),
('20000000-0000-0000-0000-000000000016', 'MUND');
-- ── Strafen ──────────────────────────────────────────────────
INSERT IGNORE INTO strafe (strafe_id, kurz_text, text, level, sekunden_von, sekunden_bis, gruppe_id) VALUES
('30000000-0000-0000-0000-000000000001', '5 Schläge mit flachen Hand',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der flachen Hand auf das Gesäß.',
1, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000002', '15 Schläge mit flachen Hand',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der flachen Hand auf das Gesäß, {PASSIV} zählt laut mit',
3, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000003', '5 Schläge mit Gerte',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Gerte auf das Gesäß.',
2, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000004', '15 Schläge mit Gerte',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der Gerte auf das Gesäß, {PASSIV} zählt laut mit',
4, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000005', '5 Schläge mit Paddel',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit dem Paddel auf das Gesäß.',
2, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000006', '15 Schläge mit Paddel',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit dem Paddel auf das Gesäß, {PASSIV} zählt laut mit',
4, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000007', '5 Schläge mit Peitsche',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Peitsche auf das Gesäß.',
3, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000008', '15 Schläge mit Peitsche',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der Peitsche auf das Gesäß, {PASSIV} zählt laut mit',
5, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000009', 'Schläge auf Klitoris mit Hand',
'{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Hand auf die Klitoris, {PASSIV} zählt laut mit',
4, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000010', 'Schläge auf Klitoris mit Peitsche',
'{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Peitsche auf die Klitoris, {PASSIV} zählt laut mit',
5, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000011', 'Schläge auf Klitoris mit Paddel',
'{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit dem Paddel auf die Klitoris, {PASSIV} zählt laut mit',
5, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000012', 'Schläge auf Klitoris mit Gerte',
'{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Gerte auf die Klitoris, {PASSIV} zählt laut mit',
5, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000013', '5 Ohrfeigen',
'{PASSIV} stellt sich mit dem Rücken zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Ohrfeigen, {PASSIV} zählt laut mit',
5, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000014', 'Elektroplug anal',
'{AKTIV} führt {PASSIV} anal einen Elektro-Plug ein. {AKTIV} erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an',
5, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000015', 'Elektroplug vaginal',
'{AKTIV} führt {PASSIV} vaginal einen Elektro-Plug ein. {AKTIV} erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an',
5, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000016', 'Pumpplug anal',
'{AKTIV} führt {PASSIV} anal einen Pump-Plug ein. {AKTIV} pumpt ganz langsam auf bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an',
5, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000017', 'Pumpplug vaginal',
'{AKTIV} führt {PASSIV} vaginal einen Pump-Plug ein. {AKTIV} pumpt ganz langsam auf bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an',
5, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000018', 'Facesitting (Vagina)',
'{PASSIV} liegt auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Vaginal und/oder Analbereich verwöhnen',
2, 90, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000019', 'Facesitting gefesselt (Vagina)',
'{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Vaginal und/oder Analbereich verwöhnen',
4, 90, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000020', 'Facesitting (Penis)',
'{PASSIV} liegt auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Penis und/oder Analbereich verwöhnen',
2, 90, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000021', 'Facesitting gefesselt (Penis)',
'{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Penis und/oder Analbereich verwöhnen',
4, 90, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000022', 'Facesitting Doppelpenisknebel',
'{PASSIV} liegt auf dem Rücken, {AKTIV} legt {PASSIV} einen Doppel-Penisknebel an und reitet diesen vaginal oder anal',
3, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000023', 'Facesitting Doppelpenisknebel gefesselt',
'{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} legt {PASSIV} einen Doppel-Penisknebel an und reitet diesen vaginal oder anal',
3, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000024', 'Nippelklemmen',
'{AKTIV} legt {PASSIV} Nippelklemmen an, {AKTIV} zieht an der Kette und erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an',
3, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000025', 'Nippelbehandlung',
'{AKTIV} nimmt die Nippel von {PASSIV} zwischen die Finger und erhöht langsam den Druck bis {PASSIV} ''STOP'' sagt',
2, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000026', 'Hilflos liegen lassen',
'{AKTIV} fesselt, knebelt und verbindet die Augen von {PASSIV}. {AKTIV} lässt {PASSIV} wehrlos liegen, bei Ablauf der Zeit erlöst {AKTIV} {PASSIV} mit einem beherzten Platsch auf den Po',
4, 300, 600, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000027', 'Strapon reiten',
'{PASSIV} liegt auf dem Rücken und trägt dabei einen Umschnalldildo. {AKTIV} reitet den Umschnalldildo von {PASSIV}',
3, 60, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000028', 'Strapon reiten gefesselt',
'{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo. {AKTIV} reitet den Umschnalldildo von {PASSIV}',
4, 60, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000029', 'Teaseblowjob mit dem Strapon',
'{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo, KG und einen großen Buttplug. {AKTIV} gibt dem Umschnalldildo einen Blowjob in 69er Position und präsentiert {PASSIV} dabei den Intimbereich',
5, 180, 300, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000030', 'Teasereiten mit Strapon',
'{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo, KG und einen großen Buttplug. {AKTIV} reitet den Umschnalldildo von {PASSIV}.',
5, 180, 300, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000031', 'Tease mit Selbstbefriedigung (Mann KG)',
'{AKTIV} knebelt und fesselt {PASSIV} an einen Stuhl. {PASSIV} trägt dabei einen KG und einen großen Buttplug. {AKTIV} befriedigt sich dann vor den Augen von {PASSIV} selber',
4, 240, 360, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000032', 'Tease mit Selbstbefriedigung (Frau KG)',
'{AKTIV} knebelt und fesselt {PASSIV} an einen Stuhl. {PASSIV} trägt dabei einen KG und einen großen Buttplug. {AKTIV} befriedigt sich dann vor den Augen von {PASSIV} selber',
4, 240, 360, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000033', 'Blowjob auf allen vieren',
'{AKTIV}, zwinge {PASSIV} vor dir auf die Knie, führe dein Glied (oder Strap on) in den Mund von {PASSIV} ein und zeig mit einem Deepthroat, wer das sagen hat',
5, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000034', 'Oralsex mit kleinem Dildo in der Vagina',
'{PASSIV}, geh auf die Knie und reite vaginal einen kleinen Dildo, befriedige dabei {AKTIV} oral.',
2, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000035', 'Oralsex mit großen Dildo in der Vagina',
'{PASSIV}, geh auf die Knie und reite vaginal einen großen Dildo, befriedige dabei {AKTIV} oral.',
4, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000036', 'Oralsex mit kleinem Dildo im Anus',
'{PASSIV}, geh auf die Knie und reite anal einen kleinen Dildo, befriedige dabei {AKTIV} oral.',
3, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000037', 'Oralsex mit großen Dildo im Anus',
'{PASSIV}, geh auf die Knie und reite anal einen großen Dildo, befriedige dabei {AKTIV} oral.',
4, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000038', 'Vagina dehnen',
'{PASSIV} geht auf alle viere und streckt den Hintern schön in die Luft, {AKTIV} führe langsam nach und nach mehr Finger in die Vagina von {PASSIV} ein, bis {PASSIV} ''STOP'' sagt',
2, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000039', 'Anus dehnen',
'{PASSIV} geht auf alle viere und streckt den Hintern schön in die Luft, {AKTIV} führe langsam nach und nach mehr Finger in die Anus von {PASSIV} ein, bis {PASSIV} ''STOP'' sagt',
2, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000040', 'Vaginalsex in Missionarstellung und Breathplay',
'{AKTIV} dringt in Missionarsstellung in {PASSIV} und gibt vollgas, dabei packt {AKTIV} {PASSIV} am Hals und drückt beherzt zu',
4, 30, 60, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000041', 'Analsex in Missionarstellung und Breathplay',
'{AKTIV} dringt in Missionarsstellung anal in {PASSIV} und gibt vollgas, dabei packt {AKTIV} {PASSIV} am Hals und drückt beherzt zu',
4, 30, 60, '10000000-0000-0000-0000-000000000005');
-- strafe_benoetigt_passiv (war @CollectionTable name="strafe_benoetigtPassiv")
INSERT IGNORE INTO strafe_benoetigt_passiv (strafe_id, werkzeug) VALUES
('30000000-0000-0000-0000-000000000009', 'VAGINA'),
('30000000-0000-0000-0000-000000000010', 'VAGINA'),
('30000000-0000-0000-0000-000000000011', 'VAGINA'),
('30000000-0000-0000-0000-000000000012', 'VAGINA'),
('30000000-0000-0000-0000-000000000014', 'ANUS'),
('30000000-0000-0000-0000-000000000015', 'VAGINA'),
('30000000-0000-0000-0000-000000000016', 'ANUS'),
('30000000-0000-0000-0000-000000000017', 'VAGINA'),
('30000000-0000-0000-0000-000000000018', 'MUND'),
('30000000-0000-0000-0000-000000000019', 'MUND'),
('30000000-0000-0000-0000-000000000020', 'MUND'),
('30000000-0000-0000-0000-000000000021', 'MUND'),
('30000000-0000-0000-0000-000000000022', 'MUND'),
('30000000-0000-0000-0000-000000000023', 'MUND'),
('30000000-0000-0000-0000-000000000033', 'MUND'),
('30000000-0000-0000-0000-000000000034', 'VAGINA'),
('30000000-0000-0000-0000-000000000035', 'VAGINA'),
('30000000-0000-0000-0000-000000000036', 'ANUS'),
('30000000-0000-0000-0000-000000000037', 'ANUS'),
('30000000-0000-0000-0000-000000000038', 'VAGINA'),
('30000000-0000-0000-0000-000000000039', 'ANUS'),
('30000000-0000-0000-0000-000000000040', 'VAGINA'),
('30000000-0000-0000-0000-000000000041', 'ANUS');
-- strafe_benoetigt_aktiv (war @CollectionTable name="strafe_benoetigtAktiv")
INSERT IGNORE INTO strafe_benoetigt_aktiv (strafe_id, werkzeug) VALUES
('30000000-0000-0000-0000-000000000018', 'VAGINA'),
('30000000-0000-0000-0000-000000000018', 'ANUS'),
('30000000-0000-0000-0000-000000000019', 'VAGINA'),
('30000000-0000-0000-0000-000000000019', 'ANUS'),
('30000000-0000-0000-0000-000000000020', 'PENIS'),
('30000000-0000-0000-0000-000000000020', 'ANUS'),
('30000000-0000-0000-0000-000000000021', 'VAGINA'),
('30000000-0000-0000-0000-000000000021', 'PENIS'),
('30000000-0000-0000-0000-000000000022', 'VAGINA'),
('30000000-0000-0000-0000-000000000023', 'VAGINA'),
('30000000-0000-0000-0000-000000000027', 'VAGINA'),
('30000000-0000-0000-0000-000000000027', 'ANUS'),
('30000000-0000-0000-0000-000000000028', 'VAGINA'),
('30000000-0000-0000-0000-000000000028', 'ANUS'),
('30000000-0000-0000-0000-000000000029', 'VAGINA'),
('30000000-0000-0000-0000-000000000030', 'VAGINA'),
('30000000-0000-0000-0000-000000000031', 'VAGINA'),
('30000000-0000-0000-0000-000000000032', 'PENIS'),
('30000000-0000-0000-0000-000000000033', 'PENIS'),
('30000000-0000-0000-0000-000000000033', 'UMSCHNALLDILDO'),
('30000000-0000-0000-0000-000000000034', 'VAGINA'),
('30000000-0000-0000-0000-000000000034', 'PENIS'),
('30000000-0000-0000-0000-000000000035', 'VAGINA'),
('30000000-0000-0000-0000-000000000035', 'PENIS'),
('30000000-0000-0000-0000-000000000036', 'VAGINA'),
('30000000-0000-0000-0000-000000000036', 'PENIS'),
('30000000-0000-0000-0000-000000000037', 'VAGINA'),
('30000000-0000-0000-0000-000000000037', 'PENIS'),
('30000000-0000-0000-0000-000000000040', 'PENIS'),
('30000000-0000-0000-0000-000000000040', 'UMSCHNALLDILDO'),
('30000000-0000-0000-0000-000000000041', 'PENIS'),
('30000000-0000-0000-0000-000000000041', 'UMSCHNALLDILDO');
-- ── Aufgaben ─────────────────────────────────────────────────
INSERT IGNORE INTO aufgabe (aufgabe_id, kurz_text, text, level, sekunden_von, sekunden_bis, gruppe_id) VALUES
('40000000-0000-0000-0000-000000000001', 'Hintern präsentieren',
'{AKTIV}, zeig {PASSIV} deinen Hintern, gib dir selber dabei ein oder zwei Klappse auf den Po',
1, NULL, NULL, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000002', 'Hals küssen',
'{AKTIV}, küsse den Hals von {PASSIV} leidenschaftlich',
1, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000003', 'Bauchnabel küssen',
'{AKTIV}, zeichne mit Küssen den Bauchnabel von {PASSIV} nach',
1, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000004', 'Ohren knabbern',
'{AKTIV}, knabber leidenschaftlich an den Ohrläppchen von {PASSIV}',
1, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000005', 'Berühren ohne anfassen',
'{AKTIV}, berühre den gesamten Körper von {PASSIV} ohne die Hände zu verwenden',
2, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000006', 'Nacken küssen',
'{PASSIV} sitzt vor {AKTIV}, {AKTIV} küsste leidenschaftlich den Nacken von {PASSIV}',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000007', 'Brust küssen',
'{AKTIV}, küsse die Brust von {PASSIV} ohne die Nippel zu berühren',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000008', 'Nippel verwöhnen',
'{AKTIV}, verwöhne die Nippel von {PASSIV} mit Küssen',
2, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000009', 'Hintern küssen',
'{AKTIV}, küsse den Hintern von {PASSIV} ohne den Anus zu berühren',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000010', 'Intimkuss durch Unterwäsche',
'{AKTIV}, küsse den Intimbereich von {PASSIV} durch die Unterwäsche',
2, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000011', 'Brustmassage',
'{AKTIV}, massiere die Brust von {PASSIV} leidenschaftlich',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000012', 'Hinternmassage',
'{AKTIV}, massiere den Hintern von {PASSIV} leidenschaftlich',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000013', 'Rückenmassage',
'{AKTIV}, massiere den Rücken von {PASSIV} leidenschaftlich',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000014', 'Oberschenkelmassage',
'{AKTIV}, massiere die Oberschenkel von {PASSIV} leidenschaftlich',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000015', 'Klitoris mit Vibrator verwöhnen',
'{AKTIV}, verwöhne die Klitoris von {PASSIV} mit einem Vibrator',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000016', 'Cunnilingus und Finger in Vagina',
'{AKTIV}, verwöhne die Klitoris von {PASSIV} mit dem Mund, führe dabei einen bis zwei Finger in die Vagina von {PASSIV} ein',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000017', 'Klitoris mit Fingern verwöhnen und Finger in Vagina',
'{AKTIV}, verwöhne die Klitoris von {PASSIV} mit der Hand, führe dabei einen bis zwei Finger in die Vagina von {PASSIV} ein',
4, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000018', 'Eichel mit Vibrator verwöhnen',
'{AKTIV}, verwöhne die Eichel von {PASSIV} mit einem Vibrator',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000019', 'Felatio',
'{AKTIV}, verwöhne die Eichel von {PASSIV} mit dem Mund',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000020', 'Handjob',
'{AKTIV}, verwöhne die Eichel von {PASSIV} mit der Hand',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000021', 'Facesitting',
'{AKTIV} liegt auf dem Rücken, {PASSIV} sitzt auf seinem Gesicht. {AKTIV}, verwöhne die Vagina von {PASSIV} mit dem Mund',
4, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000022', '69er-Position',
'69er-Zeit: {AKTIV} liegt oben. {PASSIV}, falls du verschlossen bist, ziehe einen Strap on an, damit {AKTIV} auch was zu tun hat.',
4, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000023', 'Kleiner Dildo vaginal',
'{AKTIV}, führe {PASSIV} einen kleinen Dildo vaginal ein und verwöhne {PASSIV} durch langsame Bewegungen mit selbigem',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000024', 'Großer Dildo vaginal',
'{AKTIV}, führe {PASSIV} einen großen Dildo vaginal ein und verwöhne {PASSIV} durch langsame Bewegungen mit selbigem',
4, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000025', 'Großer Dildo vaginal schnell',
'{AKTIV}, führe {PASSIV} einen großen Dildo vaginal ein und bewege selbigen möglichst schnell rein und raus',
5, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000026', 'Missionarstellung langsam',
'{AKTIV} dringt in Missionarstellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen',
3, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000027', 'Missionarstellung schnell',
'{AKTIV} dringt in Missionarstellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen',
4, 30, 90, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000028', 'Missionarstellung Vollgas',
'{AKTIV} dringt in Missionarstellung in {PASSIV} ein und gibt vollgas',
5, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000029', 'Reiterstellung langsam',
'{PASSIV} setzt sich in Reiterstellung auf {AKTIV}. {PASSIV} bestimmt das Tempo',
3, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000030', 'Reiterstellung schnell',
'{PASSIV} setzt sich in Reiterstellung auf {AKTIV}. {PASSIV} versucht das Tempo hoch zu halten',
4, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000031', 'Reiterstellung vollgas',
'{PASSIV} setzt sich in Reiterstellung auf {AKTIV} und gibt vollgas',
5, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000032', 'Doggystyle langsam',
'{AKTIV} dringt in Hundestellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen',
3, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000033', 'Doggystyle schnell',
'{AKTIV} dringt in Hundestellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen',
4, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000034', 'Doggystyle vollgas',
'{AKTIV} dringt in Hundestellung in {PASSIV} ein und gibt vollgas',
5, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000035', 'Doggystyle vollgas keinen Mucks',
'{AKTIV} dringt in Hundestellung in {PASSIV} ein und gibt vollgas. {PASSIV} darf dabei keinen Laut von sich geben.',
5, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000036', 'Doggystyle Tempo bestimmt die ''gefickte'' Person',
'{AKTIV} dringt in Hundestellung in {PASSIV} ein. {AKTIV} hält still und {PASSIV} gibt das Tempo vor',
3, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000037', 'Löffelchen langsam',
'{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen',
3, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000038', 'Löffelchen schnell',
'{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen',
4, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000039', 'Löffelchen vollgas',
'{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und gibt vollgas',
5, 30, 60, '10000000-0000-0000-0000-000000000006');
-- aufgabe_benoetigt_aktiv (war @CollectionTable name="aufgabe_benoetigtAktiv")
INSERT IGNORE INTO aufgabe_benoetigt_aktiv (aufgabe_id, werkzeug) VALUES
('40000000-0000-0000-0000-000000000002', 'MUND'),
('40000000-0000-0000-0000-000000000003', 'MUND'),
('40000000-0000-0000-0000-000000000004', 'MUND'),
('40000000-0000-0000-0000-000000000006', 'MUND'),
('40000000-0000-0000-0000-000000000007', 'MUND'),
('40000000-0000-0000-0000-000000000008', 'MUND'),
('40000000-0000-0000-0000-000000000009', 'MUND'),
('40000000-0000-0000-0000-000000000010', 'MUND'),
('40000000-0000-0000-0000-000000000016', 'MUND'),
('40000000-0000-0000-0000-000000000019', 'MUND'),
('40000000-0000-0000-0000-000000000021', 'MUND'),
('40000000-0000-0000-0000-000000000022', 'VAGINA'),
('40000000-0000-0000-0000-000000000022', 'MUND'),
('40000000-0000-0000-0000-000000000026', 'PENIS'),
('40000000-0000-0000-0000-000000000026', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000027', 'PENIS'),
('40000000-0000-0000-0000-000000000027', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000028', 'PENIS'),
('40000000-0000-0000-0000-000000000028', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000029', 'PENIS'),
('40000000-0000-0000-0000-000000000029', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000030', 'PENIS'),
('40000000-0000-0000-0000-000000000030', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000031', 'PENIS'),
('40000000-0000-0000-0000-000000000031', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000032', 'PENIS'),
('40000000-0000-0000-0000-000000000032', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000033', 'PENIS'),
('40000000-0000-0000-0000-000000000033', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000034', 'PENIS'),
('40000000-0000-0000-0000-000000000034', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000035', 'PENIS'),
('40000000-0000-0000-0000-000000000035', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000036', 'PENIS'),
('40000000-0000-0000-0000-000000000036', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000037', 'PENIS'),
('40000000-0000-0000-0000-000000000037', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000038', 'PENIS'),
('40000000-0000-0000-0000-000000000038', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000039', 'PENIS'),
('40000000-0000-0000-0000-000000000039', 'UMSCHNALLDILDO');
-- aufgabe_benoetigt_passiv (war @CollectionTable name="aufgabe_benoetigtPassiv")
INSERT IGNORE INTO aufgabe_benoetigt_passiv (aufgabe_id, werkzeug) VALUES
('40000000-0000-0000-0000-000000000015', 'VAGINA'),
('40000000-0000-0000-0000-000000000016', 'VAGINA'),
('40000000-0000-0000-0000-000000000017', 'VAGINA'),
('40000000-0000-0000-0000-000000000018', 'PENIS'),
('40000000-0000-0000-0000-000000000019', 'PENIS'),
('40000000-0000-0000-0000-000000000020', 'PENIS'),
('40000000-0000-0000-0000-000000000021', 'VAGINA'),
('40000000-0000-0000-0000-000000000022', 'MUND'),
('40000000-0000-0000-0000-000000000023', 'VAGINA'),
('40000000-0000-0000-0000-000000000024', 'VAGINA'),
('40000000-0000-0000-0000-000000000025', 'VAGINA'),
('40000000-0000-0000-0000-000000000026', 'VAGINA'),
('40000000-0000-0000-0000-000000000027', 'VAGINA'),
('40000000-0000-0000-0000-000000000028', 'VAGINA'),
('40000000-0000-0000-0000-000000000029', 'VAGINA'),
('40000000-0000-0000-0000-000000000030', 'VAGINA'),
('40000000-0000-0000-0000-000000000031', 'VAGINA'),
('40000000-0000-0000-0000-000000000032', 'VAGINA'),
('40000000-0000-0000-0000-000000000033', 'VAGINA'),
('40000000-0000-0000-0000-000000000034', 'VAGINA'),
('40000000-0000-0000-0000-000000000035', 'VAGINA'),
('40000000-0000-0000-0000-000000000036', 'VAGINA'),
('40000000-0000-0000-0000-000000000037', 'VAGINA'),
('40000000-0000-0000-0000-000000000038', 'VAGINA'),
('40000000-0000-0000-0000-000000000039', 'VAGINA');

View File

@@ -0,0 +1,504 @@
-- =============================================================
-- XXX The Game – Testdaten
-- =============================================================
-- Passwort für alle User: Test1234!
-- SHA-256("Test1234!") = 11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a
-- Hinweis: Login erwartet SHA-256-Hash vom Client
-- =============================================================
SET FOREIGN_KEY_CHECKS = 0;
-- Aufräumen (Reihenfolge wegen FK)
DELETE FROM kommentar_like;
DELETE FROM kommentar;
DELETE FROM pinnwand_like;
DELETE FROM pinnwand_eintrag;
DELETE FROM feed_post_vote;
DELETE FROM feed_post_option;
DELETE FROM feed_post_like;
DELETE FROM feed_post;
DELETE FROM umfrage_stimme;
DELETE FROM umfrage_option;
DELETE FROM gruppe_beitrag_like;
DELETE FROM gruppe_beitrag;
DELETE FROM beitrittsanfrage;
DELETE FROM gruppe_mitglied;
DELETE FROM gruppe;
DELETE FROM profile_image_like;
DELETE FROM profile_image;
DELETE FROM friendship;
DELETE FROM registration;
DELETE FROM `user`;
SET FOREIGN_KEY_CHECKS = 1;
-- =============================================================
-- BENUTZER (5 User mit unterschiedlichen Profilen)
-- =============================================================
INSERT INTO `user` (
user_id, name, email, password, geburtsdatum,
groesse, gewicht, geschlecht, neigung, beziehungsstatus, beschreibung,
lockee_xp, keyholder_xp, bdsm_xp,
sichtbarkeit_grunddaten, sichtbarkeit_galerie, sichtbarkeit_freunde,
sichtbarkeit_feed, sichtbarkeit_pinnwand, sichtbarkeit_xp, sichtbarkeit_lockhistorie
) VALUES
-- 1. MaxMuster – dominant, Single
('11111111-1111-1111-1111-000000000001',
'MaxMuster', 'max@test.de',
'11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a',
'1990-05-15',
182, 80, 'MAENNLICH', 'DOMINANT', 'SINGLE',
'Erfahrener Keyholder, der auf striktes aber faires Spiel steht. Immer offen für neue Spielpartner.',
120, 850, 300,
'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE'),
-- 2. LisaLust – devot, Single
('11111111-1111-1111-1111-000000000002',
'LisaLust', 'lisa@test.de',
'11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a',
'1995-08-22',
165, 58, 'WEIBLICH', 'DEVOT', 'SINGLE',
'Neugierigie Lockee auf der Suche nach einem verlässlichen Keyholder. Mag lange Sperren und herausfordernde Aufgaben.',
740, 0, 150,
'ALLE', 'NUR_FREUNDE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'NUR_FREUNDE'),
-- 3. SamSwitcher – Switcher, in Beziehung
('11111111-1111-1111-1111-000000000003',
'SamSwitcher', 'sam@test.de',
'11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a',
'1988-11-03',
175, 70, 'DIVERS', 'SWITCHER', 'IN_EINER_BEZIEHUNG',
'Mal oben, mal unten – kommt auf die Stimmung an. Spiele gerne mit meinem Partner zusammen.',
430, 390, 600,
'ALLE', 'ALLE', 'ALLE', 'NUR_FREUNDE', 'ALLE', 'NUR_FREUNDE', 'ALLE'),
-- 4. KajaKette – eher devot, Single
('11111111-1111-1111-1111-000000000004',
'KajaKette', 'kaja@test.de',
'11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a',
'1998-02-14',
170, 62, 'WEIBLICH', 'EHER_DEVOT', 'SINGLE',
'Chastity-Enthusiastin mit Fokus auf Community-Locks. Schreibe gerne auf Pinnwände!',
920, 50, 80,
'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE'),
-- 5. TomTop – eher dominant, verheiratet
('11111111-1111-1111-1111-000000000005',
'TomTop', 'tom@test.de',
'11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a',
'1985-07-30',
178, 85, 'MAENNLICH', 'EHER_DOMINANT', 'VERHEIRATET',
'Verheiratet, spielen als Paar. Biete Keyholder-Service für seriöse Anfragen.',
200, 560, 410,
'ALLE', 'NUR_FREUNDE', 'NUR_FREUNDE', 'NUR_FREUNDE', 'ALLE', 'ALLE', 'NUR_FREUNDE');
-- =============================================================
-- NICHT AKTIVIERTE REGISTRIERUNG (für Registrierungs-Tests)
-- =============================================================
INSERT INTO registration (
registration_id, name, email, password, activated, activation_code, geburtsdatum
) VALUES
('99999999-9999-9999-9999-000000000001',
'NeuerUser', 'neu@test.de',
'11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a',
FALSE, '347821', '2000-01-01');
-- =============================================================
-- FREUNDSCHAFTEN
-- =============================================================
INSERT INTO friendship (friendship_id, sender_id, receiver_id, status, created_at) VALUES
-- Max ↔ Lisa (akzeptiert)
('22222222-2222-2222-2222-000000000001',
'11111111-1111-1111-1111-000000000001',
'11111111-1111-1111-1111-000000000002',
'ACCEPTED', '2025-11-01 10:00:00'),
-- Max ↔ Sam (akzeptiert)
('22222222-2222-2222-2222-000000000002',
'11111111-1111-1111-1111-000000000001',
'11111111-1111-1111-1111-000000000003',
'ACCEPTED', '2025-11-15 14:30:00'),
-- Lisa ↔ Kaja (akzeptiert)
('22222222-2222-2222-2222-000000000003',
'11111111-1111-1111-1111-000000000002',
'11111111-1111-1111-1111-000000000004',
'ACCEPTED', '2025-12-03 09:15:00'),
-- Tom → Kaja (ausstehend)
('22222222-2222-2222-2222-000000000004',
'11111111-1111-1111-1111-000000000005',
'11111111-1111-1111-1111-000000000004',
'PENDING', '2026-01-10 18:45:00'),
-- Sam ↔ Kaja (akzeptiert)
('22222222-2222-2222-2222-000000000005',
'11111111-1111-1111-1111-000000000003',
'11111111-1111-1111-1111-000000000004',
'ACCEPTED', '2026-01-20 11:00:00');
-- =============================================================
-- PINNWAND-EINTRÄGE
-- =============================================================
INSERT INTO pinnwand_eintrag (eintrag_id, profil_user_id, author_id, text, created_at) VALUES
-- Auf Lisas Pinnwand
('33333333-3333-3333-3333-000000000001',
'11111111-1111-1111-1111-000000000002',
'11111111-1111-1111-1111-000000000001',
'Hey Lisa! Schön, dich hier zu sehen. Viel Spaß beim Spielen 🔒',
'2025-12-10 16:00:00'),
('33333333-3333-3333-3333-000000000002',
'11111111-1111-1111-1111-000000000002',
'11111111-1111-1111-1111-000000000004',
'Wir sollten mal ein gemeinsames Lock starten! Meld dich 😊',
'2026-01-05 12:30:00'),
-- Auf Maxs Pinnwand
('33333333-3333-3333-3333-000000000003',
'11111111-1111-1111-1111-000000000001',
'11111111-1111-1111-1111-000000000002',
'Danke für den tollen Keyholder-Service letzte Woche!',
'2026-01-08 20:00:00'),
-- Auf Kajas Pinnwand
('33333333-3333-3333-3333-000000000004',
'11111111-1111-1111-1111-000000000004',
'11111111-1111-1111-1111-000000000003',
'Kaja, du bist die Community-Queen! Immer so aktiv hier.',
'2026-02-14 09:00:00');
-- Pinnwand-Likes
INSERT INTO pinnwand_like (like_id, eintrag_id, user_id, liked_at) VALUES
('33333333-3333-3333-3333-000000000101',
'33333333-3333-3333-3333-000000000001',
'11111111-1111-1111-1111-000000000002',
'2025-12-10 16:05:00'),
('33333333-3333-3333-3333-000000000102',
'33333333-3333-3333-3333-000000000002',
'11111111-1111-1111-1111-000000000001',
'2026-01-05 13:00:00'),
('33333333-3333-3333-3333-000000000103',
'33333333-3333-3333-3333-000000000003',
'11111111-1111-1111-1111-000000000004',
'2026-01-09 10:00:00');
-- =============================================================
-- KOMMENTARE
-- =============================================================
INSERT INTO kommentar (kommentar_id, author_id, target_type, target_id, text, created_at) VALUES
-- Kommentar auf Pinnwand-Eintrag
('44444444-4444-4444-4444-000000000001',
'11111111-1111-1111-1111-000000000002',
'PINNWAND',
'33333333-3333-3333-3333-000000000001',
'Danke Max! Ich freu mich auch 😊',
'2025-12-10 17:00:00'),
('44444444-4444-4444-4444-000000000002',
'11111111-1111-1111-1111-000000000003',
'PINNWAND',
'33333333-3333-3333-3333-000000000001',
'+1, willkommen in der Community!',
'2025-12-10 18:30:00'),
-- Reply auf Kommentar
('44444444-4444-4444-4444-000000000003',
'11111111-1111-1111-1111-000000000001',
'KOMMENTAR',
'44444444-4444-4444-4444-000000000001',
'Na logo! Wir machen das 😄',
'2025-12-10 17:15:00');
-- Kommentar-Likes
INSERT INTO kommentar_like (like_id, kommentar_id, user_id, liked_at) VALUES
('44444444-4444-4444-4444-000000000101',
'44444444-4444-4444-4444-000000000001',
'11111111-1111-1111-1111-000000000001',
'2025-12-10 17:10:00'),
('44444444-4444-4444-4444-000000000102',
'44444444-4444-4444-4444-000000000002',
'11111111-1111-1111-1111-000000000002',
'2025-12-10 19:00:00');
-- =============================================================
-- FEED-POSTS (Text + Umfrage)
-- =============================================================
INSERT INTO feed_post (post_id, author_id, text, beitrag_typ, multi_choice, is_public, created_at) VALUES
-- Öffentlicher Text-Post von Max
('55555555-5555-5555-5555-000000000001',
'11111111-1111-1111-1111-000000000001',
'Wer hat Lust auf ein Cardlock-Turnier nächsten Monat? Community vs. Keyholder! 🃏',
'TEXT', NULL, TRUE, '2026-02-01 10:00:00'),
-- Öffentlicher Text-Post von Lisa
('55555555-5555-5555-5555-000000000002',
'11111111-1111-1111-1111-000000000002',
'48 Stunden geschafft! Das war mein bisher längstes Lock. Ich bin so stolz auf mich! 🔐✨',
'TEXT', NULL, TRUE, '2026-02-05 14:30:00'),
-- Öffentliche Umfrage von Kaja (Single-Choice)
('55555555-5555-5555-5555-000000000003',
'11111111-1111-1111-1111-000000000004',
'Was bevorzugt ihr: Cardlock oder Timelock?',
'UMFRAGE', FALSE, TRUE, '2026-02-10 09:00:00'),
-- Öffentliche Umfrage von Sam (Multi-Choice)
('55555555-5555-5555-5555-000000000004',
'11111111-1111-1111-1111-000000000003',
'Welche Features wollt ihr als nächstes sehen? (Mehrfachauswahl möglich)',
'UMFRAGE', TRUE, TRUE, '2026-02-15 20:00:00'),
-- Nicht-öffentlicher Post von Tom
('55555555-5555-5555-5555-000000000005',
'11111111-1111-1111-1111-000000000005',
'Spielen heute Abend mit meiner Frau eine Runde BDSM. Sie darf den Keyholder spielen!',
'TEXT', NULL, FALSE, '2026-02-20 18:00:00');
-- Umfrage-Optionen
INSERT INTO feed_post_option (option_id, post_id, text, reihenfolge) VALUES
-- Kajas Umfrage
('55555555-5555-5555-5555-000000000101', '55555555-5555-5555-5555-000000000003', 'Cardlock – ich liebe die Ungewissheit!', 0),
('55555555-5555-5555-5555-000000000102', '55555555-5555-5555-5555-000000000003', 'Timelock – Struktur ist alles.', 1),
('55555555-5555-5555-5555-000000000103', '55555555-5555-5555-5555-000000000003', 'Beides gleich gerne.', 2),
-- Sams Umfrage
('55555555-5555-5555-5555-000000000104', '55555555-5555-5555-5555-000000000004', 'Mobile App', 0),
('55555555-5555-5555-5555-000000000105', '55555555-5555-5555-5555-000000000004', 'Mehr Aufgaben-Vorlagen', 1),
('55555555-5555-5555-5555-000000000106', '55555555-5555-5555-5555-000000000004', 'Dark/Light Theme Toggle', 2),
('55555555-5555-5555-5555-000000000107', '55555555-5555-5555-5555-000000000004', 'Push-Benachrichtigungen', 3);
-- Umfrage-Stimmen
INSERT INTO feed_post_vote (stimme_id, option_id, post_id, user_id) VALUES
-- Kajas Umfrage
('55555555-5555-5555-5555-000000000201', '55555555-5555-5555-5555-000000000101', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000001'),
('55555555-5555-5555-5555-000000000202', '55555555-5555-5555-5555-000000000101', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000002'),
('55555555-5555-5555-5555-000000000203', '55555555-5555-5555-5555-000000000102', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000005'),
('55555555-5555-5555-5555-000000000204', '55555555-5555-5555-5555-000000000103', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000003'),
-- Sams Umfrage (Multi-Choice)
('55555555-5555-5555-5555-000000000205', '55555555-5555-5555-5555-000000000104', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000001'),
('55555555-5555-5555-5555-000000000206', '55555555-5555-5555-5555-000000000105', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000001'),
('55555555-5555-5555-5555-000000000207', '55555555-5555-5555-5555-000000000104', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000002'),
('55555555-5555-5555-5555-000000000208', '55555555-5555-5555-5555-000000000107', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000002'),
('55555555-5555-5555-5555-000000000209', '55555555-5555-5555-5555-000000000105', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000004');
-- Feed-Likes
INSERT INTO feed_post_like (like_id, post_id, user_id, liked_at) VALUES
('55555555-5555-5555-5555-000000000301', '55555555-5555-5555-5555-000000000001', '11111111-1111-1111-1111-000000000002', '2026-02-01 10:30:00'),
('55555555-5555-5555-5555-000000000302', '55555555-5555-5555-5555-000000000001', '11111111-1111-1111-1111-000000000003', '2026-02-01 11:00:00'),
('55555555-5555-5555-5555-000000000303', '55555555-5555-5555-5555-000000000001', '11111111-1111-1111-1111-000000000004', '2026-02-01 11:15:00'),
('55555555-5555-5555-5555-000000000304', '55555555-5555-5555-5555-000000000002', '11111111-1111-1111-1111-000000000001', '2026-02-05 15:00:00'),
('55555555-5555-5555-5555-000000000305', '55555555-5555-5555-5555-000000000002', '11111111-1111-1111-1111-000000000004', '2026-02-05 15:30:00'),
('55555555-5555-5555-5555-000000000306', '55555555-5555-5555-5555-000000000002', '11111111-1111-1111-1111-000000000003', '2026-02-05 16:00:00');
-- Kommentare unter Feed-Posts
INSERT INTO kommentar (kommentar_id, author_id, target_type, target_id, text, created_at) VALUES
('66666666-6666-6666-6666-000000000001',
'11111111-1111-1111-1111-000000000002',
'FEED_POST',
'55555555-5555-5555-5555-000000000001',
'Bin dabei! Wann genau? 🙋‍♀️',
'2026-02-01 11:00:00'),
('66666666-6666-6666-6666-000000000002',
'11111111-1111-1111-1111-000000000003',
'FEED_POST',
'55555555-5555-5555-5555-000000000001',
'Klingt mega! Ich schlage vor: 1 Woche Mindestlaufzeit.',
'2026-02-01 11:30:00'),
('66666666-6666-6666-6666-000000000003',
'11111111-1111-1111-1111-000000000001',
'FEED_POST',
'55555555-5555-5555-5555-000000000002',
'Respekt! 48h ist eine echte Leistung 👏',
'2026-02-05 15:00:00');
-- =============================================================
-- GRUPPEN
-- =============================================================
INSERT INTO gruppe (gruppe_id, name, beschreibung, bild, is_private, created_at, created_by_user_id) VALUES
-- Öffentliche Gruppe
('77777777-7777-7777-7777-000000000001',
'Cardlock Community',
'Die Gruppe für alle Cardlock-Fans! Hier tauschen wir Erfahrungen aus, veranstalten Turniere und helfen Neulingen beim Einstieg.',
NULL, FALSE, '2025-10-01 12:00:00',
'11111111-1111-1111-1111-000000000001'),
-- Private Gruppe
('77777777-7777-7777-7777-000000000002',
'Keyholder-Stammtisch',
'Privater Austausch unter erfahrenen Keyholdern. Nur auf Einladung.',
NULL, TRUE, '2025-11-15 18:00:00',
'11111111-1111-1111-1111-000000000005'),
-- Öffentliche Gruppe
('77777777-7777-7777-7777-000000000003',
'Anfänger & Fragen',
'Neuling? Frag einfach! Hier ist jede Frage willkommen. Keine Scheu.',
NULL, FALSE, '2026-01-01 00:00:00',
'11111111-1111-1111-1111-000000000004');
-- =============================================================
-- GRUPPENMITGLIEDER
-- =============================================================
INSERT INTO gruppe_mitglied (mitglied_id, gruppe_id, user_id, rolle, joined_at) VALUES
-- Cardlock Community
('77777777-7777-7777-7777-000000000101', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000001', 'ADMIN', '2025-10-01 12:00:00'),
('77777777-7777-7777-7777-000000000102', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000002', 'MITGLIED', '2025-10-05 09:00:00'),
('77777777-7777-7777-7777-000000000103', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000003', 'MITGLIED', '2025-10-10 14:00:00'),
('77777777-7777-7777-7777-000000000104', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000004', 'MITGLIED', '2025-10-20 11:00:00'),
-- Keyholder-Stammtisch
('77777777-7777-7777-7777-000000000105', '77777777-7777-7777-7777-000000000002', '11111111-1111-1111-1111-000000000005', 'ADMIN', '2025-11-15 18:00:00'),
('77777777-7777-7777-7777-000000000106', '77777777-7777-7777-7777-000000000002', '11111111-1111-1111-1111-000000000001', 'MITGLIED', '2025-11-20 10:00:00'),
-- Anfänger & Fragen
('77777777-7777-7777-7777-000000000107', '77777777-7777-7777-7777-000000000003', '11111111-1111-1111-1111-000000000004', 'ADMIN', '2026-01-01 00:00:00'),
('77777777-7777-7777-7777-000000000108', '77777777-7777-7777-7777-000000000003', '11111111-1111-1111-1111-000000000002', 'MITGLIED', '2026-01-03 08:00:00'),
('77777777-7777-7777-7777-000000000109', '77777777-7777-7777-7777-000000000003', '11111111-1111-1111-1111-000000000003', 'MITGLIED', '2026-01-05 12:00:00');
-- Ausstehende Beitrittsanfrage zur privaten Gruppe
INSERT INTO beitrittsanfrage (anfrage_id, gruppe_id, user_id, nachricht, angefragt_at, status) VALUES
('77777777-7777-7777-7777-000000000201',
'77777777-7777-7777-7777-000000000002',
'11111111-1111-1111-1111-000000000003',
'Hallo! Ich bin seit 2 Jahren aktiver Keyholder und würde gerne dazugehören.',
'2026-02-01 15:00:00', 'AUSSTEHEND'),
('77777777-7777-7777-7777-000000000202',
'77777777-7777-7777-7777-000000000002',
'11111111-1111-1111-1111-000000000004',
'Bitte nehmt mich auf! Habe schon ein paar Monate Erfahrung als Keyholderin.',
'2026-02-10 09:00:00', 'ABGELEHNT');
-- =============================================================
-- GRUPPEN-BEITRÄGE (Text + Umfrage)
-- =============================================================
INSERT INTO gruppe_beitrag (beitrag_id, gruppe_id, author_id, beitrag_typ, text, multi_choice, bild, created_at) VALUES
-- Cardlock Community
('88888888-8888-8888-8888-000000000001',
'77777777-7777-7777-7777-000000000001',
'11111111-1111-1111-1111-000000000001',
'TEXT',
'Willkommen in der Cardlock Community! Stellt euch kurz vor und erzählt, wie ihr zum Cardlock gekommen seid.',
NULL, NULL, '2025-10-01 12:05:00'),
('88888888-8888-8888-8888-000000000002',
'77777777-7777-7777-7777-000000000001',
'11111111-1111-1111-1111-000000000002',
'TEXT',
'Ich bin Lisa und liebe Cardlocks seit über einem Jahr! Mein Rekord sind 5 Tage – habt ihr Tipps für längere Sperren?',
NULL, NULL, '2025-10-05 10:00:00'),
('88888888-8888-8888-8888-000000000003',
'77777777-7777-7777-7777-000000000001',
'11111111-1111-1111-1111-000000000004',
'UMFRAGE',
'Wie viele Karten startet ihr typischerweise mit?',
FALSE, NULL, '2025-10-20 14:00:00'),
-- Anfänger & Fragen
('88888888-8888-8888-8888-000000000004',
'77777777-7777-7777-7777-000000000003',
'11111111-1111-1111-1111-000000000002',
'TEXT',
'Frage: Wie erkläre ich Cardlocks am besten meinem Partner, der noch nie davon gehört hat?',
NULL, NULL, '2026-01-10 19:00:00'),
('88888888-8888-8888-8888-000000000005',
'77777777-7777-7777-7777-000000000003',
'11111111-1111-1111-1111-000000000001',
'TEXT',
'Gute Frage! Ich würde empfehlen, erst mit einem kurzen Timelock anzufangen. So kann der Partner das Grundkonzept verstehen, ohne direkt mit der Karten-Mechanik überfordert zu werden.',
NULL, NULL, '2026-01-10 19:30:00');
-- Umfrage-Optionen für Gruppen-Beitrag
INSERT INTO umfrage_option (option_id, beitrag_id, text, reihenfolge) VALUES
('88888888-8888-8888-8888-000000000101', '88888888-8888-8888-8888-000000000003', 'Unter 20 Karten', 0),
('88888888-8888-8888-8888-000000000102', '88888888-8888-8888-8888-000000000003', '20–40 Karten', 1),
('88888888-8888-8888-8888-000000000103', '88888888-8888-8888-8888-000000000003', '40–60 Karten', 2),
('88888888-8888-8888-8888-000000000104', '88888888-8888-8888-8888-000000000003', 'Über 60 Karten', 3);
-- Umfrage-Stimmen (Gruppen)
INSERT INTO umfrage_stimme (stimme_id, option_id, beitrag_id, user_id) VALUES
('88888888-8888-8888-8888-000000000201', '88888888-8888-8888-8888-000000000101', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000002'),
('88888888-8888-8888-8888-000000000202', '88888888-8888-8888-8888-000000000102', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000001'),
('88888888-8888-8888-8888-000000000203', '88888888-8888-8888-8888-000000000102', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000003'),
('88888888-8888-8888-8888-000000000204', '88888888-8888-8888-8888-000000000103', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000004');
-- Gruppen-Beitrag-Likes
INSERT INTO gruppe_beitrag_like (like_id, beitrag_id, user_id, liked_at) VALUES
('88888888-8888-8888-8888-000000000301', '88888888-8888-8888-8888-000000000001', '11111111-1111-1111-1111-000000000002', '2025-10-01 12:10:00'),
('88888888-8888-8888-8888-000000000302', '88888888-8888-8888-8888-000000000001', '11111111-1111-1111-1111-000000000003', '2025-10-01 13:00:00'),
('88888888-8888-8888-8888-000000000303', '88888888-8888-8888-8888-000000000001', '11111111-1111-1111-1111-000000000004', '2025-10-01 14:00:00'),
('88888888-8888-8888-8888-000000000304', '88888888-8888-8888-8888-000000000002', '11111111-1111-1111-1111-000000000001', '2025-10-05 10:30:00'),
('88888888-8888-8888-8888-000000000305', '88888888-8888-8888-8888-000000000002', '11111111-1111-1111-1111-000000000004', '2025-10-05 11:00:00'),
('88888888-8888-8888-8888-000000000306', '88888888-8888-8888-8888-000000000005', '11111111-1111-1111-1111-000000000002', '2026-01-10 19:45:00'),
('88888888-8888-8888-8888-000000000307', '88888888-8888-8888-8888-000000000005', '11111111-1111-1111-1111-000000000004', '2026-01-10 20:00:00');
-- Kommentare auf Gruppen-Beiträge
INSERT INTO kommentar (kommentar_id, author_id, target_type, target_id, text, created_at) VALUES
('99999999-0000-0000-0000-000000000001',
'11111111-1111-1111-1111-000000000003',
'GROUP_POST',
'88888888-8888-8888-8888-000000000002',
'Hi Lisa! Mein Tipp: Fang mit mehr Green Cards an als du denkst. Du wirst es brauchen 😄',
'2025-10-05 11:00:00'),
('99999999-0000-0000-0000-000000000002',
'11111111-1111-1111-1111-000000000001',
'GROUP_POST',
'88888888-8888-8888-8888-000000000002',
'Mentale Vorbereitung ist alles. Schreib dir vorher auf, warum du es tust.',
'2025-10-05 12:00:00'),
('99999999-0000-0000-0000-000000000003',
'11111111-1111-1111-1111-000000000004',
'GROUP_POST',
'88888888-8888-8888-8888-000000000004',
'Ich würde sagen: zeig ihm/ihr einfach die App! Das visuelle Konzept erklärt sich fast von selbst.',
'2026-01-10 19:15:00');
-- =============================================================
-- CHASTITY LOCK (ein aktives Cardlock: Lisa gesperrt von Max)
-- =============================================================
INSERT INTO current_lock (
lock_id, lock_type, name, lockee, keyholder,
test_lock, requires_verification,
unlock_code_length, unlock_code,
start_time, unlock_time,
hygine_opening_duration_minutes, hygine_opening_everyminites,
task_mode,
keyholder_requested_unlock, emergency_auto_unlocked,
-- CARDLOCK-spezifisch
initial_cards, pick_every_minute, accumulate_picks,
show_remaining_cards, open_picks,
available_cards
) VALUES (
'aaaaaaaa-aaaa-aaaa-aaaa-000000000001',
'CARDLOCK',
'Lisas Frühlings-Lock',
'11111111-1111-1111-1111-000000000002', -- lockee: Lisa
'11111111-1111-1111-1111-000000000001', -- keyholder: Max
FALSE, FALSE,
6, NULL,
'2026-03-20 10:00:00', NULL,
30, 1440, -- Hygiene alle 24h, 30 Min offen
'KEYHOLDER',
FALSE, FALSE,
-- 30 Karten: 5×GREEN, 15×RED, 5×YELLOW, 3×TASK, 2×FREEZE
'["GREEN","GREEN","GREEN","GREEN","GREEN","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","YELLOW","YELLOW","YELLOW","YELLOW","YELLOW","TASK","TASK","TASK","FREEZE","FREEZE"]',
240, FALSE, -- Karte alle 4h ziehen, kein Akkumulieren
TRUE, 0,
'["GREEN","GREEN","GREEN","GREEN","GREEN","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","YELLOW","YELLOW","YELLOW","YELLOW","YELLOW","TASK","TASK","TASK","FREEZE","FREEZE"]'
);
-- =============================================================
-- FERTIG
-- =============================================================
-- Überblick:
-- 5 User (max@test.de, lisa@test.de, sam@test.de, kaja@test.de, tom@test.de)
-- 1 nicht aktivierte Registrierung (neu@test.de, Code: 347821)
-- 5 Freundschaften (4 akzeptiert, 1 ausstehend)
-- 4 Pinnwand-Einträge + 3 Likes
-- 3 Kommentare auf Pinnwand + 3 auf Feed + 3 auf Gruppen-Beiträge
-- 5 Feed-Posts (3 Text, 2 Umfragen) + 6 Likes
-- 3 Gruppen (2 öffentlich, 1 privat) mit je 4-6 Mitgliedern
-- 5 Gruppen-Beiträge (4 Text, 1 Umfrage) + 7 Likes
-- 1 aktives Cardlock (Lisa ← Max)
-- =============================================================

View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aktivierung – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="card">
<h1>XXX The Game</h1>
<p class="subtitle">E-Mail-Adresse bestätigen</p>
<p style="font-size:0.9rem; color:#aaa; margin-top:0.5rem; line-height:1.5;">
Du hast eine E-Mail mit einem Aktivierungslink erhalten.<br>
Falls der Link nicht funktioniert, gib hier deinen Aktivierungscode ein.
</p>
<label for="uuid" style="margin-top:1.5rem;">Aktivierungscode</label>
<input type="text" id="uuid" placeholder="6-stelliger Code" autocomplete="off" inputmode="numeric" maxlength="6" />
<button class="full-width" id="activateBtn" onclick="activate()">Jetzt aktivieren</button>
<div class="message" id="message"></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
const email = params.get('email');
if (email) {
showMessage(`Eine Bestätigungs-E-Mail wurde an ${email} gesendet.`, 'success');
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Enter') activate();
});
async function activate() {
const uuid = document.getElementById('uuid').value.trim();
const btn = document.getElementById('activateBtn');
if (!uuid) {
showMessage('Bitte den Aktivierungscode eingeben.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird aktiviert…';
hideMessage();
try {
const response = await fetch(`/activation/${encodeURIComponent(uuid)}`, {
method: 'GET',
redirect: 'follow'
});
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.href = '/login.html';
} else if (response.status === 204) {
showMessage('Code ungültig oder bereits verwendet.', 'error');
btn.disabled = false;
btn.textContent = 'Jetzt aktivieren';
} else {
showMessage(`Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;
btn.textContent = 'Jetzt aktivieren';
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Jetzt aktivieren';
console.error(err);
}
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Abonnements – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.page-hint { font-size:0.85rem; color:var(--color-muted); margin:0.25rem 0 1.5rem; }
.coming-soon {
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:1rem; padding:3rem 1rem; color:var(--color-muted); text-align:center;
}
.coming-soon .icon { font-size:3rem; }
.coming-soon h2 { font-size:1.2rem; font-weight:600; color:var(--color-text); margin:0; }
.coming-soon p { font-size:0.9rem; max-width:360px; margin:0; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin:0 0 0.25rem;">⭐ Abonnements</h1>
<p class="page-hint">Übersicht der verfügbaren Abo-Modelle</p>
<div class="coming-soon">
<span class="icon">🚧</span>
<h2>Demnächst verfügbar</h2>
<p>Hier werden bald die verschiedenen Abo-Modelle beschrieben und abschließbar sein.</p>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Benachrichtigungen – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.notif-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.notif-item {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 0.75rem 1rem;
display: flex;
gap: 0.75rem;
align-items: flex-start;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
text-decoration: none;
color: inherit;
}
.notif-item:hover {
border-color: var(--color-primary);
background: rgba(255,255,255,0.03);
}
.notif-item.unread {
border-left: 3px solid var(--color-primary);
background: rgba(var(--color-primary-rgb, 200,0,0), 0.05);
}
.notif-item.unread:hover {
background: rgba(var(--color-primary-rgb, 200,0,0), 0.09);
}
.notif-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
overflow: hidden;
}
.notif-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.notif-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
margin-top: 0.45rem;
}
.notif-dot.read { background: transparent; }
.notif-body { flex: 1; min-width: 0; }
.notif-text {
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.notif-time {
font-size: 0.75rem;
color: var(--color-muted);
margin-top: 0.2rem;
}
.notif-arrow {
font-size: 0.75rem;
color: var(--color-muted);
flex-shrink: 0;
align-self: center;
}
.notif-empty {
color: var(--color-muted);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="notif-header">
<h1 style="margin:0;">🔔 Benachrichtigungen</h1>
<button id="markAllBtn" onclick="markAllRead()"
style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);
padding:0.35rem 0.85rem;border-radius:7px;cursor:pointer;font-size:0.82rem;width:auto;display:none;">
Alle als gelesen markieren
</button>
</div>
<div class="notif-list" id="notifList">
<p class="notif-empty" id="notifEmpty" style="display:none;">Keine Benachrichtigungen vorhanden.</p>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function fmtRelTime(isoStr) {
const diff = Date.now() - new Date(isoStr).getTime();
const min = Math.floor(diff / 60000);
const h = Math.floor(min / 60);
const d = Math.floor(h / 24);
if (d > 0) return `vor ${d} Tag${d > 1 ? 'en' : ''}`;
if (h > 0) return `vor ${h} Std.`;
if (min > 0) return `vor ${min} Min.`;
return 'gerade eben';
}
function esc(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
async function handleClick(id, targetUrl) {
// Als gelesen markieren
await fetch(`/notifications/${id}/read`, { method: 'POST' }).catch(() => {});
// Dot des angeklickten Items auf "gelesen" setzen
const dot = document.querySelector(`[data-notif-id="${id}"] .notif-dot`);
if (dot) dot.classList.add('read');
const item = document.querySelector(`[data-notif-id="${id}"]`);
if (item) item.classList.remove('unread');
// Badge aktualisieren
const remaining = document.querySelectorAll('.notif-item.unread').length;
['socialNotifBadge','socialMobileNotifBadge'].forEach(bid => {
const el = document.getElementById(bid);
if (!el) return;
el.textContent = remaining;
el.style.display = remaining > 0 ? '' : 'none';
});
if (remaining === 0) document.getElementById('markAllBtn').style.display = 'none';
// Navigieren wenn Zielseite vorhanden
if (targetUrl) window.location.href = targetUrl;
}
async function markAllRead() {
await fetch('/notifications/read-all', { method: 'POST' }).catch(() => {});
document.querySelectorAll('.notif-item.unread').forEach(el => {
el.classList.remove('unread');
const dot = el.querySelector('.notif-dot');
if (dot) dot.classList.add('read');
});
document.getElementById('markAllBtn').style.display = 'none';
['socialNotifBadge','socialMobileNotifBadge'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.textContent = '0'; el.style.display = 'none'; }
});
}
async function loadNotifications() {
const list = document.getElementById('notifList');
const empty = document.getElementById('notifEmpty');
const btn = document.getElementById('markAllBtn');
// Vorhandene Einträge (außer Empty-Hint) entfernen
list.querySelectorAll('.notif-item').forEach(el => el.remove());
try {
const res = await fetch('/notifications');
if (!res.ok) return;
const items = await res.json();
if (items.length === 0) {
empty.style.display = '';
btn.style.display = 'none';
return;
}
empty.style.display = 'none';
const hasUnread = items.some(n => !n.read);
btn.style.display = hasUnread ? '' : 'none';
items.forEach(n => {
const div = document.createElement('div');
div.className = 'notif-item' + (n.read ? '' : ' unread');
div.dataset.notifId = n.id;
div.setAttribute('role', 'button');
div.setAttribute('tabindex', '0');
div.onclick = () => handleClick(n.id, n.targetUrl);
div.onkeydown = e => { if (e.key === 'Enter') handleClick(n.id, n.targetUrl); };
const arrow = n.targetUrl
? `<span class="notif-arrow">›</span>`
: '';
const avatarInner = n.senderAvatar
? `<img src="data:image/png;base64,${n.senderAvatar}" alt="${esc(n.senderName || '')}">`
: `<span>🔔</span>`;
div.innerHTML = `
<div class="notif-avatar">${avatarInner}</div>
<div class="notif-dot${n.read ? ' read' : ''}"></div>
<div class="notif-body">
<div class="notif-text">${esc(n.text)}</div>
<div class="notif-time">${fmtRelTime(n.sentAt)}</div>
</div>
${arrow}`;
list.appendChild(div);
});
} catch(e) {
console.error(e);
}
}
// SSE: Neue Benachrichtigung → sofort neu laden
window.__sseOnNotification = () => loadNotifications();
loadNotifications();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,541 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Feed – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.tabs { display:flex; gap:0; margin-bottom:1.5rem; border-bottom:1px solid var(--color-secondary); }
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.25rem; font-size:0.95rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; transition:border-color 0.15s; }
.post-compose.drag-over { border-color:var(--color-primary); background:rgba(var(--color-primary-rgb,180,0,60),0.06); }
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
.post-compose textarea:focus { border-color:var(--color-primary); }
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; width:18px; height:18px; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
.umfrage-options { margin-top:0.5rem; }
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
.umfrage-option-row input { flex:1; }
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.privacy-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
label.compose-action-btn { display:inline-flex; align-items:center; }
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-meta { font-size:0.75rem; color:var(--color-muted); }
.post-date { font-size:0.75rem; color:var(--color-muted); margin-left:auto; }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
.post-action-btn { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; }
.post-action-btn:hover { color:var(--color-primary); background:none; }
.post-action-btn.active { color:var(--color-primary); }
.post-delete { margin-left:auto; }
.post-delete:hover { color:#c0392b !important; }
/* Carousel – Stile kommen aus shared.js */
.umfrage-option-bar { margin:0.3rem 0; cursor:pointer; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; transition:border-color 0.15s; }
.umfrage-option-bar:hover { border-color:var(--color-primary); }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); transition:width 0.4s; }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
.gruppe-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; color:var(--color-muted); background:var(--color-secondary); border-radius:4px; padding:0.15rem 0.45rem; margin-top:0.1rem; }
.gruppe-badge a { color:inherit; text-decoration:none; }
.gruppe-badge a:hover { color:var(--color-primary); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
.sentinel { height:1px; }
/* Lightbox */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:920px; width:95vw; max-height:90vh; background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; max-height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
/* Comment + Like-Stile kommen aus shared.js */
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="tabs">
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button>
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button>
</div>
<!-- Mein Feed -->
<div class="tab-panel active" id="tab-mine">
<div class="post-compose" id="compose">
<div class="compose-type">
<label><input type="radio" name="beitragTyp" value="TEXT" checked onchange="toggleUmfrage()"> Text</label>
<label><input type="radio" name="beitragTyp" value="UMFRAGE" onchange="toggleUmfrage()"> Umfrage</label>
</div>
<textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
<div class="compose-thumbs" id="composeThumbs"></div>
<div class="umfrage-options" id="umfrageOptions" style="display:none;">
<div id="optionList"></div>
<button onclick="addOption()" style="width:auto; margin:0; padding:0.3rem 0.75rem; font-size:0.8rem; margin-top:0.4rem;">+ Option</button>
</div>
<div class="compose-footer">
<div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;">
<label class="multi-toggle" id="multiChoiceRow" style="display:none;">
<input type="checkbox" id="multiChoice"> Multi-Choice
</label>
<label class="privacy-toggle">
<input type="checkbox" id="isPublic"> Öffentlich
</label>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji einfügen">😊</button>
<label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)">
</label>
<button onclick="submitPost()" style="width:auto; margin:0;">Veröffentlichen</button>
</div>
</div>
</div>
<div id="mineFeed"></div>
<p class="empty-hint" id="mineEmpty" style="display:none;">Noch keine Beiträge. Schreib den ersten!</p>
<div class="sentinel" id="mineSentinel"></div>
</div>
<!-- Öffentlicher Feed -->
<div class="tab-panel" id="tab-public">
<div id="publicFeed"></div>
<p class="empty-hint" id="publicEmpty" style="display:none;">Noch keine öffentlichen Beiträge.</p>
<div class="sentinel" id="publicSentinel"></div>
</div>
</div>
</div>
<!-- Post Lightbox -->
<div class="lightbox" id="postLightbox">
<div class="lb-layout">
<button class="lb-close" onclick="closeLb()">✕</button>
<div class="lb-post-side" id="lbPostBody"></div>
<div class="lb-comments-panel">
<div class="lb-comments-header">Kommentare</div>
<div class="lb-comments-list" id="lbCommentsList"></div>
<div class="lb-comment-compose">
<textarea id="lbCommentInput" placeholder="Kommentar schreiben…" maxlength="500" rows="3"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();postLbComment()}"></textarea>
<div class="lb-comment-compose-actions">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="postLbComment()">Senden</button>
</div>
</div>
</div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script src="/js/meldung.js"></script>
<script>
// ── State ──
let myUserId = null;
let activeLbPostId = null;
let activeLbPostType = null;
const feedState = {
mine: { page:0, hasMore:true, loading:false, loaded:false },
public: { page:0, hasMore:true, loading:false, loaded:false }
};
let composeBilderArr = [];
// ── Boot ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
if (user) {
myUserId = user.userId;
loadFeed('mine');
}
}).catch(() => {});
// ── Tab switching ──
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
localStorage.setItem('tab_feed', name);
if (!feedState[name].loaded) loadFeed(name);
}
const _savedFeedTab = localStorage.getItem('tab_feed');
if (_savedFeedTab) {
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedFeedTab}"]`);
if (_btn) switchTab(_savedFeedTab, _btn);
}
// ── Feed loading ──
async function loadFeed(tab) {
const state = feedState[tab];
if (state.loading || !state.hasMore) return;
state.loading = true;
state.loaded = true;
try {
const endpoint = tab === 'mine' ? '/feed/mine' : '/feed/public';
const res = await fetch(`${endpoint}?page=${state.page}&size=10`);
if (!res.ok) return;
const data = await res.json();
const feedEl = document.getElementById(tab + 'Feed');
if (state.page === 0 && data.posts.length === 0) {
document.getElementById(tab + 'Empty').style.display = '';
}
data.posts.forEach(p => feedEl.insertAdjacentHTML('beforeend', renderPostCard(p, tab)));
state.hasMore = data.hasMore;
state.page++;
} finally {
state.loading = false;
}
}
// ── Infinite Scroll ──
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
if (e.target.id === 'mineSentinel') loadFeed('mine');
if (e.target.id === 'publicSentinel') loadFeed('public');
});
}, { threshold: 0.5 });
observer.observe(document.getElementById('mineSentinel'));
observer.observe(document.getElementById('publicSentinel'));
// bilderCarousel und carNav kommen aus shared.js
// ── Render post card ──
function renderPostCard(p, tab) {
const avatarHtml = p.authorPicture
? `<img src="data:image/png;base64,${p.authorPicture}" alt="">`
: '◉';
const privacyLabel = !p.isPublic ? ' <span style="font-size:0.7rem;color:var(--color-muted);">🔒</span>' : '';
const groupBadge = p.postType === 'GROUP' && p.gruppeId
? `<span class="gruppe-badge" onclick="event.stopPropagation()">👥 <a href="/community/gruppe.html?id=${p.gruppeId}" onclick="event.stopPropagation()">${esc(p.gruppeName)}</a></span>`
: '';
const bildHtml = bilderCarousel(p.bilder, p.postId);
let umfrageHtml = '';
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) {
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
umfrageHtml = '<div style="margin-top:0.5rem;">' + p.optionen.map(o => {
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar${voted ? ' voted' : ''}" onclick="event.stopPropagation(); votePost('${p.postId}','${o.optionId}','${tab}','${p.postType}')">
<div class="umfrage-bar-fill" style="width:${pct}%"></div>
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div>
</div>`;
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`;
}
const canDelete = p.postType === 'FEED' && p.authorId === myUserId;
const deleteBtn = canDelete
? `<button class="post-action-btn post-delete" onclick="event.stopPropagation(); deletePost('${p.postId}')">🗑</button>`
: '';
const meldenBtn = p.authorId !== myUserId
? `<button class="post-action-btn" onclick="event.stopPropagation(); openMeldungDialog('POST','${p.postId}')" title="Melden" style="color:var(--color-muted)">⚑</button>`
: '';
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="openLb('${p.postId}','${p.postType}')" style="cursor:pointer;">
<div class="post-header">
<div class="post-avatar">${avatarHtml}</div>
<div>
<div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a>${privacyLabel}</div>
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
</div>
${deleteBtn}
</div>
<div class="post-text">${esc(p.text)}</div>
${bildHtml}
${umfrageHtml}
<div class="post-actions">
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="lk-${p.postId}" onclick="event.stopPropagation(); likePost('${p.postId}','${p.postType}')">
♥ <span id="lkc-${p.postId}">${p.likeCount}</span>
</button>
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')">
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span>
</button>
${meldenBtn}
</div>
</div>`;
}
// ── Compose ──
function toggleUmfrage() {
const isUmfrage = document.querySelector('input[name="beitragTyp"]:checked').value === 'UMFRAGE';
document.getElementById('umfrageOptions').style.display = isUmfrage ? '' : 'none';
document.getElementById('multiChoiceRow').style.display = isUmfrage ? '' : 'none';
if (isUmfrage && document.getElementById('optionList').children.length === 0) {
addOption(); addOption();
}
}
function addOption() {
const list = document.getElementById('optionList');
const idx = list.children.length;
const row = document.createElement('div');
row.className = 'umfrage-option-row';
row.innerHTML = `<input type="text" placeholder="Option ${idx + 1}" maxlength="100">
<button onclick="this.parentElement.remove()">✕</button>`;
list.appendChild(row);
}
function selectComposeBilder(input) {
[...input.files].forEach(f => { if (f.type.startsWith('image/')) processImageFile(f); });
input.value = '';
}
function processImageFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const maxSize = 1024;
const canvas = document.createElement('canvas');
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
const data = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
composeBilderArr.push(data);
renderComposeThumbs();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function renderComposeThumbs() {
const container = document.getElementById('composeThumbs');
container.innerHTML = '';
composeBilderArr.forEach((b, i) => {
const div = document.createElement('div');
div.className = 'compose-thumb';
div.innerHTML = `<img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="removeThumb(${i})" title="Entfernen">✕</button>`;
container.appendChild(div);
});
container.style.display = composeBilderArr.length > 0 ? 'flex' : 'none';
}
function removeThumb(idx) {
composeBilderArr.splice(idx, 1);
renderComposeThumbs();
}
// ── Drag & Drop ──
const compose = document.getElementById('compose');
compose.addEventListener('dragover', e => {
e.preventDefault();
if ([...e.dataTransfer.items].some(i => i.type.startsWith('image/')))
compose.classList.add('drag-over');
});
compose.addEventListener('dragleave', e => {
if (!compose.contains(e.relatedTarget)) compose.classList.remove('drag-over');
});
compose.addEventListener('drop', e => {
e.preventDefault();
compose.classList.remove('drag-over');
[...e.dataTransfer.files]
.filter(f => f.type.startsWith('image/'))
.forEach(f => processImageFile(f));
});
async function submitPost() {
const text = document.getElementById('composeText').value.trim();
if (!text) return;
const beitragTyp = document.querySelector('input[name="beitragTyp"]:checked').value;
const multiChoice = document.getElementById('multiChoice').checked;
const isPublic = document.getElementById('isPublic').checked;
let optionen = [];
if (beitragTyp === 'UMFRAGE') {
optionen = Array.from(document.getElementById('optionList').querySelectorAll('input'))
.map(i => i.value.trim()).filter(v => v);
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
}
const res = await fetch('/feed/posts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beitragTyp, text, multiChoice, optionen, bilder: [...composeBilderArr], isPublic })
});
if (!res.ok) return;
const post = await res.json();
// Reset compose
document.getElementById('composeText').value = '';
composeBilderArr = [];
renderComposeThumbs();
document.querySelector('input[name="beitragTyp"][value="TEXT"]').checked = true;
toggleUmfrage();
document.getElementById('multiChoice').checked = false;
document.getElementById('isPublic').checked = false;
document.getElementById('optionList').innerHTML = '';
// Prepend to mine feed
document.getElementById('mineEmpty').style.display = 'none';
document.getElementById('mineFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'mine'));
if (isPublic) {
document.getElementById('publicEmpty').style.display = 'none';
document.getElementById('publicFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'public'));
}
}
// ── Like ──
async function likePost(postId, postType) {
let likeEndpoint;
if (postType === 'GROUP') {
const card = document.getElementById('pc-' + postId);
const gruppeId = card?.dataset?.gruppeId;
if (!gruppeId) return;
likeEndpoint = `/gruppen/${gruppeId}/posts/${postId}/like`;
} else {
likeEndpoint = `/feed/posts/${postId}/like`;
}
await fetch(likeEndpoint, { method: 'POST' });
const btn = document.getElementById('lk-' + postId);
const lc = document.getElementById('lkc-' + postId);
const was = btn.classList.contains('active');
btn.classList.toggle('active', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
}
// ── Vote ──
async function votePost(postId, optionId, tab, postType) {
if (postType === 'GROUP') {
const card = document.getElementById('pc-' + postId);
const gruppeId = card?.dataset?.gruppeId;
if (!gruppeId) return;
await fetch(`/gruppen/${gruppeId}/posts/${postId}/vote`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ optionId })
});
} else {
await fetch('/feed/posts/' + postId + '/vote', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ optionId })
});
}
reloadPost(postId, tab);
}
async function reloadPost(postId, tab) {
const state = feedState[tab];
state.page = 0; state.hasMore = true; state.loaded = false;
document.getElementById(tab + 'Feed').innerHTML = '';
document.getElementById(tab + 'Empty').style.display = 'none';
await loadFeed(tab);
}
// ── Delete ──
async function deletePost(postId) {
if (!confirm('Post löschen?')) return;
const res = await fetch('/feed/posts/' + postId, { method: 'DELETE' });
if (res.ok) {
document.getElementById('pc-' + postId)?.remove();
}
}
// ── Lightbox ──
function openLb(postId, postType) {
activeLbPostId = postId;
activeLbPostType = postType;
const card = document.getElementById('pc-' + postId);
if (card) {
const clone = card.cloneNode(true);
clone.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = clone.innerHTML;
}
loadLbComments(postId, postType);
document.getElementById('postLightbox').classList.add('open');
}
function closeLb() {
document.getElementById('postLightbox').classList.remove('open');
activeLbPostId = null;
activeLbPostType = null;
}
document.getElementById('postLightbox').addEventListener('click', e => {
if (e.target === document.getElementById('postLightbox')) closeLb();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('postLightbox').classList.contains('open')) closeLb();
});
async function loadLbComments(postId, postType) {
const targetType = postType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${postId}`);
const comments = await res.json();
document.getElementById('lbCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, targetType, postId, { myUserId })).join('');
}
async function postLbComment() {
if (!activeLbPostId) return;
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
const targetType = activeLbPostType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType, targetId: activeLbPostId, text })
});
input.value = '';
await loadLbComments(activeLbPostId, activeLbPostType);
const kcEl = document.getElementById('kc-' + activeLbPostId);
if (kcEl) kcEl.textContent = parseInt(kcEl.textContent) + 1;
}
// renderKommentarHtml und toggleKommentarLike kommen aus shared.js
async function deleteKommentar(kommentarId, targetType, targetId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments(targetId, activeLbPostType);
}
// toggleEmojiPicker, insertEmoji kommen aus shared.js
// esc, fmtDate kommen aus shared.js
</script>
</body>
</html>

View File

@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Freunde – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.tabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.tab-btn {
background: none;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
padding: 0.6rem 1.25rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-muted);
cursor: pointer;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: var(--color-text); background: none; }
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.user-list { list-style: none; margin: 0; padding: 0; }
.user-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.user-item:last-child { border-bottom: none; }
.user-avatar {
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--color-secondary);
}
.user-avatar img { width: 100%; height: 100%; object-fit: cover; }
.user-name { font-weight: 600; flex: 1; }
.user-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.user-actions button, .user-actions a.btn {
margin-top: 0;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
width: auto;
}
.user-profile-link {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}
.user-profile-link:hover .user-name { color: var(--color-primary); }
.btn-reject {
background: transparent;
border: 1px solid var(--color-secondary);
color: var(--color-muted);
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-reject:hover { background: #3d0f1a; border-color: var(--color-primary); color: var(--color-primary); }
.empty-hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.5rem; }
/* ── Confirm dialog ── */
.dialog-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.dialog-backdrop.visible { display: flex; }
.dialog {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 1.75rem;
width: 100%;
max-width: 340px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
}
.dialog h3 { color: var(--color-primary); font-size: 1.05rem; margin-bottom: 0.75rem; }
.dialog p { color: var(--color-muted); font-size: 0.88rem; margin-bottom: 1.25rem; }
.dialog-actions { display: flex; gap: 0.75rem; }
.dialog-actions button { flex: 1; margin-top: 0; }
.tab-badge {
display: inline-block;
background: var(--color-primary);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.05rem 0.35rem;
margin-left: 0.35rem;
vertical-align: middle;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom: 1.25rem;">Freunde</h1>
<div class="tabs">
<button class="tab-btn active" data-tab="friends" onclick="switchTab('friends', this)">Freunde</button>
<button class="tab-btn" data-tab="pending" onclick="switchTab('pending', this)" id="pendingTabBtn">
Anfragen<span class="tab-badge" id="pendingBadge" style="display:none;"></span>
</button>
</div>
<!-- Friends tab -->
<div class="tab-panel active" id="tab-friends">
<ul class="user-list" id="friendsList"></ul>
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></p>
</div>
<!-- Pending tab -->
<div class="tab-panel" id="tab-pending">
<ul class="user-list" id="pendingList"></ul>
<p class="empty-hint" id="pendingEmpty" style="display:none;">Keine offenen Anfragen.</p>
</div>
</div>
</div>
<!-- Confirm remove dialog -->
<div class="dialog-backdrop" id="removeDialog">
<div class="dialog">
<h3>Freundschaft beenden</h3>
<p id="removeDialogText">Möchtest du diese Freundschaft wirklich beenden?</p>
<div class="dialog-actions">
<button class="secondary" onclick="closeRemoveDialog()">Abbrechen</button>
<button id="removeConfirmBtn" style="background:#c0392b;" onclick="confirmRemove()">Entfernen</button>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
localStorage.setItem('tab_freunde', name);
}
const _savedFreundeTab = localStorage.getItem('tab_freunde');
if (_savedFreundeTab) {
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedFreundeTab}"]`);
if (_btn) switchTab(_savedFreundeTab, _btn);
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function avatar(u) {
return u.profilePicture
? `<img src="data:image/png;base64,${u.profilePicture}" alt="">`
: '◉';
}
async function loadFriends() {
try {
const res = await fetch('/social/friends');
if (!res.ok) return;
const friends = await res.json();
const list = document.getElementById('friendsList');
list.innerHTML = '';
if (friends.length === 0) {
document.getElementById('friendsEmpty').style.display = '';
return;
}
document.getElementById('friendsEmpty').style.display = 'none';
friends.forEach(f => {
list.insertAdjacentHTML('beforeend', `
<li class="user-item" id="friend-${f.friendshipId}">
<a href="/community/benutzer.html?userId=${f.user.userId}" class="user-profile-link">
<div class="user-avatar">${avatar(f.user)}</div>
<div class="user-name">${esc(f.user.name)}</div>
</a>
<div class="user-actions">
<a href="/community/nachrichten.html?userId=${f.user.userId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>
<button class="btn-reject" onclick="removeFriend('${f.friendshipId}', this)">Entfernen</button>
</div>
</li>`);
});
} catch (e) { console.error(e); }
}
async function loadPending() {
try {
const res = await fetch('/social/friends/pending');
if (!res.ok) return;
const pending = await res.json();
const list = document.getElementById('pendingList');
list.innerHTML = '';
const badge = document.getElementById('pendingBadge');
if (pending.length > 0) {
badge.textContent = pending.length;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
if (pending.length === 0) {
document.getElementById('pendingEmpty').style.display = '';
return;
}
document.getElementById('pendingEmpty').style.display = 'none';
pending.forEach(f => {
list.insertAdjacentHTML('beforeend', `
<li class="user-item" id="pending-${f.friendshipId}">
<a href="/community/benutzer.html?userId=${f.user.userId}" class="user-profile-link">
<div class="user-avatar">${avatar(f.user)}</div>
<div class="user-name">${esc(f.user.name)}</div>
</a>
<div class="user-actions">
<button onclick="accept('${f.friendshipId}', this)">✓ Annehmen</button>
<button class="btn-reject" onclick="reject('${f.friendshipId}', this)">✕ Ablehnen</button>
</div>
</li>`);
});
} catch (e) { console.error(e); }
}
async function accept(friendshipId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/social/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId })
});
if (res.ok) {
document.getElementById('pending-' + friendshipId)?.remove();
await loadFriends();
await loadPending();
} else {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
} catch (e) {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
}
async function reject(friendshipId, btn) {
btn.disabled = true;
try {
await fetch('/social/friends/reject', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId })
});
document.getElementById('pending-' + friendshipId)?.remove();
await loadPending();
} catch (e) {
btn.disabled = false;
}
}
let pendingRemoveFriendshipId = null;
let pendingRemoveBtn = null;
function removeFriend(friendshipId, btn) {
pendingRemoveFriendshipId = friendshipId;
pendingRemoveBtn = btn;
document.getElementById('removeDialogText').textContent =
'Möchtest du ' + (btn.closest('.user-item').querySelector('.user-name')?.textContent || 'diese Person') + ' wirklich aus deiner Freundesliste entfernen?';
document.getElementById('removeDialog').classList.add('visible');
}
function closeRemoveDialog() {
document.getElementById('removeDialog').classList.remove('visible');
pendingRemoveFriendshipId = null;
pendingRemoveBtn = null;
}
async function confirmRemove() {
if (!pendingRemoveFriendshipId) return;
const btn = document.getElementById('removeConfirmBtn');
btn.disabled = true;
btn.textContent = '…';
try {
await fetch('/social/friends/reject', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId: pendingRemoveFriendshipId })
});
document.getElementById('friend-' + pendingRemoveFriendshipId)?.remove();
const list = document.getElementById('friendsList');
if (list.children.length === 0) {
document.getElementById('friendsEmpty').style.display = '';
}
closeRemoveDialog();
} catch (e) {
if (pendingRemoveBtn) pendingRemoveBtn.disabled = false;
} finally {
btn.disabled = false;
btn.textContent = 'Entfernen';
}
}
document.getElementById('removeDialog').addEventListener('click', e => {
if (e.target === document.getElementById('removeDialog')) closeRemoveDialog();
});
loadFriends();
loadPending();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,411 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gruppen – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.tabs { display:flex; gap:0; margin-bottom:1.5rem; border-bottom:1px solid var(--color-secondary); }
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.25rem; font-size:0.95rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
.gruppe-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:1rem; margin-top:0.5rem; }
.gruppe-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
.gruppe-card-img { width:100%; height:110px; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:2.5rem; }
.gruppe-card-img img { width:100%; height:100%; object-fit:cover; }
.gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; }
.gruppe-card-name { font-weight:700; font-size:1rem; }
.gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); }
.gruppe-card-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:auto; padding-top:0.5rem; }
.gruppe-card-actions button, .gruppe-card-actions a.btn { margin-top:0; padding:0.3rem 0.7rem; font-size:0.8rem; width:auto; }
.role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; }
.role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); }
.search-row { display:flex; gap:0.75rem; margin-bottom:1rem; }
.search-row input { flex:1; }
.search-row button { white-space:nowrap; width:auto; margin-top:0; }
.anfrage-list { list-style:none; margin:0; padding:0; }
.anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); }
.anfrage-item:last-child { border-bottom:none; }
.anfrage-name { font-weight:600; }
.anfrage-status { font-size:0.8rem; color:var(--color-muted); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
/* Dialog */
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
.dialog-backdrop.visible { display:flex; }
.dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; }
.dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; }
.dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; }
.dialog input, .dialog textarea { width:100%; box-sizing:border-box; }
.dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; }
.dialog textarea:focus { border-color:var(--color-primary); }
.dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; }
.dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; }
.toggle-row { display:flex; align-items:center; gap:0.75rem; margin-top:0.75rem; }
.toggle-row label { margin:0; font-size:0.9rem; color:var(--color-text); }
.img-preview { width:100%; max-height:140px; object-fit:cover; border-radius:6px; margin-top:0.5rem; display:none; }
.card-notif { font-size:0.75rem; font-weight:700; color:#fff; background:#e67e22; border-radius:4px; padding:0.15rem 0.45rem; display:none; width:fit-content; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
<h1 style="margin:0;">Gruppen</h1>
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button>
<button class="tab-btn" data-tab="discover" onclick="switchTab('discover', this)">Entdecken</button>
<button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button>
</div>
<!-- Meine Gruppen -->
<div class="tab-panel active" id="tab-mine">
<div class="gruppe-grid" id="mineGrid"></div>
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
</div>
<!-- Entdecken -->
<div class="tab-panel" id="tab-discover">
<div class="search-row">
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" onkeydown="if(event.key==='Enter')doSearch()">
<button onclick="doSearch()">Suchen</button>
</div>
<div class="gruppe-grid" id="discoverGrid"></div>
<p class="empty-hint" id="discoverHint">Gib einen Suchbegriff ein.</p>
</div>
<!-- Meine Anfragen -->
<div class="tab-panel" id="tab-requests">
<ul class="anfrage-list" id="requestsList"></ul>
<p class="empty-hint" id="requestsEmpty" style="display:none;">Keine ausstehenden Anfragen.</p>
</div>
</div>
</div>
<!-- Gruppe erstellen Dialog -->
<div class="dialog-backdrop" id="createDialog">
<div class="dialog">
<h3>Gruppe erstellen</h3>
<label>Name *</label>
<input type="text" id="createName" maxlength="100" placeholder="Gruppenname">
<label>Beschreibung</label>
<textarea id="createDesc" maxlength="1000" placeholder="Worum geht es in dieser Gruppe?"></textarea>
<label>Bild (optional)</label>
<input type="file" id="createBildFile" accept="image/*" onchange="previewBild(this,'createBildPreview','createBildData')">
<img id="createBildPreview" class="img-preview" alt="">
<input type="hidden" id="createBildData">
<div class="toggle-row">
<input type="checkbox" id="createPrivate">
<label for="createPrivate">Private Gruppe (Beitritt nur per Anfrage)</label>
</div>
<p class="message error" id="createError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" onclick="closeCreateDialog()">Abbrechen</button>
<button onclick="createGruppe()">Erstellen</button>
</div>
</div>
</div>
<!-- Beitrittsanfrage Dialog -->
<div class="dialog-backdrop" id="joinDialog">
<div class="dialog">
<h3>Beitrittsanfrage senden</h3>
<p id="joinDialogGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
<label>Nachricht (optional)</label>
<textarea id="joinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
<p class="message error" id="joinError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" onclick="closeJoinDialog()">Abbrechen</button>
<button onclick="sendJoinRequest()">Anfrage senden</button>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
localStorage.setItem('tab_gruppen', name);
if (name === 'mine') loadMine();
if (name === 'requests') loadRequests();
}
const _savedGruppenTab = localStorage.getItem('tab_gruppen');
if (_savedGruppenTab) {
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedGruppenTab}"]`);
if (_btn) switchTab(_savedGruppenTab, _btn);
}
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
function gruppeCard(g, showJoin = false) {
const img = g.bild
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>`
: `<div class="gruppe-card-img">👥</div>`;
const roleBadge = g.myRole
? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>`
: '';
const privBadge = g.isPrivate ? ' 🔒' : '';
let actions = '';
if (showJoin && !g.myRole) {
if (g.myRequestStatus === 'AUSSTEHEND') {
actions = `<button disabled style="opacity:0.6;" onclick="event.stopPropagation()">Anfrage ausstehend</button>`;
} else if (g.isPrivate) {
actions = `<button onclick="event.stopPropagation(); openJoinDialog('${g.gruppeId}','${esc(g.name)}')">Anfrage senden</button>`;
} else {
actions = `<button onclick="event.stopPropagation(); joinGruppe('${g.gruppeId}', this)">Beitreten</button>`;
}
}
return `
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
${img}
<div class="gruppe-card-body">
<div class="gruppe-card-name">${esc(g.name)}${privBadge} ${roleBadge}</div>
<div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div>
${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''}
<div class="card-notif" id="notif-${g.gruppeId}"></div>
${actions ? `<div class="gruppe-card-actions">${actions}</div>` : ''}
</div>
</div>`;
}
async function loadMine() {
try {
const res = await fetch('/gruppen/mine');
if (!res.ok) return;
const data = await res.json();
const grid = document.getElementById('mineGrid');
grid.innerHTML = '';
if (data.length === 0) { document.getElementById('mineEmpty').style.display = ''; return; }
document.getElementById('mineEmpty').style.display = 'none';
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g)));
loadAdminBadges(data);
} catch(e) { console.error(e); }
}
async function loadAdminBadges(groups) {
const adminGroups = groups.filter(g => g.myRole === 'ADMIN');
await Promise.all(adminGroups.map(async g => {
const [reqRes, repRes] = await Promise.all([
fetch('/gruppen/' + g.gruppeId + '/requests'),
fetch('/gruppen/' + g.gruppeId + '/reports')
]);
const reqs = reqRes.ok ? await reqRes.json() : [];
const reps = repRes.ok ? await repRes.json() : [];
const total = reqs.length + reps.length;
if (total === 0) return;
const el = document.getElementById('notif-' + g.gruppeId);
if (!el) return;
const parts = [];
if (reqs.length > 0) parts.push(reqs.length + ' Anfrage' + (reqs.length !== 1 ? 'n' : ''));
if (reps.length > 0) parts.push(reps.length + ' Meldung' + (reps.length !== 1 ? 'en' : ''));
el.textContent = '🔔 ' + parts.join(' · ');
el.style.display = '';
}));
}
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
try {
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
if (!res.ok) return;
const data = await res.json();
const grid = document.getElementById('discoverGrid');
const hint = document.getElementById('discoverHint');
grid.innerHTML = '';
if (data.length === 0) { hint.textContent = 'Keine Gruppen gefunden.'; hint.style.display = ''; return; }
hint.style.display = 'none';
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g, true)));
} catch(e) { console.error(e); }
}
async function joinGruppe(gruppeId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/gruppen/' + gruppeId + '/join', { method: 'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
if (res.ok || res.status === 201) {
btn.textContent = 'Beigetreten ✓';
setTimeout(loadMine, 500);
} else {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
} catch(e) { btn.disabled = false; btn.textContent = 'Beitreten'; }
}
async function loadRequests() {
try {
const reqRes = await fetch('/gruppen/requests/mine');
if (!reqRes.ok) { document.getElementById('requestsEmpty').style.display = ''; return; }
const data = await reqRes.json();
const list = document.getElementById('requestsList');
list.innerHTML = '';
if (data.length === 0) { document.getElementById('requestsEmpty').style.display = ''; return; }
document.getElementById('requestsEmpty').style.display = 'none';
data.forEach(r => {
list.insertAdjacentHTML('beforeend', `
<li class="anfrage-item" id="req-${r.anfrageId}">
<div>
<div class="anfrage-name">${esc(r.gruppeName)}</div>
<div class="anfrage-status">Ausstehend seit ${fmtDate(r.angefragtAt)}</div>
${r.nachricht ? `<div style="font-size:0.82rem;color:var(--color-muted);margin-top:0.2rem;">"${esc(r.nachricht)}"</div>` : ''}
</div>
<button class="secondary" onclick="withdrawRequest('${r.gruppeId}', '${r.anfrageId}', this)">Zurückziehen</button>
</li>`);
});
} catch(e) { console.error(e); }
}
async function withdrawRequest(gruppeId, anfrageId, btn) {
btn.disabled = true;
try {
await fetch('/gruppen/' + gruppeId + '/requests/mine', { method: 'DELETE' });
document.getElementById('req-' + anfrageId)?.remove();
if (document.getElementById('requestsList').children.length === 0)
document.getElementById('requestsEmpty').style.display = '';
} catch(e) { btn.disabled = false; }
}
function fmtDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
}
// ── Create dialog ──
function openCreateDialog() {
document.getElementById('createName').value = '';
document.getElementById('createDesc').value = '';
document.getElementById('createPrivate').checked = false;
document.getElementById('createBildData').value = '';
document.getElementById('createBildPreview').style.display = 'none';
document.getElementById('createBildFile').value = '';
document.getElementById('createDialog').classList.add('visible');
}
function closeCreateDialog() { document.getElementById('createDialog').classList.remove('visible'); document.getElementById('createError').style.display = 'none'; }
function showCreateError(text) {
const el = document.getElementById('createError');
el.textContent = text;
el.style.display = 'block';
}
async function createGruppe() {
const name = document.getElementById('createName').value.trim();
if (!name) { showCreateError('Bitte einen Namen eingeben.'); return; }
document.getElementById('createError').style.display = 'none';
const body = {
name,
beschreibung: document.getElementById('createDesc').value.trim() || null,
bild: document.getElementById('createBildData').value || null,
isPrivate: document.getElementById('createPrivate').checked
};
try {
const res = await fetch('/gruppen', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (res.ok || res.status === 201) {
const g = await res.json();
closeCreateDialog();
window.location.href = '/community/gruppe.html?gruppeId=' + g.gruppeId;
} else {
showCreateError('Fehler beim Erstellen der Gruppe.');
}
} catch(e) { showCreateError('Fehler: ' + e.message); }
}
// ── Join dialog ──
let pendingJoinGruppeId = null;
function openJoinDialog(gruppeId, name) {
pendingJoinGruppeId = gruppeId;
document.getElementById('joinDialogGroupName').textContent = name;
document.getElementById('joinNachricht').value = '';
document.getElementById('joinDialog').classList.add('visible');
}
function closeJoinDialog() { document.getElementById('joinDialog').classList.remove('visible'); pendingJoinGruppeId = null; }
async function sendJoinRequest() {
if (!pendingJoinGruppeId) return;
document.getElementById('joinError').style.display = 'none';
const nachricht = document.getElementById('joinNachricht').value.trim() || null;
try {
const res = await fetch('/gruppen/' + pendingJoinGruppeId + '/join', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nachricht })
});
if (res.ok || res.status === 201) {
closeJoinDialog();
doSearch();
} else {
const el = document.getElementById('joinError');
el.textContent = 'Fehler beim Senden der Anfrage.';
el.style.display = 'block';
}
} catch(e) {
const el = document.getElementById('joinError');
el.textContent = 'Fehler: ' + e.message;
el.style.display = 'block';
}
}
// ── Image preview ──
function previewBild(input, previewId, dataId) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const original = new Image();
original.onload = () => {
const MAX = 256;
let w = original.width, h = original.height;
if (w > MAX || h > MAX) {
if (w > h) { h = Math.round(h * MAX / w); w = MAX; }
else { w = Math.round(w * MAX / h); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(original, 0, 0, w, h);
const scaled = canvas.toDataURL('image/jpeg', 0.85);
const img = document.getElementById(previewId);
img.src = scaled;
img.style.display = 'block';
document.getElementById(dataId).value = scaled.split(',')[1];
};
original.src = e.target.result;
};
reader.readAsDataURL(file);
}
document.getElementById('createDialog').addEventListener('click', e => {
if (e.target === document.getElementById('createDialog')) closeCreateDialog();
});
document.getElementById('joinDialog').addEventListener('click', e => {
if (e.target === document.getElementById('joinDialog')) closeJoinDialog();
});
loadMine();
</script>
</body>
</html>

View File

@@ -0,0 +1,671 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nachrichten – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* Override .main and .content for full-height chat layout */
.main { overflow: hidden; }
.msg-layout {
display: flex;
height: calc(100vh - 3rem);
overflow: hidden;
}
/* Left: conversation list */
.conv-list-pane {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--color-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-list-header {
padding: 1.25rem 1rem 0.75rem;
font-weight: 700;
font-size: 1rem;
border-bottom: 1px solid var(--color-secondary);
flex-shrink: 0;
}
.conv-list {
flex: 1;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.conv-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.7rem 1rem;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.conv-item:hover { background: var(--color-secondary); }
.conv-item.active { background: var(--color-secondary); border-left-color: var(--color-primary); }
.conv-avatar {
width: 38px; height: 38px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
}
.conv-avatar img { width: 100%; height: 100%; object-fit: cover; }
.conv-info { flex: 1; min-width: 0; }
.conv-name { font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.conv-preview { font-size: 0.78rem; color: var(--color-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.conv-unread {
background: var(--color-primary);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.1rem 0.35rem;
flex-shrink: 0;
}
/* Right: thread */
.thread-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.thread-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-secondary);
font-weight: 700;
font-size: 1rem;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.thread-back {
display: none;
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
font-size: 1.1rem;
padding: 0;
margin: 0;
}
.thread-messages {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.thread-placeholder {
color: var(--color-muted);
text-align: center;
margin-top: 3rem;
font-size: 0.95rem;
}
.bubble-wrap {
display: flex;
flex-direction: column;
}
.bubble-wrap.me { align-items: flex-end; }
.bubble-wrap.them { align-items: flex-start; }
.bubble {
max-width: 70%;
padding: 0.5rem 0.9rem;
border-radius: 14px;
font-size: 0.9rem;
line-height: 1.45;
word-break: break-word;
}
.bubble-wrap.me .bubble {
background: var(--color-primary);
color: #fff;
border-bottom-right-radius: 4px;
}
.bubble-wrap.them .bubble {
background: var(--color-secondary);
color: var(--color-text);
border-bottom-left-radius: 4px;
}
.bubble-time {
font-size: 0.7rem;
color: var(--color-muted);
margin-top: 0.15rem;
padding: 0 0.25rem;
}
.thread-input-wrap {
flex-shrink: 0;
position: relative;
}
.thread-input-area {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.65rem 1rem;
border-top: 1px solid var(--color-secondary);
}
.thread-input-area input {
flex: 1;
}
.btn-icon {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.3rem 0.4rem;
width: auto;
margin-top: 0;
flex-shrink: 0;
opacity: 0.65;
border-radius: 6px;
transition: opacity 0.15s, background 0.15s;
line-height: 1;
}
.btn-icon:hover { opacity: 1; background: var(--color-secondary); }
.btn-send {
width: auto;
margin-top: 0;
padding: 0.55rem 1rem;
flex-shrink: 0;
}
.emoji-picker {
position: absolute;
bottom: 100%;
left: 0; right: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px 10px 0 0;
padding: 0.5rem;
display: none;
flex-wrap: wrap;
gap: 2px;
max-height: 160px;
overflow-y: auto;
box-shadow: 0 -4px 16px rgba(0,0,0,0.4);
z-index: 50;
}
.emoji-picker.open { display: flex; }
.emoji-picker button {
background: none;
border: none;
font-size: 1.3rem;
cursor: pointer;
padding: 0.2rem 0.3rem;
border-radius: 4px;
width: auto;
margin-top: 0;
line-height: 1;
}
.emoji-picker button:hover { background: var(--color-secondary); }
.bubble-img {
max-width: 220px;
max-height: 220px;
border-radius: 8px;
display: block;
cursor: zoom-in;
object-fit: contain;
}
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 500;
align-items: center;
justify-content: center;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 90vw;
max-height: 90vh;
border-radius: 6px;
object-fit: contain;
box-shadow: 0 8px 40px rgba(0,0,0,0.7);
}
.lightbox-close {
position: fixed !important;
top: 1rem !important;
right: 1rem !important;
background: rgba(0,0,0,0.55) !important;
border: 1px solid rgba(255,255,255,0.2) !important;
color: #fff !important;
font-size: 1.1rem !important;
width: 2.2rem !important;
height: 2.2rem !important;
border-radius: 50% !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
margin: 0 !important;
line-height: 1 !important;
transition: background 0.15s !important;
z-index: 501;
}
.lightbox-close:hover { background: rgba(0,0,0,0.85) !important; }
/* Mobile */
@media (max-width: 768px) {
.msg-layout { height: auto; flex-direction: column; }
.conv-list-pane { width: 100%; border-right: none; border-bottom: 1px solid var(--color-secondary); max-height: 240px; }
.conv-list-pane.hidden { display: none; }
.thread-pane.hidden { display: none; }
.thread-back { display: block; }
}
</style>
</head>
<body class="app">
<div class="main">
<div class="msg-layout">
<!-- Left pane: conversation list -->
<div class="conv-list-pane" id="convListPane">
<div class="conv-list-header">Nachrichten</div>
<ul class="conv-list" id="convList">
<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Wird geladen…</li>
</ul>
</div>
<!-- Right pane: thread -->
<div class="thread-pane" id="threadPane">
<div class="thread-header" id="threadHeader">
<button class="thread-back" id="backBtn" onclick="showList()" aria-label="Zurück">‹</button>
<div class="conv-avatar" id="threadPartnerAvatar" style="display:none;"></div>
<span id="threadPartnerName">Konversation auswählen</span>
</div>
<div class="thread-messages" id="threadMessages">
<div class="thread-placeholder" id="threadPlaceholder">Wähle eine Konversation aus oder schreibe jemanden direkt an.</div>
</div>
<div class="thread-input-wrap" id="threadInputWrap" style="display:none;">
<div class="emoji-picker" id="emojiPicker"></div>
<div class="thread-input-area">
<button class="btn-icon" id="emojiBtn" onclick="toggleEmoji()" title="Emoji">😊</button>
<input type="text" id="msgInput" placeholder="Nachricht eingeben…" autocomplete="off">
<input type="file" id="imgFile" accept="image/*" style="display:none;">
<button class="btn-icon" onclick="document.getElementById('imgFile').click()" title="Bild senden">📷</button>
<button class="btn-send" onclick="sendMsg()">↑</button>
</div>
</div>
</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightboxImg" src="" alt="" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox()" aria-label="Schließen">✕</button>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
const SUPPORT_USER_ID = 'dbf1e35a-e331-3211-9889-d0d21f386028';
let myId = null;
let activePartnerId = null;
let pollTimer = null;
// Pagination state
let oldestSentAt = null; // ISO string of oldest visible message
let newestSentAt = null; // ISO string of newest visible message
let hasMoreOlder = false;
let isLoadingOlder = false;
// Load current user
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
if (!user) return;
myId = user.userId;
loadConversations();
const urlPartnerId = new URLSearchParams(window.location.search).get('userId');
if (urlPartnerId) openThread(urlPartnerId);
})
.catch(() => {});
async function loadConversations() {
try {
const res = await fetch('/social/messages');
if (!res.ok) return;
const convs = await res.json();
renderConvList(convs);
} catch (e) { console.error(e); }
}
function renderConvList(convs) {
const list = document.getElementById('convList');
list.innerHTML = '';
if (convs.length === 0) {
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></li>';
return;
}
convs.forEach(c => {
const av = c.partner.profilePicture
? `<img src="data:image/png;base64,${c.partner.profilePicture}" alt="" style="cursor:zoom-in;" onclick="event.stopPropagation();openLightbox(this.src)">`
: '◉';
const unreadHtml = c.unreadCount > 0
? `<span class="conv-unread">${c.unreadCount}</span>`
: '';
const preview = c.lastMessage
? (c.lastMessage.text.startsWith('data:image/') ? '📷 Bild' : esc(c.lastMessage.text.substring(0, 40)))
: '';
const li = document.createElement('li');
li.className = 'conv-item' + (c.partner.userId === activePartnerId ? ' active' : '');
li.dataset.partnerId = c.partner.userId;
li.innerHTML = `
<div class="conv-avatar">${av}</div>
<div class="conv-info">
<div class="conv-name">${esc(c.partner.name)}</div>
<div class="conv-preview">${preview}</div>
</div>
${unreadHtml}`;
li.addEventListener('click', () => openThread(c.partner.userId, c.partner.name, c.partner.profilePicture));
list.appendChild(li);
});
}
async function openThread(partnerId, partnerName, partnerPic) {
activePartnerId = partnerId;
oldestSentAt = null;
newestSentAt = null;
hasMoreOlder = false;
isLoadingOlder = false;
document.querySelectorAll('.conv-item').forEach(li => {
li.classList.toggle('active', li.dataset.partnerId === partnerId);
});
if (!partnerName) {
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
partnerName = convItem ? convItem.querySelector('.conv-name').textContent : '…';
}
document.getElementById('threadPartnerName').innerHTML =
`<a href="/community/benutzer.html?userId=${partnerId}" style="color:inherit;text-decoration:none;">${esc(partnerName)}</a>`;
const avatarEl = document.getElementById('threadPartnerAvatar');
if (partnerPic) {
avatarEl.innerHTML = `<img src="data:image/png;base64,${partnerPic}" alt="" style="cursor:zoom-in;" onclick="openLightbox(this.src)">`;
avatarEl.style.display = '';
} else {
avatarEl.style.display = 'none';
}
const isSupport = partnerId === SUPPORT_USER_ID;
document.getElementById('threadInputWrap').style.display = isSupport ? 'none' : '';
if (!isSupport) document.getElementById('msgInput').focus();
if (window.innerWidth <= 768) showThread();
// Clear and load latest messages
const container = document.getElementById('threadMessages');
container.innerHTML = '';
await loadInitialThread();
startPolling();
}
async function loadInitialThread() {
if (!activePartnerId) return;
try {
const res = await fetch('/social/messages/' + activePartnerId);
if (!res.ok) return;
const { messages, hasMore } = await res.json();
const container = document.getElementById('threadMessages');
container.innerHTML = '';
if (messages.length === 0) {
container.appendChild(Object.assign(document.createElement('div'), {
className: 'thread-placeholder',
textContent: 'Noch keine Nachrichten. Schreib als Erster!'
}));
oldestSentAt = null;
newestSentAt = null;
hasMoreOlder = false;
} else {
// messages are oldest-first from backend
messages.forEach(m => container.appendChild(buildBubble(m)));
oldestSentAt = messages[0].sentAt;
newestSentAt = messages[messages.length - 1].sentAt;
hasMoreOlder = hasMore;
}
// Zweifaches rAF stellt sicher, dass der Browser das Layout vollständig berechnet hat
// bevor gescrollt wird (wichtig bei Bild-Nachrichten)
requestAnimationFrame(() => requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
}));
loadConversations();
} catch (e) { console.error(e); }
}
async function loadOlderMessages() {
if (!activePartnerId || !hasMoreOlder || isLoadingOlder || !oldestSentAt) return;
isLoadingOlder = true;
try {
const res = await fetch('/social/messages/' + activePartnerId + '?before=' + encodeURIComponent(oldestSentAt));
if (!res.ok) return;
const { messages, hasMore } = await res.json();
if (messages.length === 0) { hasMoreOlder = false; return; }
const container = document.getElementById('threadMessages');
const prevHeight = container.scrollHeight;
// Prepend older messages (oldest-first order)
const frag = document.createDocumentFragment();
messages.forEach(m => frag.appendChild(buildBubble(m)));
container.prepend(frag);
// Restore scroll position
container.scrollTop = container.scrollHeight - prevHeight;
oldestSentAt = messages[0].sentAt;
hasMoreOlder = hasMore;
} catch (e) { console.error(e); }
finally { isLoadingOlder = false; }
}
async function pollNewMessages() {
if (!activePartnerId || !newestSentAt) return;
try {
const res = await fetch('/social/messages/' + activePartnerId + '?after=' + encodeURIComponent(newestSentAt));
if (!res.ok) return;
const { messages } = await res.json();
if (messages.length === 0) return;
const container = document.getElementById('threadMessages');
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 60;
// Remove placeholder if present
const ph = container.querySelector('.thread-placeholder');
if (ph) ph.remove();
messages.forEach(m => container.appendChild(buildBubble(m)));
newestSentAt = messages[messages.length - 1].sentAt;
if (atBottom) container.scrollTop = container.scrollHeight;
loadConversations();
} catch (e) { console.error(e); }
}
function buildBubble(m) {
const isMe = m.senderId === myId;
const time = new Date(m.sentAt).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
const wrap = document.createElement('div');
wrap.className = 'bubble-wrap ' + (isMe ? 'me' : 'them');
wrap.dataset.sentAt = m.sentAt;
const isImg = m.text.startsWith('data:image/');
const content = isImg
? `<img src="${m.text}" class="bubble-img" onclick="openLightbox(this.src)" alt="Bild">`
: linkify(m.text);
wrap.innerHTML = `
<div class="bubble">${content}</div>
<div class="bubble-time">${time}</div>`;
return wrap;
}
// Scroll-up → load older messages
document.getElementById('threadMessages').addEventListener('scroll', function () {
if (this.scrollTop < 80 && hasMoreOlder && !isLoadingOlder) {
loadOlderMessages();
}
});
async function sendMsg() {
if (!activePartnerId) return;
const input = document.getElementById('msgInput');
const text = input.value.trim();
if (!text) return;
input.value = '';
try {
await fetch('/social/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId: activePartnerId, text })
});
await pollNewMessages();
} catch (e) { console.error(e); }
}
document.getElementById('msgInput').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); }
});
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollNewMessages, 10000);
}
function showThread() {
document.getElementById('convListPane').classList.add('hidden');
document.getElementById('threadPane').classList.remove('hidden');
}
function showList() {
document.getElementById('convListPane').classList.remove('hidden');
document.getElementById('threadPane').classList.add('hidden');
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
activePartnerId = null;
}
function openLightbox(src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeLightbox();
});
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function linkify(text) {
// HTML-escapen, Zeilenumbrüche als <br>, URLs als klickbare Links
const escaped = esc(text)
.replace(/\n/g, '<br>');
return escaped.replace(
/(https?:\/\/[^\s<>"]+)/g,
url => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:underline;word-break:break-all;">${url}</a>`
);
}
// ── Emoji-Picker ──
const EMOJIS = [
'😀','😂','🤣','😅','😊','😍','🥰','😘','😎','🤩',
'😉','🙂','😐','😏','😒','😢','😭','😤','😡','🤬',
'🤔','🥺','😳','🤭','😈','💀','🙈','🙉','🙊','😴',
'👍','👎','👏','🙏','💪','🫶','🤗','🫠','✌️','🤞',
'❤️','🧡','💛','💚','💙','💜','🖤','💕','💖','💘',
'🔥','✨','⚡','🌟','💯','🎉','🎊','🎀','🌹','🌸',
'🍕','🍔','🍟','🌮','🍜','🍣','🍩','🍪','☕','🍷',
'🐱','🐶','🦊','🐼','🐨','🐸','🦄','🐝','🦋','🐬',
];
(function initEmojis() {
const picker = document.getElementById('emojiPicker');
EMOJIS.forEach(emoji => {
const btn = document.createElement('button');
btn.textContent = emoji;
btn.title = emoji;
btn.onclick = () => {
const input = document.getElementById('msgInput');
const pos = input.selectionStart ?? input.value.length;
const val = input.value;
input.value = val.slice(0, pos) + emoji + val.slice(pos);
input.selectionStart = input.selectionEnd = pos + [...emoji].length;
input.focus();
};
picker.appendChild(btn);
});
})();
function toggleEmoji() {
document.getElementById('emojiPicker').classList.toggle('open');
}
document.addEventListener('click', e => {
if (!e.target.closest('#emojiPicker') && !e.target.closest('#emojiBtn')) {
document.getElementById('emojiPicker').classList.remove('open');
}
});
// ── Bild-Upload ──
(function attachImgHandler() {
document.getElementById('imgFile').addEventListener('change', async function () {
const file = this.files[0];
if (!file || !activePartnerId) return;
const fresh = this.cloneNode(false);
this.replaceWith(fresh);
attachImgHandler();
try {
const dataUrl = await resizeImage(file);
await fetch('/social/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId: activePartnerId, text: dataUrl })
});
await pollNewMessages();
} catch (e) { console.error(e); }
});
})();
function resizeImage(file) {
const MAX = 800;
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > MAX || h > MAX) {
if (w >= h) { h = Math.max(1, Math.round(MAX * h / w)); w = MAX; }
else { w = Math.max(1, Math.round(MAX * w / h)); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.72));
};
img.onerror = reject;
img.src = url;
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Personen suchen – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.search-bar input { flex: 1; }
.search-bar button { width: auto; margin-top: 0; padding: 0.65rem 1.25rem; }
.user-list { list-style: none; margin: 0; padding: 0; }
.user-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.user-item:last-child { border-bottom: none; }
.user-avatar {
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--color-secondary);
}
.user-avatar img { width: 100%; height: 100%; object-fit: cover; }
.user-name { font-weight: 600; flex: 1; }
.user-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.user-actions button, .user-actions a.btn {
margin-top: 0;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
width: auto;
}
.user-profile-link {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}
.user-profile-link:hover .user-name { color: var(--color-primary); }
.hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.5rem; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom: 1.25rem;">Personen suchen</h1>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Name eingeben (mind. 2 Zeichen)…" autocomplete="off">
<button onclick="doSearch()">Suchen</button>
</div>
<ul class="user-list" id="resultList"></ul>
<p class="hint" id="hint">Gib mindestens 2 Zeichen ein, um zu suchen.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let debounceTimer;
document.getElementById('searchInput').addEventListener('input', function () {
clearTimeout(debounceTimer);
const q = this.value.trim();
if (q.length < 2) {
document.getElementById('resultList').innerHTML = '';
document.getElementById('hint').textContent = 'Gib mindestens 2 Zeichen ein, um zu suchen.';
document.getElementById('hint').style.display = '';
return;
}
document.getElementById('hint').style.display = 'none';
debounceTimer = setTimeout(doSearch, 400);
});
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') { clearTimeout(debounceTimer); doSearch(); }
});
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (q.length < 2) return;
try {
const res = await fetch('/social/users/search?q=' + encodeURIComponent(q));
if (!res.ok) return;
renderResults(await res.json());
} catch (e) { console.error(e); }
}
function renderResults(users) {
const list = document.getElementById('resultList');
const hint = document.getElementById('hint');
list.innerHTML = '';
if (users.length === 0) {
hint.textContent = 'Keine Ergebnisse gefunden.';
hint.style.display = '';
return;
}
hint.style.display = 'none';
users.forEach(u => {
const avatar = u.profilePicture
? `<img src="data:image/png;base64,${u.profilePicture}" alt="">`
: '◉';
list.insertAdjacentHTML('beforeend', `
<li class="user-item" data-user-id="${u.userId}">
<a href="/community/benutzer.html?userId=${u.userId}" class="user-profile-link">
<div class="user-avatar">${avatar}</div>
<div class="user-name">${esc(u.name)}</div>
</a>
<div class="user-actions">${buildActions(u)}</div>
</li>`);
});
}
function buildActions(u) {
if (u.friendStatus === 'FRIEND') {
return `<a href="/community/nachrichten.html?userId=${u.userId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
}
if (u.friendStatus === 'PENDING_SENT') {
return `<button disabled>Anfrage gesendet</button>`;
}
if (u.friendStatus === 'PENDING_RECEIVED') {
return `<button onclick="acceptByUserId('${u.userId}', this)">✓ Annehmen</button>`;
}
return `<button onclick="sendRequest('${u.userId}', this)">+ Freund hinzufügen</button>`;
}
async function sendRequest(receiverId, btn) {
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
try {
const res = await fetch('/social/friends/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId })
});
btn.textContent = (res.ok || res.status === 201 || res.status === 409) ? 'Anfrage gesendet' : 'Fehler';
} catch (e) {
btn.disabled = false;
btn.textContent = '+ Freund hinzufügen';
}
}
async function acceptByUserId(senderId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const pendingRes = await fetch('/social/friends/pending');
const pending = await pendingRes.json();
const f = pending.find(p => p.user.userId === senderId);
if (!f) { btn.textContent = 'Fehler'; return; }
const res = await fetch('/social/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId: f.friendshipId })
});
if (res.ok) {
btn.textContent = '✓ Freund';
const item = btn.closest('.user-item');
if (item) {
item.querySelector('.user-actions').innerHTML =
`<a href="/community/nachrichten.html?userId=${senderId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
}
} else {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
} catch (e) {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,967 @@
/* ── Reset ── */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base ── */
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--color-bg);
font-family: 'Segoe UI', sans-serif;
color: var(--color-text);
gap: 2rem;
}
h1 {
color: var(--color-primary);
}
p {
color: var(--color-muted);
font-size: 1rem;
}
/* ── Card ── */
.card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 380px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
gap: 0;
}
.card h1 {
text-align: center;
font-size: 1.6rem;
margin-bottom: 0.25rem;
}
.subtitle {
text-align: center;
font-size: 0.85rem;
color: var(--color-muted);
margin-bottom: 2rem;
}
/* ── Form elements ── */
label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-bottom: 0.3rem;
margin-top: 1rem;
}
input {
width: 100%;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
input:focus {
border-color: var(--color-primary);
}
/* ── Buttons ── */
button, .btn {
display: inline-block;
padding: 0.75rem 2.5rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
}
button:hover:not(:disabled), .btn:hover {
background: #c73652;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.full-width {
width: 100%;
margin-top: 1.75rem;
padding: 0.75rem;
}
button.secondary {
background: var(--color-secondary);
font-weight: normal;
padding: 0.3rem 0.7rem;
font-size: 0.75rem;
width: auto;
margin-top: 0.5rem;
}
button.secondary:hover {
background: #1a4a8a;
}
/* ── Messages ── */
.message {
margin-top: 1.25rem;
padding: 0.65rem 0.9rem;
border-radius: 6px;
font-size: 0.85rem;
display: none;
word-break: break-all;
}
.message.error {
background: #3d0f1a;
border: 2px solid var(--color-primary);
color: var(--color-primary);
}
.message.warning {
background: #3a2c0a;
border: 2px solid #f5c518;
color: #f5c518;
}
.message.success {
background: #0f3d1a;
border: 1px solid var(--color-success);
color: var(--color-success);
}
/* ── App layout ── */
body.app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--color-bg);
padding: 1.5rem 1.5rem 0;
gap: 0;
}
.app-wrapper {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
gap: 1.5rem;
align-items: stretch;
padding-bottom: 1.5rem;
width: 100%;
max-width: calc(240px + 1.5rem + 93.75rem);
margin-left: auto;
margin-right: auto;
}
.main {
flex: 1;
min-width: 0;
overflow-y: auto;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.content { padding: 2rem 1.5rem; flex: 1; }
/* ── Sidebar ── */
.sidebar-wrapper {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-self: stretch;
gap: 0.75rem;
z-index: 10;
transition: transform 0.25s ease;
}
.sidebar {
flex: 1;
min-height: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-scroll-area {
flex: 1;
overflow-y: auto;
}
.sidebar-logo-area {
padding: 1rem 1rem 0.5rem;
flex-shrink: 0;
}
.sidebar-logo-area a { display: block; line-height: 0; }
.sidebar-logo-area img {
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
.sidebar-desktop-profile { flex-shrink: 0; padding: 0.25rem 0; }
.sidebar-desktop-profile a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1.25rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.9rem;
font-weight: 600;
border-left: 3px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.sidebar-desktop-profile a:hover {
background: var(--color-secondary);
color: var(--color-primary);
border-left-color: var(--color-primary);
}
.sidebar-desktop-profile .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; }
.social-sidebar-logo-area {
padding: 1rem 1rem 0.5rem;
flex-shrink: 0;
}
.social-sidebar-logo-area img {
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
.sidebar-mobile-only { display: none; }
.sidebar ul { list-style: none; padding: 0.5rem 0; }
.sidebar ul li a,
.sidebar-footer ul li a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1.25rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
border-left: 3px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.sidebar ul li a:hover,
.sidebar ul li a.active,
.sidebar-footer ul li a:hover,
.sidebar-footer ul li a.active {
background: var(--color-secondary);
color: var(--color-primary);
border-left-color: var(--color-primary);
}
.sidebar ul li a .icon,
.sidebar-footer ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; }
.sidebar-profile-img {
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--color-secondary);
}
/* ── Burger (mobile only) ── */
.burger {
display: none;
position: fixed;
top: 0.75rem; right: 0.75rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 6px;
cursor: pointer;
color: var(--color-text);
padding: 0.35rem 0.5rem;
z-index: 110;
transition: background 0.15s;
}
.burger:hover { background: var(--color-secondary); }
.burger-icon { display: flex; flex-direction: column; gap: 5px; width: 22px; }
.burger-icon span {
display: block;
height: 2px;
background: var(--color-text);
border-radius: 2px;
transition: transform 0.25s, opacity 0.25s;
}
.burger.open .burger-icon span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.burger.open .burger-icon span:nth-child(2) { opacity: 0; }
.burger.open .burger-icon span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
/* ── Sidebar overlay ── */
.sidebar-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 90;
}
.sidebar-overlay.visible { display: block; }
/* ── Mobile ── */
@media (max-width: 768px) {
body.app {
height: auto;
min-height: 100vh;
overflow: visible;
padding: 0;
}
.app-wrapper {
flex-direction: column;
gap: 0;
padding-bottom: 0;
overflow: visible;
}
.sidebar-wrapper {
position: fixed;
top: 0; right: 0;
width: 240px;
height: 100vh;
gap: 0;
background: var(--color-bg);
border-left: 1px solid var(--color-secondary);
transform: translateX(100%);
align-self: auto;
z-index: 100;
padding: 0;
overflow-y: auto;
}
.sidebar-wrapper.open { transform: translateX(0); box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5); }
.sidebar {
flex: none;
border-radius: 0;
border: none;
box-shadow: none;
border-bottom: 1px solid var(--color-secondary);
}
.sidebar-footer {
border-radius: 0;
box-shadow: none;
border: none;
border-top: 1px solid var(--color-secondary);
}
.sidebar-logo-area { display: none; }
.sidebar-desktop-profile { display: none; }
.main {
border-radius: 0;
box-shadow: none;
border: none;
min-height: 100vh;
overflow-y: visible;
}
.burger { display: flex; }
.sidebar-mobile-only { display: block; }
}
/* ── Social Sidebar ── */
.social-sidebar {
width: 260px;
flex-shrink: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-self: flex-start;
position: sticky;
top: 1.5rem;
max-height: calc(100vh - 3rem);
overflow-y: auto;
}
.social-sidebar ul { list-style: none; padding: 0.5rem 0; display: flex; flex-direction: column; flex: 1; }
.social-sidebar ul li a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1.25rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
border-left: 3px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.social-sidebar ul li a:hover,
.social-sidebar ul li a.active {
background: var(--color-secondary);
color: var(--color-primary);
border-left-color: var(--color-primary);
}
.social-sidebar ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; }
.social-sidebar-title {
padding: 1rem 1.25rem 0.25rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-muted);
text-transform: uppercase;
flex-shrink: 0;
}
.social-badge {
margin-left: auto;
background: var(--color-primary);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.1rem 0.4rem;
line-height: 1.4;
flex-shrink: 0;
}
@media (max-width: 768px) {
.social-sidebar {
position: static;
width: 100%;
max-height: none;
border-radius: 0;
border: none;
border-top: 1px solid var(--color-secondary);
box-shadow: none;
align-self: auto;
}
}
/* ── Token box ── */
.token-box {
margin-top: 1.25rem;
padding: 0.65rem 0.9rem;
background: #0f1e3d;
border: 1px solid var(--color-secondary);
border-radius: 6px;
font-size: 0.75rem;
color: #aaa;
word-break: break-all;
display: none;
}
.token-box span {
display: block;
font-size: 0.7rem;
color: #666;
margin-bottom: 0.4rem;
}
/* ── Sidebar groups ── */
.sidebar-footer {
flex-shrink: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding: 0.5rem 0;
}
.sidebar-footer ul {
list-style: none;
padding: 0;
}
.sidebar-group-toggle {
cursor: pointer;
justify-content: space-between;
}
.sidebar-arrow {
margin-left: auto;
font-size: 0.7rem;
flex-shrink: 0;
transition: transform 0.2s;
}
.sidebar-group.open > a .sidebar-arrow {
transform: rotate(90deg);
}
.sidebar-sub {
list-style: none;
padding: 0;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.sidebar-group.open > .sidebar-sub {
max-height: 500px;
}
.sidebar .sidebar-sub li a {
padding: 0.55rem 1.25rem 0.55rem 2.5rem;
font-size: 0.9rem;
color: var(--color-muted);
}
/* ═══════════════════════════════════════════
TOP BAR
═══════════════════════════════════════════ */
.topbar {
flex-shrink: 0;
width: 100%;
max-width: calc(240px + 1.5rem + 93.75rem);
margin: 0 auto 1rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1rem;
}
/* Linker Bereich – Banner, gleiche Breite wie Sidebar */
.topbar-left {
width: 240px;
flex-shrink: 0;
align-self: stretch;
margin: -0.55rem 0 -0.55rem -1rem;
overflow: hidden;
border-radius: 11px 0 0 11px;
display: flex;
align-items: center;
padding: 5px 0 0 5px;
justify-content: center;
}
.topbar-left a {
display: flex;
align-items: center;
height: 100%;
}
.topbar-banner {
height: 3.5rem;
width: auto;
display: block;
}
/* ── Suche ── */
.topbar-search-wrap {
flex: 1;
max-width: 520px;
margin: 0 auto;
position: relative;
}
.topbar-search-wrap input {
background: var(--color-secondary);
border: 1px solid transparent;
border-radius: 8px;
padding: 0.46rem 0.9rem 0.46rem 2.2rem;
width: 100%;
font-size: 0.9rem;
transition: border-color 0.2s;
}
.topbar-search-wrap input:focus {
border-color: var(--color-primary);
}
.topbar-search-icon {
position: absolute;
left: 0.7rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-muted);
font-size: 0.85rem;
pointer-events: none;
line-height: 1;
}
.topbar-search-overlay {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 600;
max-height: 360px;
overflow-y: auto;
display: none;
}
.topbar-search-overlay.open { display: block; }
.topbar-search-hint {
padding: 0.75rem 1rem;
color: var(--color-muted);
font-size: 0.88rem;
}
.topbar-search-result {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
text-decoration: none;
color: var(--color-text);
transition: background 0.15s;
border-bottom: 1px solid var(--color-secondary);
}
.topbar-search-result:last-child { border-bottom: none; }
.topbar-search-result:hover { background: var(--color-secondary); }
.topbar-search-avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.topbar-search-avatar--placeholder {
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
/* ── Rechter Bereich ── */
.topbar-right {
display: flex;
align-items: center;
gap: 0.2rem;
flex-shrink: 0;
margin-left: auto;
}
.topbar-btn {
position: relative;
background: none;
border: none;
padding: 0.4rem 0.55rem;
border-radius: 8px;
cursor: pointer;
font-size: 1.25rem;
color: var(--color-text);
display: flex;
align-items: center;
gap: 0.4rem;
transition: background 0.15s;
width: auto;
font-weight: normal;
line-height: 1;
}
.topbar-btn:hover { background: var(--color-secondary); }
.topbar-badge {
position: absolute;
top: 1px;
right: 1px;
background: var(--color-primary);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.05rem 0.3rem;
min-width: 1rem;
text-align: center;
line-height: 1.6;
display: none;
pointer-events: none;
}
.topbar-avatar {
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--color-secondary);
display: block;
}
.topbar-avatar-placeholder {
font-size: 1.2rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.topbar-username {
font-size: 0.88rem;
font-weight: 600;
max-width: 130px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-profile-btn { gap: 0.5rem; padding: 0.3rem 0.55rem; }
/* ═══════════════════════════════════════════
TOPBAR PANELS (Overlays)
═══════════════════════════════════════════ */
.topbar-panel {
position: fixed;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.65);
z-index: 550;
width: 360px;
max-height: 500px;
display: none;
flex-direction: column;
overflow: hidden;
}
.topbar-panel.open { display: flex; }
.topbar-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.7rem 1rem;
font-weight: 700;
font-size: 0.92rem;
border-bottom: 1px solid var(--color-secondary);
flex-shrink: 0;
background: var(--color-card);
}
.topbar-panel-close {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
width: auto;
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.topbar-panel-close:hover { background: var(--color-secondary); color: var(--color-text); }
.topbar-panel-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.topbar-panel-footer {
border-top: 1px solid var(--color-secondary);
padding: 0.55rem 1rem;
flex-shrink: 0;
text-align: center;
background: var(--color-card);
}
.topbar-panel-footer a {
color: var(--color-primary);
font-size: 0.85rem;
text-decoration: none;
}
.topbar-panel-footer a:hover { text-decoration: underline; }
.topbar-panel-hint {
padding: 0.9rem 1rem;
color: var(--color-muted);
font-size: 0.88rem;
}
/* Einzel-Eintrag in Panel */
.topbar-panel-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--color-secondary);
text-decoration: none;
color: var(--color-text);
transition: background 0.15s;
cursor: pointer;
}
.topbar-panel-item:last-child { border-bottom: none; }
.topbar-panel-item:hover { background: var(--color-secondary); }
.topbar-item-avatar {
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.topbar-item-avatar--placeholder {
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.topbar-panel-item-body {
flex: 1;
min-width: 0;
}
.topbar-panel-item-sub {
font-size: 0.75rem;
color: var(--color-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.1rem;
}
.topbar-item-badge {
background: var(--color-primary);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.1rem 0.4rem;
flex-shrink: 0;
}
/* Benachrichtigungen */
.topbar-notif-item--unread { background: rgba(var(--color-primary-rgb, 231,57,84), 0.07); border-left: 3px solid var(--color-primary); }
.topbar-notif-item--unread:hover { background: rgba(var(--color-primary-rgb, 231,57,84), 0.12); }
.topbar-notif-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
margin-top: 0.25rem;
align-self: flex-start;
}
.topbar-mark-all-btn {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.8rem;
cursor: pointer;
width: auto;
padding: 0.2rem 0.4rem;
border-radius: 4px;
transition: background 0.15s;
}
.topbar-mark-all-btn:hover { background: var(--color-secondary); }
/* Einladungen */
.topbar-inv-card { align-items: center; }
.topbar-inv-btn {
padding: 0.3rem 0.6rem;
font-size: 0.78rem;
border-radius: 6px;
border: none;
cursor: pointer;
width: auto;
margin: 0;
text-decoration: none;
display: inline-block;
font-weight: 600;
line-height: 1.5;
transition: opacity 0.15s;
}
.topbar-inv-btn:hover { opacity: 0.85; }
.topbar-inv-btn--decline { background: #c0392b; color: #fff; }
.topbar-inv-btn--accept { background: var(--color-success, #27ae60); color: #fff; }
/* Profil-Panel */
.topbar-profile-body { display: flex; flex-direction: column; }
.topbar-profile-card {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 1rem 1rem 0.75rem;
}
.topbar-profile-nav {
display: flex;
flex-direction: column;
padding: 0.4rem 0;
}
.topbar-profile-link {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.6rem 1rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.9rem;
transition: background 0.15s;
}
.topbar-profile-link:hover { background: var(--color-secondary); }
.topbar-profile-link--danger { color: var(--color-primary); }
/* ── Mobile: Topbar ausblenden ── */
@media (max-width: 768px) {
.topbar { display: none; }
}

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passwort vergessen – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
align-items: center;
justify-content: center;
}
.overlay.active {
display: flex;
}
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
max-width: 340px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
text-align: center;
}
.modal p {
color: var(--color-text);
font-size: 0.95rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="card">
<img src="icon.png" alt="Logo">
<h1>Passwort vergessen</h1>
<p class="subtitle">Gib deine E-Mail-Adresse ein. Falls sie bei uns registriert ist, erhältst du einen Link zum Zurücksetzen.</p>
<label for="email">E-Mail</label>
<input type="email" id="email" placeholder="deine@email.de" autocomplete="email" />
<button class="full-width" id="submitBtn" onclick="submit()">Link anfordern</button>
<div class="message" id="message"></div>
<p style="text-align:center; margin-top:1.25rem; font-size:0.85rem;">
<a href="/login.html" style="color:var(--color-primary);">Zurück zum Login</a>
</p>
</div>
<div class="overlay" id="overlay">
<div class="modal">
<p>Falls diese E-Mail-Adresse bei uns registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen deines Passworts.</p>
<button class="full-width" onclick="goToLogin()">Zum Login</button>
</div>
</div>
<script>
document.addEventListener('keydown', e => {
if (e.key === 'Enter') submit();
});
async function submit() {
const email = document.getElementById('email').value.trim();
const btn = document.getElementById('submitBtn');
if (!email) {
showMessage('Bitte E-Mail-Adresse eingeben.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
hideMessage();
try {
await fetch('/password-reset/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
} catch (err) {
console.error(err);
}
document.getElementById('overlay').classList.add('active');
}
function goToLogin() {
window.location.href = '/login.html';
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BDSM Game – Einladung – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.invite-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 14px;
padding: 2rem;
text-align: center;
width: 100%;
}
.invite-icon { font-size: 2.5rem; margin-bottom: 1rem; }
.invite-title { font-size: 1.2rem; font-weight: 700; margin-bottom: 0.5rem; }
.invite-sub { font-size: 0.9rem; color: var(--color-muted); margin-bottom: 2rem; line-height: 1.6; }
.invite-actions { display: flex; flex-direction: column; gap: 0.75rem; }
.invite-actions button { width: 100%; padding: 0.85rem; }
.decline-btn {
background: transparent;
border: none;
color: var(--color-muted);
font-size: 0.82rem;
cursor: pointer;
text-decoration: underline;
padding: 0.25rem;
margin-top: 0.5rem;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div id="loading" style="text-align:center;color:var(--color-muted);padding:3rem 0;">Einladung wird geladen…</div>
<div class="invite-card" id="card" style="display:none;">
<div class="invite-icon">⛓️</div>
<div class="invite-title" id="title"></div>
<div class="invite-sub" id="sub"></div>
<div class="message" id="message" style="display:none;margin-bottom:1rem;"></div>
<div class="invite-actions" id="actions"></div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const params = new URLSearchParams(location.search);
const einladungId = params.get('id');
if (!einladungId) window.location.replace('/userhome.html');
let einladung = null;
async function laden() {
try {
const res = await fetch(`/bdsm/einladung/${einladungId}`);
if (!res.ok) { zeigeFehler('Einladung nicht gefunden.'); return; }
einladung = await res.json();
document.getElementById('loading').style.display = 'none';
document.getElementById('card').style.display = '';
if (einladung.status === 'ACCEPTED_OWN' || einladung.status === 'ACCEPTED_HOST') {
zeigeBestaetigt();
return;
}
if (einladung.status === 'DECLINED' || einladung.status === 'CANCELLED') {
zeigeFehler('Diese Einladung ist nicht mehr gültig.');
return;
}
document.getElementById('title').textContent = `${einladung.inviterName || 'Jemand'} lädt dich ein`;
document.getElementById('sub').textContent = 'Du wurdest zu einem BDSM Game eingeladen. Wie möchtest du mitspielen?';
const actions = document.getElementById('actions');
actions.innerHTML = `
<button onclick="antworten(true, 'OWN_DEVICE')">Am eigenen Gerät mitspielen</button>
<button class="secondary" onclick="antworten(true, 'HOST_DEVICE')">Am Gerät von ${einladung.inviterName || 'der einladenden Person'}</button>
<button class="decline-btn" onclick="antworten(false, null)">Einladung ablehnen</button>`;
} catch (e) {
zeigeFehler('Fehler beim Laden der Einladung.');
}
}
async function antworten(accepted, mode) {
document.getElementById('actions').innerHTML = '<div style="color:var(--color-muted);font-size:0.9rem;">Wird gespeichert…</div>';
try {
const res = await fetch(`/bdsm/einladung/${einladungId}/antwort`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted, mode }),
});
if (!res.ok) throw new Error();
if (!accepted) {
document.getElementById('title').textContent = 'Einladung abgelehnt';
document.getElementById('sub').textContent = 'Du hast die Einladung abgelehnt.';
document.getElementById('actions').innerHTML = '<button onclick="window.location.href=\'/userhome.html\'">Zur Startseite</button>';
} else if (mode === 'OWN_DEVICE') {
window.location.replace(`/games/bdsm/neubdsm.html`);
} else {
zeigeBestaetigt();
}
} catch (_) {
document.getElementById('actions').innerHTML = '';
zeigeFehler('Fehler beim Speichern der Antwort.');
}
}
function zeigeBestaetigt() {
document.getElementById('title').textContent = 'Einladung angenommen';
document.getElementById('sub').textContent = 'Du spielst am Gerät der einladenden Person mit. Das Spiel wird dort von ihr gestartet.';
document.getElementById('actions').innerHTML = '<button onclick="window.location.href=\'/userhome.html\'">Zur Startseite</button>';
}
function zeigeFehler(text) {
document.getElementById('loading').style.display = 'none';
document.getElementById('card').style.display = '';
document.getElementById('title').textContent = 'Hinweis';
document.getElementById('sub').textContent = text;
document.getElementById('actions').innerHTML = '<button onclick="window.location.href=\'/userhome.html\'">Zur Startseite</button>';
}
laden();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/games/bdsm/neubdsm.html">
<title>BDSM Game – xXx Sphere</title>
</head>
<body>
<script>window.location.replace('/games/bdsm/neubdsm.html');</script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BDSM Game – Info – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="app">
<div class="main">
<div class="content">
<h1>BDSM Game</h1>
<p>Informationen zum BDSM Game folgen hier.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,413 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Community Votes – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.page-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.35rem;
}
.page-subtitle {
font-size: 0.88rem;
color: var(--color-muted);
margin-bottom: 1.5rem;
}
#feed {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ── Verifikations-Karte ── */
.vote-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
overflow: hidden;
}
.vote-card-media { position: relative; }
.vote-card-img {
width: 100%;
max-height: 420px;
object-fit: contain;
display: block;
background: #000;
}
.vote-card-code {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
font-family: monospace;
font-size: 2rem;
font-weight: 700;
letter-spacing: 0.25em;
color: #fff;
background: rgba(0,0,0,0.55);
padding: 0.4rem 1.1rem;
border-radius: 8px;
pointer-events: none;
white-space: nowrap;
}
.vote-card-body {
padding: 0.85rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.vote-meta { font-size: 0.8rem; color: var(--color-muted); }
.vote-actions { display: flex; align-items: center; gap: 0.75rem; }
.vote-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: none;
border: 1px solid var(--color-secondary);
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.9rem;
cursor: pointer;
color: var(--color-text);
margin: 0;
width: auto;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.vote-btn:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
background: none;
}
.vote-btn.voted-up { border-color: #2ecc71; color: #2ecc71; background: rgba(46,204,113,0.08); }
.vote-btn.voted-down { border-color: #e74c3c; color: #e74c3c; background: rgba(231,76,60,0.08); }
.vote-btn:disabled { opacity: 0.55; cursor: not-allowed; pointer-events: none; }
.vote-count { font-weight: 600; font-size: 0.88rem; }
/* ── Aufgaben-Abstimmungs-Karte ── */
.task-vote-card {
background: var(--color-card);
border: 1px solid rgba(52,152,219,0.35);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.task-vote-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
gap: 0.4rem;
}
.task-vote-lockee { font-weight: 600; font-size: 0.92rem; }
.task-vote-expires { font-size: 0.78rem; color: var(--color-muted); }
.task-vote-options { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.5rem; }
.task-vote-btn {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(52,152,219,0.08);
border: 1px solid rgba(52,152,219,0.25);
border-radius: 7px;
padding: 0.45rem 0.7rem;
cursor: pointer;
color: var(--color-text);
text-align: left;
font-size: 0.85rem;
transition: background 0.15s, border-color 0.15s;
width: 100%;
margin: 0;
}
.task-vote-btn:hover:not(:disabled) {
background: rgba(52,152,219,0.22);
border-color: rgba(52,152,219,0.5);
}
.task-vote-btn.my-vote {
border-color: var(--color-primary);
background: rgba(52,152,219,0.18);
}
.task-vote-btn:disabled { cursor: default; }
.task-vote-count {
font-size: 0.78rem;
color: var(--color-muted);
white-space: nowrap;
margin-left: 0.5rem;
flex-shrink: 0;
}
.task-vote-own-hint {
font-size: 0.78rem;
color: var(--color-muted);
font-style: italic;
margin-top: 0.4rem;
text-align: center;
}
/* ── Pranger-Karte ── */
.pillory-card {
background: var(--color-card);
border: 1px solid rgba(231,76,60,0.35);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.pillory-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
flex-wrap: wrap;
gap: 0.4rem;
}
.pillory-lockee { font-weight: 600; font-size: 0.92rem; }
.pillory-date { font-size: 0.78rem; color: var(--color-muted); }
.pillory-reason {
font-size: 0.82rem;
color: #e74c3c;
margin-bottom: 0.25rem;
}
.pillory-message { font-size: 0.88rem; }
.empty-hint {
color: var(--color-muted);
font-size: 0.9rem;
text-align: center;
padding: 2rem 0;
}
.load-spinner {
text-align: center;
color: var(--color-muted);
font-size: 0.85rem;
padding: 1rem 0;
}
.sentinel { height: 1px; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="page-title">Community Votes</div>
<div class="page-subtitle">Verifikationen, Aufgaben-Abstimmungen &amp; Pranger</div>
<div id="feed"></div>
<div class="load-spinner" id="loadSpinner" style="display:none;">Lädt…</div>
<div class="empty-hint" id="emptyHint" style="display:none;">Noch keine Community-Abstimmungen vorhanden.</div>
<div class="sentinel" id="sentinel"></div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtDateTime(isoStr) {
return new Date(isoStr).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
// ── Verifikations-Karte ────────────────────────────────────────────────────
function buildVerCard(base, detail) {
const voted = detail.isOwnLock || detail.myVote !== null && detail.myVote !== undefined;
const votedUp = !detail.isOwnLock && detail.myVote === true;
const votedDn = !detail.isOwnLock && detail.myVote === false;
const id = base.displayId;
const card = document.createElement('div');
card.className = 'vote-card';
card.innerHTML = `
<div class="vote-card-media">
<img class="vote-card-img" src="data:image/jpeg;base64,${detail.image}" alt="Verifikationsbild">
<div class="vote-card-code">${esc(detail.code)}</div>
</div>
<div class="vote-card-body">
<div class="vote-meta">Verifikation · ${esc(base.lockeeName)} · ${fmtDateTime(base.createdAt)}</div>
<div class="vote-actions">
<button class="vote-btn ${votedUp ? 'voted-up' : ''}" id="up-${id}"
${voted ? 'disabled' : ''}
onclick="castVerVote('${id}', true)">
👍 <span class="vote-count" id="upcount-${id}">${detail.upvotes}</span>
</button>
<button class="vote-btn ${votedDn ? 'voted-down' : ''}" id="dn-${id}"
${voted ? 'disabled' : ''}
onclick="castVerVote('${id}', false)">
👎 <span class="vote-count" id="dncount-${id}">${detail.downvotes}</span>
</button>
</div>
</div>`;
return card;
}
async function castVerVote(displayId, upvote) {
document.getElementById('up-' + displayId).disabled = true;
document.getElementById('dn-' + displayId).disabled = true;
const res = await fetch(`/games/chastity/community/verification/${displayId}/vote/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upvote })
});
if (res.ok || res.status === 202) {
const countEl = document.getElementById(upvote ? 'upcount-' + displayId : 'dncount-' + displayId);
countEl.textContent = parseInt(countEl.textContent) + 1;
document.getElementById((upvote ? 'up-' : 'dn-') + displayId)
.classList.add(upvote ? 'voted-up' : 'voted-down');
}
}
// ── Aufgaben-Abstimmungs-Karte ─────────────────────────────────────────────
function buildTaskVoteCard(base, detail) {
const isOwn = detail.isOwnLock;
const alreadyVoted = detail.entries.some(e => e.ownVote);
const id = base.displayId;
let optionsHtml = '';
(detail.entries || []).forEach((e, i) => {
const desc = e.description
? `<div style="font-size:0.75rem;color:var(--color-muted);margin-top:0.1rem;">${esc(e.description)}</div>`
: '';
const mins = e.minutes > 0
? ` <span style="font-size:0.75rem;color:var(--color-muted);">⏱ ${e.minutes} Min.</span>`
: '';
optionsHtml += `<button class="task-vote-btn ${e.ownVote ? 'my-vote' : ''}"
id="tvbtn-${id}-${i}"
${(alreadyVoted || isOwn) ? 'disabled' : ''}
onclick="castTaskVote('${id}', ${i})">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;">${esc(e.title)}${mins}</div>
${desc}
</div>
<span class="task-vote-count" id="tvcount-${id}-${i}">${e.votes} Stimme${e.votes !== 1 ? 'n' : ''}</span>
</button>`;
});
const ownHint = isOwn
? `<div class="task-vote-own-hint">Das ist dein eigenes Lock – du kannst hier nicht abstimmen.</div>`
: '';
const card = document.createElement('div');
card.className = 'task-vote-card';
card.innerHTML = `
<div class="task-vote-header">
<span class="task-vote-lockee">🃏 ${esc(base.lockeeName)}</span>
<span class="task-vote-expires">Endet: ${fmtDateTime(detail.expiresAt)}</span>
</div>
<div class="task-vote-options">${optionsHtml}</div>
${ownHint}`;
return card;
}
async function castTaskVote(displayId, taskIndex) {
document.querySelectorAll(`[id^="tvbtn-${displayId}-"]`).forEach(btn => btn.disabled = true);
const res = await fetch(`/games/chastity/community/taskvote/${displayId}/vote/${taskIndex}`, { method: 'POST' });
if (res.ok || res.status === 204) {
const countEl = document.getElementById(`tvcount-${displayId}-${taskIndex}`);
if (countEl) {
const next = (parseInt(countEl.textContent) || 0) + 1;
countEl.textContent = `${next} Stimme${next !== 1 ? 'n' : ''}`;
}
document.getElementById(`tvbtn-${displayId}-${taskIndex}`)?.classList.add('my-vote');
}
}
// ── Pranger-Karte ──────────────────────────────────────────────────────────
const PILLORY_LABELS = {
HYGIENE_OPENING_EXEEDED: 'Hygiene-Öffnung überschritten',
KEYHOLDER_DESCESSION: 'Keyholder hat aufgegeben'
};
function buildPilloryCard(base, detail) {
const card = document.createElement('div');
card.className = 'pillory-card';
card.innerHTML = `
<div class="pillory-header">
<span class="pillory-lockee">🔒 ${esc(base.lockeeName)}</span>
<span class="pillory-date">${fmtDateTime(base.createdAt)}</span>
</div>
<div class="pillory-reason">⚠️ ${esc(PILLORY_LABELS[detail.reason] || detail.reason)}</div>
${detail.message ? `<div class="pillory-message">${esc(detail.message)}</div>` : ''}`;
return card;
}
// ── Unified feed mit Paging ────────────────────────────────────────────────
let page = 0;
let exhausted = false;
let loading = false;
let rendered = 0;
async function fetchDetail(base) {
const urls = {
VERIFICATION: `/games/chastity/community/verification/${base.displayId}`,
TASK_VOTE: `/games/chastity/community/taskvote/${base.displayId}`,
PILLORY: `/games/chastity/community/pillory/${base.displayId}`
};
const url = urls[base.type];
if (!url) return null;
try {
const r = await fetch(url);
return r.ok ? await r.json() : null;
} catch(e) { return null; }
}
function buildCard(base, detail) {
if (!detail) return null;
if (base.type === 'VERIFICATION') return buildVerCard(base, detail);
if (base.type === 'TASK_VOTE') return buildTaskVoteCard(base, detail);
if (base.type === 'PILLORY') return buildPilloryCard(base, detail);
return null;
}
async function loadMore() {
if (loading || exhausted) return;
loading = true;
document.getElementById('loadSpinner').style.display = '';
let pageData;
try {
const r = await fetch(`/games/chastity/community?page=${page}&sort=createdAt,desc`);
if (!r.ok) { loading = false; document.getElementById('loadSpinner').style.display = 'none'; return; }
pageData = await r.json();
} catch(e) { loading = false; document.getElementById('loadSpinner').style.display = 'none'; return; }
const items = pageData.content || [];
if (pageData.last) exhausted = true;
page++;
const details = await Promise.all(items.map(fetchDetail));
document.getElementById('loadSpinner').style.display = 'none';
loading = false;
const feed = document.getElementById('feed');
items.forEach((base, i) => {
const card = buildCard(base, details[i]);
if (card) { feed.appendChild(card); rendered++; }
});
if (rendered === 0 && exhausted) {
document.getElementById('emptyHint').style.display = '';
}
}
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadMore();
}, { rootMargin: '200px' });
observer.observe(document.getElementById('sentinel'));
loadMore();
</script>
</body>
</html>

View File

@@ -0,0 +1,528 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vorlagen entdecken – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Suche ── */
.search-bar {
display: flex; gap: 0.6rem; margin-bottom: 1.5rem; align-items: center;
}
.search-bar input {
flex: 1; padding: 0.55rem 0.85rem; border-radius: 8px;
border: 1px solid var(--color-secondary); background: var(--color-card);
color: var(--color-text); font-size: 0.95rem;
}
.search-bar button {
width: auto; padding: 0.55rem 1.2rem; font-size: 0.9rem;
}
/* ── Template-Karte ── */
.tpl-card {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 10px; padding: 1rem; margin-bottom: 0.75rem;
cursor: pointer; transition: border-color 0.15s;
}
.tpl-card:hover { border-color: var(--color-primary); }
.tpl-card.own-template { border-left: 3px solid #3498db; }
.tpl-card-header {
display: flex; align-items: flex-start;
justify-content: space-between; gap: 0.75rem;
}
.tpl-icon {
width: 2.4rem; height: 2.4rem; flex-shrink: 0;
border-radius: 8px; background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
}
.tpl-name { font-weight: 700; font-size: 1rem; margin-bottom: 0.2rem; }
.tpl-meta { font-size: 0.78rem; color: var(--color-muted); line-height: 1.5; }
.tpl-badges {
display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem;
}
.tpl-badge {
font-size: 0.7rem; border-radius: 5px; padding: 0.18rem 0.55rem;
border: 1px solid var(--color-secondary); color: var(--color-muted);
background: var(--color-secondary);
}
.tpl-badge.blue { background: rgba(52,152,219,0.12); border-color: rgba(52,152,219,0.35); color: #3498db; }
.tpl-badge.green { background: rgba(46,204,113,0.12); border-color: rgba(46,204,113,0.35); color: #2ecc71; }
.tpl-badge.orange { background: rgba(231,152,52,0.12); border-color: rgba(231,152,52,0.35); color: #e67e22; }
.tpl-badge.own { background: rgba(52,152,219,0.15); border-color: rgba(52,152,219,0.5); color: #3498db; font-weight: 600; }
/* ── Abonnieren-Button ── */
.btn-sub {
white-space: nowrap; width: auto; padding: 0.4rem 0.9rem; font-size: 0.82rem;
font-weight: 600; border-radius: 7px; cursor: pointer; flex-shrink: 0;
border: 1px solid var(--color-secondary);
background: none; color: var(--color-muted);
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-sub:hover:not(:disabled) {
background: rgba(52,152,219,0.12); border-color: rgba(52,152,219,0.45); color: #3498db;
}
.btn-sub.subscribed {
background: rgba(46,204,113,0.12); border-color: rgba(46,204,113,0.4); color: #2ecc71;
}
.btn-sub.subscribed:hover:not(:disabled) {
background: rgba(231,76,60,0.1); border-color: rgba(231,76,60,0.35); color: #e74c3c;
}
.btn-sub:disabled { opacity: 0.45; cursor: not-allowed; }
/* ── Detail-Modal ── */
.detail-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 400;
align-items: flex-start; justify-content: center;
padding: 2rem 1rem; overflow-y: auto;
}
.detail-backdrop.open { display: flex; }
.detail-box {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 14px; padding: 1.75rem 1.5rem 1.5rem;
max-width: 500px; width: 100%; position: relative;
display: flex; flex-direction: column; gap: 1rem;
}
.detail-section {
background: var(--color-secondary); border-radius: 8px;
padding: 0.85rem 1rem;
}
.detail-section-title {
font-size: 0.72rem; font-weight: 700; color: var(--color-muted);
text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 0.5rem;
}
.detail-row {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 0.88rem; padding: 0.2rem 0; gap: 1rem;
}
.detail-row-label { color: var(--color-muted); flex-shrink: 0; }
.detail-row-val { color: var(--color-text); text-align: right; }
.detail-task-item {
font-size: 0.85rem; color: var(--color-text); padding: 0.3rem 0;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.detail-task-item:last-child { border-bottom: none; }
.detail-wheel-entry {
display: inline-flex; align-items: center; gap: 0.3rem;
font-size: 0.78rem; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 5px;
padding: 0.2rem 0.55rem; margin: 0.2rem;
}
.detail-footer {
display: flex; gap: 0.75rem; justify-content: flex-end; flex-wrap: wrap;
border-top: 1px solid var(--color-secondary); padding-top: 1rem;
}
.btn-close-detail {
background: none; border: 1px solid var(--color-secondary);
color: var(--color-muted); padding: 0.5rem 1.1rem; border-radius: 7px;
cursor: pointer; font-size: 0.88rem; width: auto;
}
.btn-subscribe-detail {
padding: 0.5rem 1.25rem; border-radius: 7px; cursor: pointer;
font-size: 0.88rem; font-weight: 600; width: auto; border: none;
background: var(--color-primary); color: #fff;
}
.btn-subscribe-detail.subscribed {
background: rgba(46,204,113,0.15); border: 1px solid rgba(46,204,113,0.4); color: #2ecc71;
}
.detail-author-avatar {
width: 52px; height: 52px; border-radius: 50%;
border: 2px solid var(--color-secondary);
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; color: var(--color-muted);
overflow: hidden; flex-shrink: 0;
}
.detail-author-avatar img { width: 100%; height: 100%; object-fit: cover; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h2 style="margin-bottom:1.25rem;">🔍 Vorlagen entdecken</h2>
<!-- Suchleiste -->
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Nach Namen suchen…"
onkeydown="if(event.key==='Enter') doSearch()">
<button onclick="doSearch()">Suchen</button>
</div>
<!-- Ergebnisliste -->
<div id="templateList"></div>
<div id="scrollSentinel" style="height:1px;"></div>
<p id="listLoading" style="display:none;text-align:center;color:var(--color-muted);padding:1rem;">Laden…</p>
<p id="listEmpty" style="display:none;color:var(--color-muted);">Keine öffentlichen Vorlagen gefunden.</p>
</div>
</div>
<!-- Detail-Modal -->
<div class="detail-backdrop" id="detailModal" onclick="closeDetail()">
<div class="detail-box" onclick="event.stopPropagation()">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;">
<div style="display:flex;align-items:flex-start;gap:0.85rem;">
<div class="detail-author-avatar" id="detailAuthorAvatar" style="display:none;">◉</div>
<div>
<h2 id="detailTitle" style="margin:0 0 0.25rem;font-size:1.2rem;"></h2>
<div id="detailMeta" style="font-size:0.82rem;color:var(--color-muted);"></div>
</div>
</div>
<button onclick="closeDetail()" style="background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0;flex-shrink:0;">✕</button>
</div>
<div id="detailBody"></div>
<div class="detail-footer">
<button class="btn-close-detail" onclick="closeDetail()">Schließen</button>
<button class="btn-subscribe-detail" id="detailSubscribeBtn" onclick="toggleSubscribeDetail()"></button>
</div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
let page = 0;
let isLastPage = false;
let isLoading = false;
let currentSearch = '';
let _detailTemplate = null;
function fmtMinutes(min) {
if (!min) return '–';
const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
return [d && d + 'T', h && h + 'Std', m && m + 'Min'].filter(Boolean).join(' ') || '0Min';
}
// ── Laden ──────────────────────────────────────────────────────────────────
async function loadNextPage() {
if (isLoading || isLastPage) return;
isLoading = true;
document.getElementById('listLoading').style.display = '';
try {
const q = encodeURIComponent(currentSearch);
const res = await fetch(`/templates/public?page=${page}&size=10&q=${q}`);
if (!res.ok) return;
const data = await res.json();
data.content.forEach(t => appendCard(t));
isLastPage = !data.hasMore;
page = data.page + 1;
if (page === 1 && data.content.length === 0) {
document.getElementById('listEmpty').style.display = '';
}
} catch(e) { console.error(e); }
finally {
isLoading = false;
document.getElementById('listLoading').style.display = 'none';
}
}
function resetList() {
page = 0; isLastPage = false; isLoading = false;
document.getElementById('templateList').innerHTML = '';
document.getElementById('listEmpty').style.display = 'none';
loadNextPage();
}
function doSearch() {
currentSearch = document.getElementById('searchInput').value.trim();
resetList();
}
// ── Karte ─────────────────────────────────────────────────────────────────
function appendCard(t) {
const list = document.getElementById('templateList');
const isCard = t.lockType === 'CARDLOCK';
const card = document.createElement('div');
card.className = 'tpl-card' + (t.isOwnTemplate ? ' own-template' : '');
card.dataset.templateId = t.templateId;
const metaParts = [
isCard ? '🃏 Karten-Lock' : '⏱ Zeit-Lock',
t.authorName ? 'von ' + esc(t.authorName) : null,
t.subscriberCount + ' Abo(s)',
].filter(Boolean);
const badges = buildBadges(t);
const subBtnCls = t.isOwnTemplate ? '' : (t.isSubscribed ? 'subscribed' : '');
const subBtnLabel = t.isOwnTemplate ? 'Eigene' : (t.isSubscribed ? '✓ Abonniert' : '+ Abonnieren');
card.innerHTML = `
<div class="tpl-card-header">
<div class="tpl-icon">${isCard ? '🃏' : '⏱'}</div>
<div style="flex:1;min-width:0;">
<div class="tpl-name">${esc(t.name || 'Ohne Namen')}</div>
<div class="tpl-meta">${metaParts.join(' · ')}</div>
</div>
<button class="btn-sub ${subBtnCls}" ${t.isOwnTemplate ? 'disabled' : ''}
onclick="event.stopPropagation();toggleSubscribe('${t.templateId}',this)">
${subBtnLabel}
</button>
</div>
<div class="tpl-badges">${badges}</div>`;
card.addEventListener('click', () => openDetail(t));
list.appendChild(card);
}
function buildBadges(t) {
const b = [];
if (t.lockType === 'TIMELOCK') {
if (t.minTimeInMinutes || t.maxTimeInMinutes) {
const min = fmtMinutes(t.minTimeInMinutes), max = fmtMinutes(t.maxTimeInMinutes);
b.push(`<span class="tpl-badge blue">⏱ ${min} – ${max}</span>`);
}
if (t.spinningWheelEntries && t.spinningWheelEntries.length)
b.push(`<span class="tpl-badge orange">🎡 Glücksrad (${t.spinningWheelEntries.length})</span>`);
if (t.penaltyType)
b.push(`<span class="tpl-badge orange">⚠ Strafe</span>`);
}
if (t.taskCount > 0)
b.push(`<span class="tpl-badge">🎯 ${t.taskCount} Aufgabe(n)</span>`);
if (t.hygieneEnabled)
b.push(`<span class="tpl-badge">🚿 Hygiene</span>`);
if (t.requiresVerification)
b.push(`<span class="tpl-badge">📷 Verifikation</span>`);
if (t.isOwnTemplate)
b.push(`<span class="tpl-badge own">Meine Vorlage</span>`);
return b.join('');
}
// ── Abonnieren (Listenansicht) ─────────────────────────────────────────────
async function toggleSubscribe(id, btn) {
const isSubscribed = btn.classList.contains('subscribed');
btn.disabled = true;
try {
const method = isSubscribed ? 'DELETE' : 'POST';
const res = await fetch(`/templates/${id}/subscribe`, { method });
if (res.ok || res.status === 204) {
if (isSubscribed) {
btn.classList.remove('subscribed');
btn.textContent = '+ Abonnieren';
// Update card data
const card = btn.closest('.tpl-card');
updateCardSubscriberCount(card, -1);
} else {
btn.classList.add('subscribed');
btn.textContent = '✓ Abonniert';
const card = btn.closest('.tpl-card');
updateCardSubscriberCount(card, +1);
}
}
} catch(e) { /* ignore */ }
btn.disabled = false;
}
function updateCardSubscriberCount(card, delta) {
// Update the meta text - find the "X Abo(s)" part
const meta = card.querySelector('.tpl-meta');
if (!meta) return;
meta.innerHTML = meta.innerHTML.replace(/(\d+) Abo\(s\)/, (_, n) => `${Math.max(0, parseInt(n) + delta)} Abo(s)`);
}
// ── Detail-Modal ───────────────────────────────────────────────────────────
function openDetail(t) {
_detailTemplate = t;
document.getElementById('detailTitle').textContent = t.name || 'Ohne Namen';
const avatarEl = document.getElementById('detailAuthorAvatar');
if (t.authorProfilePicture) {
avatarEl.innerHTML = `<img src="data:image/png;base64,${t.authorProfilePicture}" alt="${esc(t.authorName || '')}">`;
avatarEl.style.display = '';
} else {
avatarEl.innerHTML = '◉';
avatarEl.style.display = 'none';
}
const metaParts = [
t.lockType === 'CARDLOCK' ? '🃏 Karten-Lock' : '⏱ Zeit-Lock',
t.authorName ? 'von ' + t.authorName : null,
t.subscriberCount + ' Abonnent(en)',
].filter(Boolean);
document.getElementById('detailMeta').textContent = metaParts.join(' · ');
document.getElementById('detailBody').innerHTML = buildDetailBody(t);
const btn = document.getElementById('detailSubscribeBtn');
if (t.isOwnTemplate) {
btn.style.display = 'none';
} else {
btn.style.display = '';
btn.className = 'btn-subscribe-detail' + (t.isSubscribed ? ' subscribed' : '');
btn.textContent = t.isSubscribed ? '✓ Abonniert' : '+ Abonnieren';
}
document.getElementById('detailModal').classList.add('open');
}
function closeDetail() {
document.getElementById('detailModal').classList.remove('open');
_detailTemplate = null;
}
async function toggleSubscribeDetail() {
if (!_detailTemplate) return;
const t = _detailTemplate;
const btn = document.getElementById('detailSubscribeBtn');
btn.disabled = true;
const isSubscribed = t.isSubscribed;
try {
const method = isSubscribed ? 'DELETE' : 'POST';
const res = await fetch(`/templates/${t.templateId}/subscribe`, { method });
if (res.ok || res.status === 204) {
t.isSubscribed = !isSubscribed;
t.subscriberCount = Math.max(0, (t.subscriberCount || 0) + (isSubscribed ? -1 : 1));
if (isSubscribed) {
btn.className = 'btn-subscribe-detail';
btn.textContent = '+ Abonnieren';
} else {
btn.className = 'btn-subscribe-detail subscribed';
btn.textContent = '✓ Abonniert';
}
// Update card in list
const card = document.querySelector(`.tpl-card[data-template-id="${t.templateId}"]`);
if (card) {
const subBtn = card.querySelector('.btn-sub');
if (subBtn) {
if (isSubscribed) { subBtn.classList.remove('subscribed'); subBtn.textContent = '+ Abonnieren'; }
else { subBtn.classList.add('subscribed'); subBtn.textContent = '✓ Abonniert'; }
}
updateCardSubscriberCount(card, isSubscribed ? -1 : 1);
}
}
} catch(e) { /* ignore */ }
btn.disabled = false;
}
// ── Detail-Body aufbauen ───────────────────────────────────────────────────
function buildDetailBody(t) {
const sections = [];
if (t.lockType === 'TIMELOCK') {
sections.push(buildSection('⏱ Zeit-Einstellungen', [
['Mindestdauer', fmtMinutes(t.minTimeInMinutes)],
['Maximaldauer', fmtMinutes(t.maxTimeInMinutes)],
['Endzeit sichtbar', t.endTimeVisible ? 'Ja' : 'Nein'],
]));
if (t.spinningWheelEntries && t.spinningWheelEntries.length) {
const WHEEL_LABELS = {
ADD_TIME: '+ Zeit', REMOVE_TIME: '− Zeit', FREEZE_TIME: '❄ Einfrieren für',
FREEZE: '🧊 Einfrieren (∞)', UNFREEZE: '🌊 Auftauen', TASK: '🎯 Aufgabe', TEXT: '💬 Text',
};
const entries = t.spinningWheelEntries.map(e => {
const label = WHEEL_LABELS[e.type] || e.type;
const extra = e.intVal ? ' ' + fmtMinutes(e.intVal) : (e.stringVal ? ' «' + e.stringVal + '»' : '');
return `<span class="detail-wheel-entry">${label}${extra}</span>`;
}).join('');
sections.push(`<div class="detail-section">
<div class="detail-section-title">🎡 Glücksrad (${t.spinningWheelEntries.length} Einträge${t.spinsEveryMinutes ? ', alle ' + fmtMinutes(t.spinsEveryMinutes) : ''})</div>
<div>${entries}</div>
</div>`);
}
if (t.penaltyType) {
const penaltyLabels = { ADD: 'Zeit hinzufügen', FREEZE: 'Einfrieren', PILLORY: 'Pranger' };
sections.push(buildSection('⚠ Strafmaß', [
['Typ', penaltyLabels[t.penaltyType] || t.penaltyType],
['Wert', t.penaltyValue ? fmtMinutes(t.penaltyValue) : '–'],
]));
}
if (t.taskEveryMinutes || t.minTasksPerDay) {
sections.push(buildSection('🎯 Aufgaben-Timing', [
['Intervall', t.taskEveryMinutes ? fmtMinutes(t.taskEveryMinutes) : '–'],
['Min./Tag', t.minTasksPerDay ? t.minTasksPerDay + ' Aufgabe(n)' : '–'],
]));
}
}
if (t.lockType === 'CARDLOCK') {
const rows = [];
const allKeys = new Set([
...Object.keys(t.cardCountsMin || {}),
...Object.keys(t.cardCountsMax || {}),
]);
allKeys.forEach(k => {
const mn = (t.cardCountsMin || {})[k] ?? 0;
const mx = (t.cardCountsMax || {})[k] ?? 0;
if (mn > 0 || mx > 0) rows.push([k, `${mn} – ${mx}`]);
});
if (rows.length)
sections.push(buildSection('🃏 Karten', rows));
sections.push(buildSection('⚙ Karten-Einstellungen', [
['Zieh-Intervall', t.pickEveryMinute ? fmtMinutes(t.pickEveryMinute) : '–'],
['Picks kumulieren', t.accumulatePicks ? 'Ja' : 'Nein'],
['Verbl. Karten zeigen', t.showRemainingCards ? 'Ja' : 'Nein'],
]));
}
// Gemeinsame Einstellungen
sections.push(buildSection('⚙ Allgemein', [
['Hygiene-Öffnung', t.hygieneEnabled ? `alle ${fmtMinutes(t.hygineOpeningEveryMinites)}, ${fmtMinutes(t.hygineOpeningDurationMinutes)} offen` : 'Keine'],
['Verifikation', t.requiresVerification ? 'Erforderlich' : 'Keine'],
['Aufgaben-Modus', t.taskMode === 'KEYHOLDER' ? 'Keyholder' : t.taskMode === 'COMMUNITY' ? 'Community' : 'Zufällig'],
]));
if (t.tasks && t.tasks.length) {
const taskItems = t.tasks.map(task => {
const dur = task.durationMinutes ? ` <span style="color:var(--color-muted);font-size:0.8rem;">(${fmtMinutes(task.durationMinutes)})</span>` : '';
const desc = task.description ? `<div style="font-size:0.78rem;color:var(--color-muted);margin-top:0.1rem;">${esc(task.description)}</div>` : '';
return `<div class="detail-task-item">${esc(task.title || task.name || '–')}${dur}${desc}</div>`;
}).join('');
sections.push(`<div class="detail-section">
<div class="detail-section-title">🎯 Aufgaben (${t.tasks.length})</div>
${taskItems}
</div>`);
}
return sections.join('');
}
function buildSection(title, rows) {
const rowsHtml = rows.map(([label, val]) =>
`<div class="detail-row">
<span class="detail-row-label">${label}</span>
<span class="detail-row-val">${val}</span>
</div>`
).join('');
return `<div class="detail-section">
<div class="detail-section-title">${title}</div>
${rowsHtml}
</div>`;
}
// ── Infinite Scroll ────────────────────────────────────────────────────────
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadNextPage();
}, { rootMargin: '200px' });
observer.observe(document.getElementById('scrollSentinel'));
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeDetail();
});
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
if (!user) { window.location.href = '/login.html'; return; }
loadNextPage();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,485 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Entdecken – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Search ── */
.search-bar {
display: flex; gap: 0.6rem; margin-bottom: 1.5rem; align-items: center;
}
.search-bar input[type="text"] {
flex: 1; padding: 0.55rem 0.85rem;
border: 1px solid var(--color-secondary); border-radius: 6px;
background: var(--color-card); color: var(--color-text);
font-size: 0.95rem; outline: none; transition: border-color 0.2s;
}
.search-bar input[type="text"]:focus { border-color: var(--color-primary); }
.search-bar input[type="text"]::placeholder { color: var(--color-muted); }
.btn-search {
background: var(--color-secondary); color: var(--color-text);
border: none; border-radius: 6px; padding: 0.55rem 1rem;
font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
}
.btn-search:hover { background: var(--color-primary); color: #fff; }
/* ── Paging ── */
.paging {
display: flex; align-items: center; justify-content: center;
gap: 0.75rem; margin-top: 1rem;
}
.paging button {
background: var(--color-secondary); color: var(--color-text);
border: none; border-radius: 6px; padding: 0.4rem 0.9rem;
font-size: 0.85rem; cursor: pointer; transition: background 0.15s;
}
.paging button:hover:not(:disabled) { background: var(--color-primary); }
.paging button:disabled { opacity: 0.35; cursor: default; }
.paging .page-info { font-size: 0.85rem; color: var(--color-muted); }
.empty, .loading { color: var(--color-muted); font-size: 0.9rem; padding: 0.75rem 0; }
/* ── Gruppe card ── */
.gruppe-list { display: flex; flex-direction: column; gap: 0.75rem; }
.gruppe-card {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 10px; overflow: hidden; transition: border-color 0.15s;
}
.gruppe-card.open { border-color: rgba(233,69,96,0.35); }
.gruppe-header {
display: flex; align-items: center; gap: 0.9rem;
padding: 0.85rem 1rem; cursor: pointer; user-select: none;
}
.gruppe-img {
width: 48px; height: 48px; border-radius: 7px;
object-fit: cover; flex-shrink: 0;
}
.gruppe-img-placeholder {
width: 48px; height: 48px; border-radius: 7px;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; flex-shrink: 0; color: var(--color-muted);
}
.gruppe-meta { flex: 1; min-width: 0; }
.gruppe-name {
font-size: 0.95rem; font-weight: 600; color: var(--color-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gruppe-info { font-size: 0.75rem; color: var(--color-muted); margin-top: 0.2rem; }
.gruppe-badges { display: flex; gap: 0.3rem; margin-top: 0.25rem; flex-wrap: wrap; }
.gruppe-badge {
font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 20px;
background: rgba(255,255,255,0.07); color: var(--color-muted);
}
.gruppe-badge-sub { background: rgba(46,204,113,0.15); color: var(--color-success); }
.gruppe-toggle { font-size: 0.75rem; color: var(--color-muted); flex-shrink: 0; transition: transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform: rotate(90deg); }
/* ── Subscribe button ── */
.btn-sub {
background: none; border: 1px solid var(--color-secondary); border-radius: 6px;
color: var(--color-muted); font-size: 0.8rem; padding: 0.3rem 0.75rem;
cursor: pointer; transition: border-color 0.15s, color 0.15s, background 0.15s;
flex-shrink: 0; white-space: nowrap;
}
.btn-sub:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-sub.subscribed {
border-color: rgba(46,204,113,0.5); color: var(--color-success);
}
.btn-sub.subscribed:hover {
border-color: var(--color-primary); color: var(--color-primary);
background: rgba(233,69,96,0.08);
}
.btn-sub:disabled { opacity: 0.4; cursor: default; }
/* ── Gruppe body ── */
.gruppe-body { border-top: 1px solid var(--color-secondary); padding: 1rem 1rem 0.75rem; }
.gruppe-desc { font-size: 0.82rem; color: var(--color-muted); margin-bottom: 0.85rem; line-height: 1.5; }
.sub-section + .sub-section { margin-top: 0.85rem; }
.sub-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; }
.sub-section-title {
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--color-primary);
}
/* ── Items ── */
.item-list { display: flex; flex-direction: column; gap: 0.3rem; }
.item { border-radius: 6px; background: var(--color-secondary); overflow: hidden; }
.item-row {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.35rem 0.6rem;
cursor: pointer; user-select: none; transition: background 0.12s;
}
.item-row:hover { background: rgba(255,255,255,0.04); }
.item.open .item-row { background: rgba(233,69,96,0.08); }
.item-text {
color: var(--color-text); flex: 1; min-width: 0; font-size: 0.82rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.item-badges { display: flex; gap: 0.35rem; flex-shrink: 0; }
.badge {
font-size: 0.7rem; padding: 0.1rem 0.45rem; border-radius: 20px;
background: rgba(233,69,96,0.15); color: var(--color-primary); white-space: nowrap;
}
.badge-neutral { background: rgba(255,255,255,0.07); color: var(--color-muted); }
/* ── Item detail ── */
.item-detail {
display: none; padding: 0.5rem 0.6rem 0.6rem;
border-top: 1px solid rgba(255,255,255,0.06);
font-size: 0.8rem; color: var(--color-muted); line-height: 1.55;
}
.item.open .item-detail { display: block; }
.item-detail-text { margin-bottom: 0.4rem; color: var(--color-text); white-space: pre-wrap; }
.item-detail-row { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; margin-top: 0.25rem; }
.item-detail-label { font-size: 0.72rem; color: var(--color-muted); }
.item-detail-chip {
font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 20px;
background: rgba(255,255,255,0.07); color: var(--color-text);
}
.item-detail-chip-toy { background: rgba(233,69,96,0.12); color: var(--color-primary); }
.sub-empty { font-size: 0.78rem; color: var(--color-muted); padding: 0.2rem 0; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" maxlength="200">
<button class="btn-search" id="searchBtn">Suchen</button>
</div>
<div id="loading" class="loading">Wird geladen…</div>
<div id="groupList" class="gruppe-list"></div>
<div class="paging" id="paging" style="display:none;">
<button id="prevBtn">‹ Zurück</button>
<span class="page-info" id="pageInfo"></span>
<button id="nextBtn">Weiter ›</button>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const PAGE_SIZE = 10;
let currentPage = 0, totalPages = 1;
let currentName = '';
// ── XSS ──
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Auth ──
fetch('/login/me')
.then(r => { if (r.status === 401) { window.location.href = '/login.html'; return null; } return r.ok ? r.json() : null; })
.then(user => { if (!user) return; loadGroups(); })
.catch(() => { window.location.href = '/login.html'; });
// ── Load ──
function loadGroups() {
document.getElementById('loading').style.display = 'block';
document.getElementById('groupList').innerHTML = '';
document.getElementById('paging').style.display = 'none';
const nameParam = currentName ? `&name=${encodeURIComponent(currentName)}` : '';
fetch(`/abo/discover?page=${currentPage}&size=${PAGE_SIZE}${nameParam}`)
.then(r => r.json())
.then(data => {
totalPages = data.totalPages || 1;
renderGroups(data.content || []);
updatePaging(currentPage, totalPages);
document.getElementById('loading').style.display = 'none';
})
.catch(() => { document.getElementById('loading').textContent = 'Fehler beim Laden.'; });
}
// ── Render ──
const WERKZEUG_LABEL = {
MUND: 'Mund', VAGINA: 'Vagina', PENIS: 'Penis',
ANUS: 'Anus', UMSCHNALLDILDO: 'Umschnall-Dildo'
};
function werkzeugChips(list) {
if (!list || list.length === 0) return '';
return list.map(w => `<span class="item-detail-chip">${esc(WERKZEUG_LABEL[w] || w)}</span>`).join('');
}
function toyChips(list) {
if (!list || list.length === 0) return '';
return list.map(t => `<span class="item-detail-chip item-detail-chip-toy">${esc(t.name || t)}</span>`).join('');
}
function formatSek(von, bis) {
if (von != null && bis != null) return `${von}–${bis} s`;
if (von != null) return `ab ${von} s`;
if (bis != null) return `bis ${bis} s`;
return '';
}
function formatMin(von, bis) {
if (von != null && bis != null) return `${von}–${bis} min`;
if (von != null) return `ab ${von} min`;
if (bis != null) return `bis ${bis} min`;
return '';
}
// Track which group card is open
let openGroupId = null;
// Track which item detail is open
let openItemId = null;
function renderGroups(groups) {
const list = document.getElementById('groupList');
if (!groups || groups.length === 0) {
list.innerHTML = '<p class="empty">Keine Gruppen gefunden.</p>';
return;
}
list.innerHTML = groups.map(g => {
const aufgabenCount = (g.aufgaben || []).length;
const strafeCount = (g.strafen || []).length;
const sperreCount = (g.sperren || []).length;
const counts = [
aufgabenCount ? `${aufgabenCount} Aufgabe${aufgabenCount !== 1 ? 'n' : ''}` : '',
strafeCount ? `${strafeCount} Strafe${strafeCount !== 1 ? 'n' : ''}` : '',
sperreCount ? `${sperreCount} Zeitstrafe${sperreCount !== 1 ? 'n' : ''}` : ''
].filter(Boolean).join(' · ');
const subLabel = g.subscribed
? `<span class="gruppe-badge gruppe-badge-sub">♥ Abonniert</span>`
: '';
const subCount = g.subscriberCount > 0
? `<span class="gruppe-badge">♥ ${g.subscriberCount} Abo${g.subscriberCount !== 1 ? 's' : ''}</span>`
: '';
const subBtnClass = g.subscribed ? 'btn-sub subscribed' : 'btn-sub';
const subBtnText = g.subscribed ? '♥ Abonniert' : '♥ Abonnieren';
return `
<div class="gruppe-card" id="dgroup-${esc(g.gruppenId)}">
<div class="gruppe-header">
<div style="cursor:pointer; display:flex; align-items:center; gap:0.9rem; flex:1; min-width:0;"
onclick="toggleGroup('${esc(g.gruppenId)}')">
${g.bild
? `<img class="gruppe-img" src="data:image/png;base64,${g.bild}" alt="${esc(g.name)}">`
: `<div class="gruppe-img-placeholder">⊙</div>`}
<div class="gruppe-meta">
<div class="gruppe-name">${esc(g.name)}</div>
<div class="gruppe-info">${g.von ? esc(g.von) + (counts ? ' · ' : '') : ''}${counts || 'Keine Einträge'}</div>
${(subLabel || subCount) ? `<div class="gruppe-badges">${subCount}${subLabel}</div>` : ''}
</div>
<span class="gruppe-toggle">▶</span>
</div>
<button class="${subBtnClass}" id="subbtn-${esc(g.gruppenId)}"
onclick="toggleSubscribe('${esc(g.gruppenId)}', this)">
${subBtnText}
</button>
</div>
<div class="gruppe-body" id="dbody-${esc(g.gruppenId)}" style="display:none;">
${g.beschreibung ? `<div class="gruppe-desc">${esc(g.beschreibung)}</div>` : ''}
${renderSubSection('Aufgaben', sortByLevelThenName(g.aufgaben || []), renderAufgabe)}
${renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), renderStrafe)}
${renderSubSection('Zeitstrafen', sortByName(g.sperren || []), renderZeitstrafe)}
</div>
</div>`;
}).join('');
openItemId = null;
}
function renderSubSection(title, items, renderFn) {
return `<div class="sub-section">
<div class="sub-section-header">
<span class="sub-section-title">${esc(title)} (${items.length})</span>
</div>
${items.length === 0
? '<div class="sub-empty">Keine Einträge</div>'
: `<div class="item-list">${items.map(item => renderFn(item)).join('')}</div>`}
</div>`;
}
function renderAufgabe(a) {
const badges = [];
const zeit = formatSek(a.sekundenVon, a.sekundenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
if (a.level != null) badges.push(`<span class="badge">Level ${esc(String(a.level))}</span>`);
const detailRows = [];
if (a.text) detailRows.push(`<div class="item-detail-text">${esc(a.text)}</div>`);
if (a.benoetigtAktiv && a.benoetigtAktiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(a.benoetigtAktiv)}</div>`);
if (a.benoetigtPassiv && a.benoetigtPassiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(a.benoetigtPassiv)}</div>`);
if (a.benoetigteToys && a.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(a.benoetigteToys)}</div>`);
return `<div class="item" id="ditem-${esc(a.aufgabeId)}">
<div class="item-row" onclick="toggleItem('${esc(a.aufgabeId)}')">
<span class="item-text">${esc(a.kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
</div>`;
}
function renderStrafe(s) {
const badges = [];
const zeit = formatSek(s.sekundenVon, s.sekundenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
if (s.level != null) badges.push(`<span class="badge">Level ${esc(String(s.level))}</span>`);
const detailRows = [];
if (s.text) detailRows.push(`<div class="item-detail-text">${esc(s.text)}</div>`);
if (s.benoetigtAktiv && s.benoetigtAktiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(s.benoetigtAktiv)}</div>`);
if (s.benoetigtPassiv && s.benoetigtPassiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(s.benoetigtPassiv)}</div>`);
if (s.benoetigteToys && s.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(s.benoetigteToys)}</div>`);
return `<div class="item" id="ditem-${esc(s.strafeId)}">
<div class="item-row" onclick="toggleItem('${esc(s.strafeId)}')">
<span class="item-text">${esc(s.kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
</div>`;
}
function renderZeitstrafe(z) {
const badges = [];
const zeit = formatMin(z.minutenVon, z.minutenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
const detailRows = [];
if (z.text) detailRows.push(`<div class="item-detail-text">${esc(z.text)}</div>`);
if (z.releaseText) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Bei Aufhebung:</span><span style="font-size:0.78rem; color:var(--color-text);">${esc(z.releaseText)}</span></div>`);
if (z.sperreFuer && z.sperreFuer.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Sperrt:</span>${werkzeugChips(z.sperreFuer)}</div>`);
if (z.benoetigteToys && z.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(z.benoetigteToys)}</div>`);
return `<div class="item" id="ditem-${esc(z.sperreId)}">
<div class="item-row" onclick="toggleItem('${esc(z.sperreId)}')">
<span class="item-text">${esc(z.kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
</div>`;
}
// ── Sort ──
function sortByLevelThenName(items) {
return items.slice().sort((a, b) => {
const la = a.level ?? 999, lb = b.level ?? 999;
if (la !== lb) return la - lb;
return (a.kurzText || '').localeCompare(b.kurzText || '', 'de');
});
}
function sortByName(items) {
return items.slice().sort((a, b) => (a.kurzText || '').localeCompare(b.kurzText || '', 'de'));
}
// ── Group toggle ──
function toggleGroup(gruppenId) {
const card = document.getElementById('dgroup-' + gruppenId);
const body = document.getElementById('dbody-' + gruppenId);
if (!card) return;
if (card.classList.contains('open')) {
card.classList.remove('open');
body.style.display = 'none';
if (openGroupId === gruppenId) openGroupId = null;
} else {
if (openGroupId) {
const prev = document.getElementById('dgroup-' + openGroupId);
const prevBody = document.getElementById('dbody-' + openGroupId);
if (prev) prev.classList.remove('open');
if (prevBody) prevBody.style.display = 'none';
}
card.classList.add('open');
body.style.display = 'block';
openGroupId = gruppenId;
openItemId = null;
}
}
// ── Item toggle ──
function toggleItem(itemId) {
if (openItemId === itemId) {
const el = document.getElementById('ditem-' + itemId);
if (el) el.classList.remove('open');
openItemId = null;
return;
}
if (openItemId) {
const prev = document.getElementById('ditem-' + openItemId);
if (prev) prev.classList.remove('open');
}
const el = document.getElementById('ditem-' + itemId);
if (el) el.classList.add('open');
openItemId = itemId;
}
// ── Subscribe / Unsubscribe ──
function toggleSubscribe(gruppenId, btn) {
btn.disabled = true;
const isSubscribed = btn.classList.contains('subscribed');
const method = isSubscribed ? 'DELETE' : 'POST';
fetch(`/abo/${gruppenId}`, { method })
.then(r => {
if (r.ok || r.status === 201 || r.status === 202) {
if (isSubscribed) {
btn.classList.remove('subscribed');
btn.textContent = '♥ Abonnieren';
updateBadge(gruppenId, false);
} else {
btn.classList.add('subscribed');
btn.textContent = '♥ Abonniert';
updateBadge(gruppenId, true);
}
btn.disabled = false;
} else {
btn.disabled = false;
}
})
.catch(() => { btn.disabled = false; });
}
function updateBadge(gruppenId, subscribed) {
const card = document.getElementById('dgroup-' + gruppenId);
if (!card) return;
const badgesEl = card.querySelector('.gruppe-badges');
if (!badgesEl) return;
const subBadge = badgesEl.querySelector('.gruppe-badge-sub');
if (subscribed && !subBadge) {
badgesEl.insertAdjacentHTML('beforeend', `<span class="gruppe-badge gruppe-badge-sub">♥ Abonniert</span>`);
} else if (!subscribed && subBadge) {
subBadge.remove();
}
}
// ── Search ──
document.getElementById('searchBtn').addEventListener('click', () => {
currentName = document.getElementById('searchInput').value.trim();
currentPage = 0;
loadGroups();
});
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('searchBtn').click();
});
// ── Paging ──
function updatePaging(current, total) {
const el = document.getElementById('paging');
if (total <= 1) { el.style.display = 'none'; return; }
el.style.display = 'flex';
document.getElementById('prevBtn').disabled = current === 0;
document.getElementById('nextBtn').disabled = current >= total - 1;
document.getElementById('pageInfo').textContent = `Seite ${current + 1} von ${total}`;
}
document.getElementById('prevBtn').addEventListener('click', () => {
if (currentPage > 0) { currentPage--; loadGroups(); }
});
document.getElementById('nextBtn').addEventListener('click', () => {
if (currentPage < totalPages - 1) { currentPage++; loadGroups(); }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chastity Game – Info – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="app">
<div class="main">
<div class="content">
<h1>Chastity Game</h1>
<p>Informationen zum Chastity Game folgen hier.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lock-Einladung – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.invite-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem 1.5rem;
width: 100%;
text-align: center;
}
.invite-icon { font-size: 3rem; margin-bottom: 1rem; }
.invite-title { font-size: 1.3rem; font-weight: 700; margin-bottom: 0.4rem; }
.invite-sub { color: var(--color-muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
.invite-detail {
background: var(--color-secondary);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
text-align: left;
font-size: 0.9rem;
}
.invite-detail dt { color: var(--color-muted); font-size: 0.78rem; margin-bottom: 0.1rem; }
.invite-detail dd { font-weight: 600; margin: 0 0 0.5rem 0; }
.invite-detail dd:last-child { margin-bottom: 0; }
.invite-actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.invite-actions button { width: auto; padding: 0.65rem 1.5rem; }
.btn-danger { background: #c0392b !important; }
.btn-danger:hover { background: #a93226 !important; }
.code-lines-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
margin-bottom: 1.25rem;
}
.code-lines-row input { width: 80px; text-align: center; }
.code-lines-row span { color: var(--color-text); font-size: 0.9rem; }
/* Unlock-Code-Modal */
.unlock-modal-bg {
display: none;
position: fixed;
inset: 0;
z-index: 400;
align-items: center;
justify-content: center;
}
.unlock-modal-bg.open { display: flex; }
.unlock-modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
}
.unlock-modal-box {
position: relative;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 1.5rem 1.5rem 1.25rem;
max-width: 380px;
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
z-index: 1;
text-align: center;
}
.unlock-code-display {
font-family: monospace;
font-size: 2rem;
letter-spacing: 0.3em;
background: var(--color-secondary);
border-radius: 8px;
padding: 1rem 1.5rem;
color: var(--color-primary);
line-height: 1.8;
word-break: break-all;
width: 100%;
box-sizing: border-box;
}
#stateLoading { display: none; }
#stateError { display: none; }
#stateAlready { display: none; }
#stateDeclined { display: none; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div id="stateLoading" style="text-align:center;padding:3rem 1rem;color:var(--color-muted);">Lade Einladung…</div>
<div id="stateError" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;">⚠️</div>
<h2 style="margin-bottom:0.5rem;">Einladung nicht gefunden</h2>
<p style="color:var(--color-muted);">Diese Einladung existiert nicht oder wurde bereits bearbeitet.</p>
<a href="/games/common/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
</div>
<div id="stateAlready" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;">🔒</div>
<h2 style="margin-bottom:0.5rem;">Lock bereits aktiv</h2>
<p style="color:var(--color-muted);">Diese Einladung wurde bereits angenommen.</p>
</div>
<div id="stateDeclined" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;">✕</div>
<h2 style="margin-bottom:0.5rem;">Einladung abgelehnt</h2>
<p style="color:var(--color-muted);">Du hast die Einladung abgelehnt. Der Keyholder wurde benachrichtigt.</p>
<a href="/games/common/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
</div>
<div id="stateInvite" style="display:none;">
<div class="invite-card">
<div class="invite-icon">🔒</div>
<div class="invite-title">Lock-Einladung</div>
<div class="invite-sub" id="invSubtitle"></div>
<dl class="invite-detail" id="invDetail"></dl>
<div id="acceptSection">
<p style="font-size:0.88rem;color:var(--color-muted);margin-bottom:0.75rem;">
Wie viele Ziffern soll dein Entsperrcode haben?
</p>
<div class="code-lines-row">
<input type="number" id="codeLines" min="1" max="20" value="5">
<span>Ziffern</span>
</div>
<div class="invite-actions">
<button class="btn-danger" onclick="declineInvitation()">✕ Ablehnen</button>
<button onclick="acceptInvitation()">✓ Annehmen</button>
</div>
</div>
<div id="errorMsg" style="color:#e74c3c;font-size:0.85rem;margin-top:0.5rem;display:none;"></div>
</div>
</div>
</div>
</div>
<!-- Unlock-Code-Modal -->
<div class="unlock-modal-bg" id="unlockModal">
<div class="unlock-modal-overlay"></div>
<div class="unlock-modal-box">
<div style="font-size:2rem;">🔒</div>
<h3 id="unlockModalTitle" style="margin:0;">Dein Entsperrcode</h3>
<p id="unlockModalHint" style="color:var(--color-muted);font-size:0.85rem;margin:0;">
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
</p>
<div class="unlock-code-display" id="unlockCodeDisplay"></div>
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);font-family:monospace;"></div>
<button id="unlockModalBtn" style="width:100%;margin-top:0.25rem;">Weiter</button>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
let lockId = null;
document.getElementById('stateLoading').style.display = '';
async function load() {
if (!token) { showState('stateError'); return; }
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token));
if (res.status === 409) { showState('stateAlready'); return; }
if (!res.ok) { showState('stateError'); return; }
const inv = await res.json();
lockId = inv.lockId;
document.getElementById('invSubtitle').textContent =
inv.keyholderName + ' hat dich als Lockee eingeladen';
const dl = document.getElementById('invDetail');
dl.innerHTML = `
<dt>Lock-Name</dt><dd>${esc(inv.lockName)}</dd>
<dt>Keyholder</dt><dd>${esc(inv.keyholderName)}</dd>`;
showState('stateInvite');
} catch(e) {
showState('stateError');
}
}
function showState(id) {
document.getElementById('stateLoading').style.display = 'none';
['stateError','stateAlready','stateInvite','stateDeclined'].forEach(s => {
document.getElementById(s).style.display = s === id ? '' : 'none';
});
}
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
async function acceptInvitation() {
const lines = parseInt(document.getElementById('codeLines').value);
if (!lines || lines < 1) { showError('Bitte eine Ziffernanzahl eingeben.'); return; }
const btn = document.querySelector('#acceptSection button:last-child');
btn.disabled = true;
document.getElementById('errorMsg').style.display = 'none';
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token) + '/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unlockCodeLines: lines })
});
if (!res.ok) { btn.disabled = false; showError('Fehler beim Annehmen der Einladung.'); return; }
const data = await res.json();
showUnlockCodeModal(data.unlockCode, data.lockId);
} catch(e) {
btn.disabled = false;
showError('Fehler beim Annehmen der Einladung.');
}
}
async function declineInvitation() {
if (!confirm('Bist du sicher, dass du diese Einladung ablehnen möchtest? Das Lock wird gelöscht.')) return;
const btn = document.querySelector('.btn-danger');
btn.disabled = true;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) {
showState('stateDeclined');
} else {
btn.disabled = false;
showError('Fehler beim Ablehnen der Einladung.');
}
} catch(e) {
btn.disabled = false;
showError('Fehler beim Ablehnen der Einladung.');
}
}
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.style.display = '';
}
function showUnlockCodeModal(code, lid) {
document.getElementById('unlockCodeDisplay').textContent = code;
const url = '/games/chastity/activelock.html?lockId=' + lid;
const btn = document.getElementById('unlockModalBtn');
btn.onclick = () => startCodeScramble(code, url);
document.getElementById('unlockModal').classList.add('open');
}
function startCodeScramble(realCode, url) {
const display = document.getElementById('unlockCodeDisplay');
const btn = document.getElementById('unlockModalBtn');
const hint = document.getElementById('unlockModalHint');
const countdown = document.getElementById('unlockModalCountdown');
const len = realCode.length;
const DURATION = 3 * 60;
let remaining = DURATION;
let stopped = false;
function randomCode() {
return Array.from({ length: len }, () => Math.floor(Math.random() * 10)).join('');
}
function finish() {
stopped = true;
clearInterval(scrambleInterval);
clearInterval(countdownInterval);
window.location.href = url;
}
if (hint) hint.style.display = 'none';
countdown.style.display = '';
document.getElementById('unlockModalTitle').textContent = 'Nun vergessen wir den Code…';
btn.textContent = 'Abbrechen';
btn.onclick = finish;
function updateCountdown() {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
countdown.textContent = `${m}:${String(s).padStart(2, '0')}`;
}
updateCountdown();
const scrambleInterval = setInterval(() => { if (!stopped) display.textContent = randomCode(); }, 1000);
const countdownInterval = setInterval(() => {
if (stopped) return;
remaining--;
updateCountdown();
if (remaining <= 0) finish();
}, 1000);
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,530 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keyholder finden – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.offer-card {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 10px; padding: 1rem; margin-bottom: 0.75rem;
display: flex; align-items: center; gap: 0.85rem;
}
.offer-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: var(--color-secondary); border: 1px solid rgba(255,255,255,0.08);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; flex-shrink: 0; overflow: hidden;
}
.offer-avatar img { width: 100%; height: 100%; object-fit: cover; }
.offer-body { flex: 1; min-width: 0; }
.offer-name { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.2rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.offer-sub { font-size: 0.78rem; color: var(--color-muted); margin-bottom: 0.3rem; }
.offer-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.offer-badge {
display: inline-block; font-size: 0.72rem; padding: 0.1rem 0.45rem;
border-radius: 4px; background: rgba(255,255,255,0.07); border: 1px solid var(--color-secondary);
}
.offer-badge.direct { background: rgba(46,204,113,0.12); border-color: rgba(46,204,113,0.3); color: #2ecc71; }
.offer-badge.confirm { background: rgba(230,126,34,0.12); border-color: rgba(230,126,34,0.3); color: #e67e22; }
.btn-join {
background: var(--color-primary); border: none; color: #fff;
border-radius: 7px; padding: 0.4rem 1rem; font-size: 0.85rem;
font-weight: 600; cursor: pointer; flex-shrink: 0; width: auto;
}
.btn-join:disabled { opacity: 0.45; cursor: default; }
/* Klickbarer Card-Bereich */
.offer-card-clickable { cursor: pointer; }
.offer-card-clickable:hover { background: var(--color-secondary); }
/* Detail-Dialog */
.detail-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 500;
align-items: flex-start; justify-content: center;
overflow-y: auto; padding: 2rem 1rem;
}
.detail-backdrop.open { display: flex; }
.detail-box {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 14px; padding: 1.5rem; max-width: 520px; width: 100%;
display: flex; flex-direction: column; gap: 1rem; position: relative;
}
.detail-section { margin-bottom: 0.25rem; }
.detail-section-title {
font-size: 0.72rem; font-weight: 700; color: var(--color-primary);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.5rem;
}
.detail-row {
display: flex; justify-content: space-between; gap: 1rem;
padding: 0.25rem 0; border-bottom: 1px solid rgba(255,255,255,0.05);
font-size: 0.88rem;
}
.detail-row:last-child { border-bottom: none; }
.detail-row-label { color: var(--color-muted); flex-shrink: 0; }
.detail-row-val { color: var(--color-text); text-align: right; }
.detail-task-item {
font-size: 0.88rem; padding: 0.35rem 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.detail-task-item:last-child { border-bottom: none; }
.detail-wheel-entry {
display: inline-block; font-size: 0.8rem; padding: 0.15rem 0.5rem;
border-radius: 4px; background: rgba(255,255,255,0.07);
border: 1px solid var(--color-secondary); margin: 0.15rem 0.2rem 0.15rem 0;
}
.detail-footer {
display: flex; gap: 0.6rem; justify-content: flex-end;
border-top: 1px solid var(--color-secondary); padding-top: 1rem; margin-top: 0.25rem;
}
.btn-close-detail {
background: none; border: 1px solid var(--color-secondary);
color: var(--color-muted); padding: 0.5rem 1.1rem;
border-radius: 7px; cursor: pointer; font-size: 0.88rem; width: auto;
}
.btn-join-detail {
background: var(--color-primary); border: none; color: #fff;
border-radius: 7px; padding: 0.5rem 1.25rem; font-size: 0.88rem;
font-weight: 600; cursor: pointer; width: auto;
}
.btn-join-detail:disabled { opacity: 0.45; cursor: default; }
.detail-author-avatar {
width: 52px; height: 52px; border-radius: 50%;
background: var(--color-secondary); border: 1px solid rgba(255,255,255,0.1);
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; overflow: hidden; flex-shrink: 0;
}
.detail-author-avatar img { width: 100%; height: 100%; object-fit: cover; }
/* Join-Dialog */
.join-modal-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 500;
align-items: center; justify-content: center;
}
.join-modal-backdrop.open { display: flex; }
.join-modal-box {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 14px; padding: 1.5rem; max-width: 400px; width: 92%;
display: flex; flex-direction: column; gap: 1rem; position: relative;
}
.form-group { display: flex; flex-direction: column; gap: 0.35rem; }
.form-label { font-size: 0.72rem; font-weight: 700; color: var(--color-primary);
text-transform: uppercase; letter-spacing: 0.06em; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h2 style="margin-bottom:1.25rem;">🔍 Keyholder finden</h2>
<p style="font-size:0.88rem;color:var(--color-muted);margin-bottom:1.25rem;line-height:1.5;">
Hier findest du Nutzer*innen, die sich als Keyholder für ein bestimmtes Lock-Template anbieten.
Die beliebtesten Angebote erscheinen ganz oben.
</p>
<div id="offerList"></div>
<p id="listEmpty" style="display:none;color:var(--color-muted);">Keine Keyholder-Angebote gefunden.</p>
<p id="listLoading" style="color:var(--color-muted);">Wird geladen…</p>
</div>
</div>
<!-- Detail-Dialog -->
<div class="detail-backdrop" id="detailModal" onclick="closeDetail()">
<div class="detail-box" onclick="event.stopPropagation()">
<button onclick="closeDetail()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;">✕</button>
<div style="display:flex;align-items:flex-start;gap:0.85rem;">
<div class="detail-author-avatar" id="detailAvatar" style="display:none;">◉</div>
<div>
<h2 id="detailTitle" style="margin:0 0 0.25rem;font-size:1.2rem;"></h2>
<div id="detailMeta" style="font-size:0.82rem;color:var(--color-muted);"></div>
</div>
</div>
<div id="detailBody"></div>
<div class="detail-footer">
<button class="btn-close-detail" onclick="closeDetail()">Schließen</button>
<button class="btn-join-detail" id="detailJoinBtn" onclick="detailJoin()">🔒 Beitreten</button>
</div>
</div>
</div>
<!-- Join-Dialog -->
<div class="join-modal-backdrop" id="joinModal">
<div class="join-modal-box">
<button onclick="closeJoinModal()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;">✕</button>
<h3 style="margin:0;font-size:1.05rem;">🔒 Angebot annehmen</h3>
<p id="joinModalDesc" style="margin:0;font-size:0.85rem;color:var(--color-muted);line-height:1.5;"></p>
<div class="form-group">
<div class="form-label">Schloss-Steuerung</div>
<select id="joinControllType" style="padding:0.5rem 0.75rem;border-radius:7px;border:1px solid var(--color-secondary);background:var(--color-secondary);color:var(--color-text);font-size:0.88rem;">
<option value="">– Bitte wählen –</option>
<option value="UNLOCK_CODE">🔢 Entsperrcode (Standard)</option>
<option value="TRUST">🤝 Trust (kein Code)</option>
</select>
</div>
<div class="form-group" id="codeLenGroup">
<div class="form-label">Code-Länge</div>
<input type="number" id="joinCodeLen" value="5" min="1" max="10"
style="padding:0.5rem 0.75rem;border-radius:7px;border:1px solid var(--color-secondary);background:var(--color-secondary);color:var(--color-text);font-size:0.88rem;width:100%;box-sizing:border-box;">
</div>
<div id="joinError" style="display:none;font-size:0.85rem;color:#e74c3c;"></div>
<div style="display:flex;gap:0.6rem;justify-content:flex-end;margin-top:0.25rem;">
<button onclick="closeJoinModal()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;width:auto;">Abbrechen</button>
<button id="joinConfirmBtn" onclick="confirmJoin()" style="padding:0.5rem 1.25rem;border-radius:7px;font-size:0.88rem;font-weight:600;width:auto;">Beitreten</button>
</div>
</div>
</div>
<!-- Ergebnis-Dialog (nach erfolgreichem Join) -->
<div class="join-modal-backdrop" id="joinResultModal">
<div class="join-modal-box" style="align-items:center;text-align:center;">
<div style="font-size:2.5rem;line-height:1;" id="joinResultIcon">🔒</div>
<h3 style="margin:0;" id="joinResultTitle"></h3>
<p style="margin:0;font-size:0.88rem;color:var(--color-muted);line-height:1.5;" id="joinResultText"></p>
<div id="joinResultCode" style="display:none;font-family:monospace;font-size:1.6rem;font-weight:700;letter-spacing:0.18em;padding:0.6rem 1.25rem;background:rgba(255,255,255,0.06);border-radius:8px;"></div>
<div style="display:flex;gap:0.6rem;justify-content:center;margin-top:0.5rem;flex-wrap:wrap;">
<button onclick="closeJoinResultModal()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;width:auto;">Schließen</button>
<button id="btnGoToLock" onclick="goToActiveLock()" style="padding:0.5rem 1.25rem;border-radius:7px;font-size:0.88rem;font-weight:600;width:auto;display:none;">Zum aktiven Lock</button>
</div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
const GENDER_LABELS = { WEIBLICH: 'Weiblich', MAENNLICH: 'Männlich', DIVERS: 'Divers' };
let _joinOfferId = null;
let _lastJoinOfferId = null;
let _joinLockId = null;
let _detailOfferId = null;
let _detailOffer = null;
let _allOffers = [];
// ── Laden ──────────────────────────────────────────────────────────────────
async function loadOffers() {
const res = await fetch('/keyholder-offers/public');
document.getElementById('listLoading').style.display = 'none';
if (!res.ok) return;
_allOffers = await res.json();
const list = document.getElementById('offerList');
if (_allOffers.length === 0) { document.getElementById('listEmpty').style.display = ''; return; }
_allOffers.forEach(o => list.appendChild(buildCard(o)));
}
function buildCard(o) {
const av = o.offererProfilePic
? `<div class="offer-avatar"><img src="data:image/png;base64,${o.offererProfilePic}" alt=""></div>`
: `<div class="offer-avatar">👤</div>`;
const genderTags = (o.targetGenders && o.targetGenders.length > 0)
? o.targetGenders.map(g => `<span class="offer-badge">${esc(GENDER_LABELS[g] || g)}</span>`).join('')
: '<span class="offer-badge">Alle</span>';
const modeBadge = o.directStart
? '<span class="offer-badge direct">Direktstart</span>'
: '<span class="offer-badge confirm">Mit Bestätigung</span>';
const typeBadge = o.templateType === 'TIMELOCK'
? '<span class="offer-badge">⏱ Zeit-Lock</span>'
: '<span class="offer-badge">🃏 Karten-Lock</span>';
const authorLine = o.offererName
? `von ${esc(o.offererName)} · `
: '';
const joinBtn = o.isOwn
? `<button class="btn-join" disabled title="Eigenes Angebot">Eigenes</button>`
: `<button class="btn-join" onclick="openJoinModal('${o.id}', event)">Beitreten</button>`;
const div = document.createElement('div');
div.className = 'offer-card';
div.dataset.offerId = o.id;
div.innerHTML = `
${av}
<div class="offer-body offer-card-clickable" onclick="openDetail('${o.id}')">
<div class="offer-name">${esc(o.templateName || 'Unbenannt')}</div>
<div class="offer-sub">${authorLine}✓ ${o.acceptanceCount}× angenommen</div>
<div class="offer-tags">${typeBadge} ${modeBadge} ${genderTags}</div>
</div>
${joinBtn}`;
return div;
}
// ── Join-Dialog ────────────────────────────────────────────────────────────
function openJoinModal(offerId, e) {
if (e) e.stopPropagation();
_joinOfferId = offerId;
const card = document.querySelector(`[data-offer-id="${offerId}"]`);
const name = card ? card.querySelector('.offer-name')?.textContent : 'dieses Lock';
const direct = card?.querySelector('.offer-badge.direct') != null;
document.getElementById('joinModalDesc').textContent = direct
? `Das Lock „${name}" wird sofort für dich gestartet. Bitte wähle deine bevorzugte Schloss-Steuerung.`
: `Du sendest eine Einladung an den Keyholder für das Lock „${name}". Nach Annahme kannst du loslegen.`;
document.getElementById('joinError').style.display = 'none';
document.getElementById('joinControllType').value = '';
document.getElementById('joinCodeLen').value = '5';
document.getElementById('joinConfirmBtn').disabled = true;
updateCodeLenVisibility();
document.getElementById('joinModal').classList.add('open');
}
function closeJoinModal() {
document.getElementById('joinModal').classList.remove('open');
_joinOfferId = null;
}
document.getElementById('joinControllType').addEventListener('change', function() {
updateCodeLenVisibility();
document.getElementById('joinConfirmBtn').disabled = !this.value;
});
function updateCodeLenVisibility() {
const val = document.getElementById('joinControllType').value;
document.getElementById('codeLenGroup').style.display = val === 'TRUST' ? 'none' : '';
}
async function confirmJoin() {
if (!_joinOfferId) return;
const controllType = document.getElementById('joinControllType').value;
if (!controllType) return;
const btn = document.getElementById('joinConfirmBtn');
btn.disabled = true;
const unlockCodeLength = parseInt(document.getElementById('joinCodeLen').value) || 5;
const res = await fetch(`/keyholder-offers/${_joinOfferId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ controllType, unlockCodeLength })
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
let msg = 'Fehler beim Beitreten.';
if (d.error === 'active_lock_exists') msg = 'Du hast bereits ein aktives Lock.';
else if (d.error === 'own_offer') msg = 'Du kannst nicht deinem eigenen Angebot beitreten.';
else if (d.error === 'template_gone') msg = 'Die Vorlage existiert nicht mehr.';
document.getElementById('joinError').textContent = msg;
document.getElementById('joinError').style.display = '';
btn.disabled = false;
return;
}
const data = await res.json();
_lastJoinOfferId = _joinOfferId;
closeJoinModal();
showJoinResult(data);
}
function showJoinResult(data) {
_joinLockId = data.lockId;
const direct = !data.invitationSent;
document.getElementById('joinResultIcon').textContent = direct ? '🔒' : '✉️';
document.getElementById('joinResultTitle').textContent = direct ? 'Lock gestartet!' : 'Einladung gesendet';
document.getElementById('btnGoToLock').style.display = direct ? '' : 'none';
if (direct && data.unlockCode) {
document.getElementById('joinResultText').textContent = 'Dein aktueller Entsperrcode:';
document.getElementById('joinResultCode').textContent = data.unlockCode;
document.getElementById('joinResultCode').style.display = '';
} else if (direct) {
document.getElementById('joinResultText').textContent = 'Das Lock wurde erfolgreich gestartet.';
document.getElementById('joinResultCode').style.display = 'none';
} else {
document.getElementById('joinResultText').textContent =
'Die Einladung wurde an den Keyholder gesendet. Sobald dieser annimmt, startet das Lock.';
document.getElementById('joinResultCode').style.display = 'none';
}
document.getElementById('joinResultModal').classList.add('open');
}
function closeJoinResultModal() {
document.getElementById('joinResultModal').classList.remove('open');
_joinLockId = null;
}
function goToActiveLock() {
if (!_joinLockId) return;
const isTimelock = _allOffers.find(o => o.id === _lastJoinOfferId)?.templateType === 'TIMELOCK';
const page = isTimelock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html';
window.location.href = page + '?lockId=' + _joinLockId;
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeDetail();
closeJoinModal();
closeJoinResultModal();
}
});
// ── Detail-Dialog ──────────────────────────────────────────────────────────
async function openDetail(offerId) {
_detailOfferId = offerId;
const card = document.querySelector(`[data-offer-id="${offerId}"]`);
// Offer-Objekt aus den geladenen Daten holen
_detailOffer = _allOffers.find(o => o.id === offerId);
if (!_detailOffer) return;
// Autoren-Avatar
const avatarEl = document.getElementById('detailAvatar');
if (_detailOffer.offererProfilePic) {
avatarEl.innerHTML = `<img src="data:image/png;base64,${_detailOffer.offererProfilePic}" alt="">`;
avatarEl.style.display = '';
} else {
avatarEl.style.display = 'none';
}
const typeTxt = _detailOffer.templateType === 'TIMELOCK' ? '⏱ Zeit-Lock' : '🃏 Karten-Lock';
const modeTxt = _detailOffer.directStart ? 'Direktstart' : 'Mit Bestätigung';
const authorTxt = _detailOffer.offererName ? ' · von ' + _detailOffer.offererName : '';
document.getElementById('detailTitle').textContent = _detailOffer.templateName || 'Unbenannt';
document.getElementById('detailMeta').textContent = typeTxt + ' · ' + modeTxt + authorTxt;
// Join-Button ein/ausblenden
const joinBtn = document.getElementById('detailJoinBtn');
joinBtn.style.display = _detailOffer.isOwn ? 'none' : '';
// Template-Details laden
document.getElementById('detailBody').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;">Lädt…</p>';
document.getElementById('detailModal').classList.add('open');
try {
const res = await fetch('/templates/' + _detailOffer.templateId + '/public');
if (res.ok) {
const tpl = await res.json();
document.getElementById('detailBody').innerHTML = buildDetailBody(tpl);
} else {
document.getElementById('detailBody').innerHTML = '';
}
} catch { document.getElementById('detailBody').innerHTML = ''; }
}
function closeDetail() {
document.getElementById('detailModal').classList.remove('open');
_detailOfferId = null;
_detailOffer = null;
}
function detailJoin() {
const id = _detailOfferId;
if (!id) return;
closeDetail();
openJoinModal(id, null);
}
// ── Detail-Body ────────────────────────────────────────────────────────────
function fmtMinutes(min) {
if (!min) return '0 Min.';
const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
return [d ? d + 'd' : '', h ? h + 'h' : '', m ? m + 'min' : ''].filter(Boolean).join(' ') || '0 Min.';
}
function buildSection(title, rows) {
const rowsHtml = rows.map(([l, v]) =>
`<div class="detail-row"><span class="detail-row-label">${esc(l)}</span><span class="detail-row-val">${v}</span></div>`
).join('');
return `<div class="detail-section"><div class="detail-section-title">${title}</div>${rowsHtml}</div>`;
}
function buildDetailBody(t) {
const sections = [];
if (t.lockType === 'TIMELOCK') {
sections.push(buildSection('⏱ Zeit-Einstellungen', [
['Mindestdauer', fmtMinutes(t.minTimeInMinutes)],
['Maximaldauer', fmtMinutes(t.maxTimeInMinutes)],
['Endzeit sichtbar', t.endTimeVisible ? 'Ja' : 'Nein'],
]));
if (t.spinningWheelEntries && t.spinningWheelEntries.length) {
const WHEEL_LABELS = {
ADD_TIME: '+ Zeit', REMOVE_TIME: '− Zeit', FREEZE_TIME: '❄ Einfrieren für',
FREEZE: '🧊 Einfrieren (∞)', UNFREEZE: '🌊 Auftauen', TASK: '🎯 Aufgabe', TEXT: '💬 Text',
};
const entries = t.spinningWheelEntries.map(e => {
const label = WHEEL_LABELS[e.type] || e.type;
const extra = e.intVal ? ' ' + fmtMinutes(e.intVal) : (e.stringVal ? ' «' + e.stringVal + '»' : '');
return `<span class="detail-wheel-entry">${label}${extra}</span>`;
}).join('');
sections.push(`<div class="detail-section">
<div class="detail-section-title">🎡 Glücksrad (${t.spinningWheelEntries.length} Einträge${t.spinsEveryMinutes ? ', alle ' + fmtMinutes(t.spinsEveryMinutes) : ''})</div>
<div>${entries}</div>
</div>`);
}
if (t.penaltyType) {
const penaltyLabels = { ADD: 'Zeit hinzufügen', FREEZE: 'Einfrieren', PILLORY: 'Pranger' };
sections.push(buildSection('⚠ Strafmaß', [
['Typ', penaltyLabels[t.penaltyType] || t.penaltyType],
['Wert', t.penaltyValue ? fmtMinutes(t.penaltyValue) : '–'],
]));
}
if (t.taskEveryMinutes || t.minTasksPerDay) {
sections.push(buildSection('🎯 Aufgaben-Timing', [
['Intervall', t.taskEveryMinutes ? fmtMinutes(t.taskEveryMinutes) : '–'],
['Min./Tag', t.minTasksPerDay ? t.minTasksPerDay + ' Aufgabe(n)' : '–'],
]));
}
}
if (t.lockType === 'CARDLOCK') {
const allKeys = new Set([
...Object.keys(t.cardCountsMin || {}),
...Object.keys(t.cardCountsMax || {}),
]);
const rows = [];
allKeys.forEach(k => {
const mn = (t.cardCountsMin || {})[k] ?? 0;
const mx = (t.cardCountsMax || {})[k] ?? 0;
if (mn > 0 || mx > 0) rows.push([k, `${mn} – ${mx}`]);
});
if (rows.length) sections.push(buildSection('🃏 Karten', rows));
sections.push(buildSection('⚙ Karten-Einstellungen', [
['Zieh-Intervall', t.pickEveryMinute ? fmtMinutes(t.pickEveryMinute) : '–'],
['Picks kumulieren', t.accumulatePicks ? 'Ja' : 'Nein'],
['Verbl. Karten zeigen', t.showRemainingCards ? 'Ja' : 'Nein'],
]));
}
sections.push(buildSection('⚙ Allgemein', [
['Hygiene-Öffnung', t.hygieneEnabled ? `alle ${fmtMinutes(t.hygineOpeningEveryMinites)}, ${fmtMinutes(t.hygineOpeningDurationMinutes)} offen` : 'Keine'],
['Verifikation', t.requiresVerification ? 'Erforderlich' : 'Keine'],
['Aufgaben-Modus', t.taskMode === 'KEYHOLDER' ? 'Keyholder' : t.taskMode === 'COMMUNITY' ? 'Community' : 'Zufällig'],
]));
if (t.tasks && t.tasks.length) {
const taskItems = t.tasks.map(task => {
const dur = task.durationMinutes ? ` <span style="color:var(--color-muted);font-size:0.8rem;">(${fmtMinutes(task.durationMinutes)})</span>` : '';
const desc = task.description ? `<div style="font-size:0.78rem;color:var(--color-muted);margin-top:0.1rem;">${esc(task.description)}</div>` : '';
return `<div class="detail-task-item">${esc(task.title || task.name || '–')}${dur}${desc}</div>`;
}).join('');
sections.push(`<div class="detail-section">
<div class="detail-section-title">🎯 Aufgaben (${t.tasks.length})</div>${taskItems}
</div>`);
}
return sections.join('');
}
loadOffers();
</script>
</body>
</html>

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keyholder*In bestätigt – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="app">
<div class="main">
<div class="content" style="text-align:center;padding-top:3rem;">
<div id="msgOk" style="display:none;">
<div style="font-size:3rem;margin-bottom:1rem;">🔐</div>
<h2 style="margin-bottom:0.75rem;">Keyholder*In-Rolle angenommen!</h2>
<p style="color:var(--color-muted);margin-bottom:2rem;line-height:1.6;">
Du hast die Keyholder*In-Rolle erfolgreich bestätigt.<br>
Das Lock läuft ab sofort mit dir als Keyholder*In.
</p>
<a href="/userhome.html" style="display:inline-block;padding:0.65rem 1.75rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">
Zur Startseite
</a>
</div>
<div id="msgInvalid" style="display:none;">
<div style="font-size:3rem;margin-bottom:1rem;">❌</div>
<h2 style="margin-bottom:0.75rem;">Link ungültig</h2>
<p style="color:var(--color-muted);margin-bottom:2rem;line-height:1.6;">
Dieser Bestätigungslink ist abgelaufen oder wurde bereits verwendet.
</p>
<a href="/userhome.html" style="display:inline-block;padding:0.65rem 1.75rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">
Zur Startseite
</a>
</div>
</div>
</div>
<script>
const status = new URLSearchParams(window.location.search).get('status');
document.getElementById(status === 'ok' ? 'msgOk' : 'msgInvalid').style.display = '';
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,935 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neues Lock – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.form-section {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.form-section-title {
font-size: 0.78rem;
font-weight: 700;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 1rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.9rem;
}
.form-row:last-child { margin-bottom: 0; }
.form-row label {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text);
}
.form-hint {
font-size: 0.78rem;
color: var(--color-muted);
margin-top: 0.1rem;
}
.form-row input[type="text"],
.form-row input[type="number"],
.form-row input[type="datetime-local"] {
width: 100%;
box-sizing: border-box;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.75rem;
cursor: pointer;
}
.checkbox-row:last-child { margin-bottom: 0; }
.checkbox-row input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--color-primary);
}
.checkbox-row label {
font-size: 0.9rem;
color: var(--color-text);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.combo-wrap { position: relative; }
.combo-wrap input[type="text"] { width: 100%; box-sizing: border-box; }
.combo-dropdown {
display: none;
position: absolute;
top: calc(100% + 3px);
left: 0; right: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 8px;
max-height: 220px;
overflow-y: auto;
z-index: 200;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
.combo-dropdown.open { display: block; }
.combo-option {
padding: 0.55rem 0.85rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text);
}
.combo-option:hover, .combo-option.active { background: var(--color-secondary); }
.combo-option .combo-hint { font-size: 0.78rem; color: var(--color-muted); margin-left: 0.4rem; }
.combo-empty { padding: 0.55rem 0.85rem; font-size: 0.85rem; color: var(--color-muted); font-style: italic; }
.inline-number { display: flex; align-items: center; gap: 0.5rem; }
.inline-number input { width: 90px !important; flex-shrink: 0; }
.inline-number span { font-size: 0.9rem; color: var(--color-text); }
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.form-actions button { width: auto; padding: 0.65rem 1.5rem; }
.error-msg { color: #e74c3c; font-size: 0.85rem; margin-top: 0.4rem; display: none; }
.required-star { color: #e74c3c; margin-left: 0.15em; }
.field-error input { border-color: #e74c3c !important; }
.field-error-msg { font-size: 0.78rem; color: #e74c3c; margin-top: 0.15rem; }
/* Zeitpicker */
.time-picker { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; }
.tp-seg { display:flex; flex-direction:column; align-items:center; gap:0.15rem; }
.tp-seg-row { display:flex; align-items:center; gap:0.2rem; }
.tp-seg button {
width:24px; height:24px; background:var(--color-card);
border:1px solid var(--color-muted); border-radius:4px;
cursor:pointer; font-size:0.9rem; font-weight:700; color:var(--color-text);
display:flex; align-items:center; justify-content:center; padding:0; flex-shrink:0;
}
.tp-seg button:hover { background:var(--color-primary); color:#fff; border-color:var(--color-primary); }
.tp-seg input {
width:28px; text-align:center; background:var(--color-card);
border:1px solid var(--color-muted); border-radius:4px;
color:var(--color-text); font-size:0.9rem; font-weight:600;
font-family:monospace; padding:0.15rem 0; box-sizing:border-box;
}
.tp-seg .tp-label { font-size:0.62rem; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.04em; }
.tp-colon { font-size:1rem; font-weight:700; color:var(--color-muted); margin-bottom:0.9rem; }
/* LockControl-Auswahl */
.lockcontrol-options { display: flex; flex-direction: column; gap: 0.6rem; }
.lockcontrol-option {
display: flex; align-items: flex-start; gap: 0.7rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--color-secondary); border-radius: 8px;
cursor: pointer; transition: border-color 0.15s;
}
.lockcontrol-option:hover:not(.lc-disabled) { border-color: var(--color-primary); }
.lockcontrol-option.lc-selected { border-color: var(--color-primary); background: rgba(var(--color-primary-rgb, 180,80,255),0.06); }
.lockcontrol-option.lc-disabled { opacity: 0.55; cursor: not-allowed; }
.lockcontrol-option input[type="radio"] {
margin-top: 0.15rem; flex-shrink: 0;
accent-color: var(--color-primary); width: 1rem; height: 1rem; cursor: pointer;
}
.lockcontrol-option.lc-disabled input[type="radio"] { cursor: not-allowed; }
.lc-label { font-size: 0.9rem; font-weight: 600; color: var(--color-text); }
.lc-desc { font-size: 0.78rem; color: var(--color-muted); margin-top: 0.15rem; }
.lc-badge {
display: inline-block; font-size: 0.68rem; font-weight: 700;
padding: 0.15em 0.5em; border-radius: 4px;
background: var(--color-primary); color: #fff;
margin-left: 0.4rem; vertical-align: middle; letter-spacing: 0.03em;
}
/* Unlock-Code-Modal */
.modal-overlay {
display: none; position: fixed; inset: 0; z-index: 500;
align-items: center; justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal-bg { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
.modal-box {
position: relative; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 12px;
padding: 1.5rem 1.5rem 1.25rem; max-width: 380px; width: 90%;
display: flex; flex-direction: column; align-items: center; gap: 0.75rem; z-index: 1;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h2 style="margin-bottom:1.25rem;">🔒 Neues Lock</h2>
<!-- Vorlage (Pflichtfeld) -->
<div class="form-section">
<div class="form-section-title">Vorlage<span class="required-star">*</span></div>
<div class="form-row" id="rowTemplate">
<div class="combo-wrap" id="templateCombo">
<input type="text" id="templateInput" placeholder="Vorlage suchen…" autocomplete="off">
<div class="combo-dropdown" id="templateDropdown"></div>
<input type="hidden" id="templateValue">
</div>
</div>
</div>
<!-- Personen -->
<div class="form-section">
<div class="form-section-title">Personen</div>
<div class="form-row" id="rowLockee">
<label for="lockeeInput">Lockee<span class="required-star">*</span></label>
<div class="combo-wrap" id="lockeeCombo">
<input type="text" id="lockeeInput" placeholder="Suchen oder „Ich selbst"…" autocomplete="off">
<div class="combo-dropdown" id="lockeeDropdown"></div>
<input type="hidden" id="lockeeValue">
</div>
<div class="form-hint">Wähle dich selbst oder einen Freund als Lockee.</div>
</div>
<div class="checkbox-row" id="rowDetailsVisible" style="display:none;">
<input type="checkbox" id="lockeeDetailsVisible" checked>
<label for="lockeeDetailsVisible">Details für Lockee sichtbar
<span class="form-hint">(Lockee sieht die Lock-Konfiguration vor dem Annehmen)</span>
</label>
</div>
<div class="form-row" id="rowKeyholder">
<label for="keyholderInput">Keyholder*In</label>
<div class="combo-wrap" id="keyholderCombo">
<input type="text" id="keyholderInput" placeholder="Freund suchen…" autocomplete="off">
<div class="combo-dropdown" id="keyholderDropdown"></div>
<input type="hidden" id="keyholderValue">
</div>
<div class="form-hint">Ohne Keyholder läuft das Lock als Self-Lock.</div>
</div>
</div>
<!-- Optionen -->
<div class="form-section">
<div class="form-section-title">Optionen</div>
<!-- CardLock: Längste Dauer -->
<div class="form-row" id="rowMaxDuration">
<label>Längste Dauer</label>
<div class="time-picker">
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('dur',-1,'d')">−</button>
<input type="text" id="dur_d" value="0" readonly>
<button type="button" onclick="tpChange('dur',1,'d')">+</button>
</div>
<span class="tp-label">Tage</span>
</div>
<div class="tp-colon">:</div>
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('dur',-1,'h')">−</button>
<input type="text" id="dur_h" value="00" readonly>
<button type="button" onclick="tpChange('dur',1,'h')">+</button>
</div>
<span class="tp-label">Std</span>
</div>
</div>
<div class="form-hint">Das Lock öffnet spätestens nach dieser Zeit automatisch. 0 : 00 = keine Begrenzung.</div>
</div>
<!-- TimeLock: Dauer-Info aus Vorlage -->
<div class="form-row" id="rowTimeLockInfo" style="display:none;">
<label>Sperrdauer</label>
<div id="timeLockDurationText" style="font-size:0.9rem;color:var(--color-text);padding:0.3rem 0;"></div>
<div class="form-hint">Die Dauer wird beim Lock-Start zufällig aus dem Bereich der Vorlage gewählt.</div>
</div>
<!-- LockControl-Auswahl -->
<div class="form-row" id="rowLockControl">
<label>Schloss-Steuerung</label>
<div class="lockcontrol-options">
<label class="lockcontrol-option lc-selected" id="lcOptUnlockCode" onclick="selectLockControl('UNLOCK_CODE')">
<input type="radio" name="lockControl" value="UNLOCK_CODE" checked>
<div>
<div class="lc-label">🔢 Unlock-Code</div>
<div class="lc-desc">Ein numerischer Code wird generiert, den du in deinen Tresor einstellst.</div>
</div>
</label>
<label class="lockcontrol-option" id="lcOptTrust" onclick="selectLockControl('TRUST')">
<input type="radio" name="lockControl" value="TRUST">
<div>
<div class="lc-label">🤝 Trust</div>
<div class="lc-desc">Kein technisches Schloss – du vertraust dir selbst oder deiner Keyholder*in.</div>
</div>
</label>
<label class="lockcontrol-option lc-disabled" id="lcOptTtlock" onclick="selectLockControl('TTLOCK')">
<input type="radio" name="lockControl" value="TTLOCK" disabled>
<div>
<div class="lc-label">📱 TTLock <span class="lc-badge" id="lcTtlockBadge">ABO</span></div>
<div class="lc-desc" id="lcTtlockDesc">Steuert ein TTLock-Smartschloss direkt über die App-Integration. Erfordert ein aktives Abonnement.</div>
</div>
</label>
</div>
</div>
<div class="form-row" id="rowUnlockCodeLines">
<label for="unlockCodeLines">Anzahl Ziffern des Entsperrcodes</label>
<div class="inline-number">
<input type="number" id="unlockCodeLines" min="1" max="20" value="5">
<span>Ziffern</span>
</div>
</div>
<div class="checkbox-row" id="rowTestLock">
<input type="checkbox" id="testLock">
<label for="testLock">Test-Lock <span class="form-hint">(kein echter Lock, zum Ausprobieren)</span></label>
</div>
</div>
<div class="error-msg" id="errorMsg"></div>
<div class="form-actions">
<button onclick="history.back()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);">Abbrechen</button>
<button onclick="createSession()">🔒 Lock starten</button>
</div>
</div>
</div>
<!-- TTLock Lade-Overlay -->
<div class="modal-overlay" id="ttlLoadingOverlay">
<div class="modal-bg"></div>
<div class="modal-box" style="max-width:320px;text-align:center;gap:0.75rem;">
<div style="font-size:2rem;">⏳</div>
<div style="font-weight:600;">TTLock-Kommunikation läuft…</div>
<div style="font-size:0.85rem;color:var(--color-muted);">Bitte warten, der TTLock-Server wird kontaktiert.</div>
</div>
</div>
<!-- Entsperrcode-Modal -->
<div class="modal-overlay" id="unlockModal">
<div class="modal-bg"></div>
<div class="modal-box">
<div style="font-size:2rem;">🔒</div>
<h3 id="unlockModalTitle" style="margin:0;">Dein Entsperrcode</h3>
<p id="unlockModalHint" style="color:var(--color-muted);font-size:0.85rem;text-align:center;margin:0;">
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
</p>
<div id="unlockCodeDisplay" style="
font-family: monospace; font-size: 2rem; letter-spacing: 0.3em;
background: var(--color-secondary); border-radius: 8px;
padding: 1rem 1.5rem; text-align: center; color: var(--color-primary);
line-height: 1.8; word-break: break-all;
"></div>
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);text-align:center;font-family:monospace;"></div>
<div id="unlockKeyholderHint" style="display:none;background:var(--color-secondary);border-radius:8px;padding:0.75rem 1rem;font-size:0.85rem;color:var(--color-muted);text-align:center;line-height:1.5;">
⏳ Die eingetragene Keyholder*In wurde benachrichtigt und muss die Rolle noch bestätigen.
Bis zur Bestätigung läuft das Lock als Self-Lock.
</div>
<button id="unlockModalBtn" onclick="" style="width:100%;margin-top:0.25rem;">Weiter</button>
</div>
</div>
<script src="/js/card-defs.js"></script>
<script src="/js/shared.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let myUserId = null;
let myUserName = null;
let allFriends = [];
let allTemplates = []; // combined; each entry has _type: 'cardlock'|'timelock'
let selectedTemplate = null;
let comboActiveIdx = -1;
let selectedLockControl = 'UNLOCK_CODE';
let hasPaidSubscription = false;
let ttlockReady = false;
// ── Boot ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
if (!user) { window.location.href = '/login.html'; return; }
myUserId = user.userId;
myUserName = user.name;
// Subscription + Templates (eigene + abonnierte) + TTLock-Config parallel laden
try {
const [ownTpls, subTpls, subData, ttlCfg] = await Promise.all([
fetch('/templates/mine').then(r => r.ok ? r.json() : []),
fetch('/templates/subscribed').then(r => r.ok ? r.json() : []),
fetch('/subscription/me').then(r => r.ok ? r.json() : null),
fetch('/user/me/ttlock').then(r => r.ok ? r.json() : null)
]);
const toEntry = t => ({ ...t, _type: t.lockType === 'TIMELOCK' ? 'timelock' : 'cardlock' });
const ownIds = new Set(ownTpls.map(t => t.templateId));
allTemplates = [
...ownTpls.map(toEntry),
...subTpls.filter(t => !ownIds.has(t.templateId)).map(toEntry)
];
hasPaidSubscription = !!(subData && subData.subscriptionType === 'PREMIUM');
ttlockReady = !!(ttlCfg && ttlCfg.testSuccessful);
const ttlockTestOk = ttlockReady;
if (hasPaidSubscription && ttlockTestOk) {
const opt = document.getElementById('lcOptTtlock');
opt.classList.remove('lc-disabled');
opt.querySelector('input').disabled = false;
document.getElementById('lcTtlockBadge').style.display = 'none';
document.getElementById('lcTtlockDesc').textContent =
'Steuert ein TTLock-Smartschloss direkt über die App-Integration.';
} else if (hasPaidSubscription && !ttlockTestOk) {
document.getElementById('lcTtlockBadge').textContent = 'KONFIG';
document.getElementById('lcTtlockDesc').textContent =
'TTLock ist noch nicht konfiguriert. Bitte teste die Verbindung zuerst in den Einstellungen.';
}
} catch { allTemplates = []; }
if (allTemplates.length === 0) {
document.querySelector('.content').innerHTML = `
<div style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;">📋</div>
<h2 style="margin-bottom:0.75rem;">Keine Vorlagen vorhanden</h2>
<p style="color:var(--color-muted);margin-bottom:2rem;">
Du musst zuerst mindestens eine Lock-Vorlage erstellen,<br>
bevor du ein neues Lock starten kannst.
</p>
<a href="/games/chastity/meine-locks.html" style="display:inline-block;padding:0.7rem 1.8rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Vorlage erstellen</a>
</div>`;
return;
}
setupTemplateCombo();
await loadOptions(user.userId);
});
async function loadOptions(myId) {
try {
allFriends = await fetch('/social/friends/user/' + myId).then(r => r.ok ? r.json() : []);
} catch { allFriends = []; }
setupLockeeCombo();
setupKeyholderCombo();
document.getElementById('lockeeInput').value = 'Ich selbst';
document.getElementById('lockeeValue').value = myId;
}
// ── Template-Combobox ──
function setupTemplateCombo() {
const input = document.getElementById('templateInput');
const dropdown = document.getElementById('templateDropdown');
const hidden = document.getElementById('templateValue');
function renderDropdown(query) {
const q = query.toLowerCase().trim();
const filtered = q
? allTemplates.filter(t => (t.name || '').toLowerCase().includes(q))
: allTemplates;
dropdown.innerHTML = '';
if (filtered.length === 0) {
dropdown.innerHTML = `<div class="combo-empty">Keine Vorlagen gefunden.</div>`;
} else {
filtered.forEach(t => {
const badge = t._type === 'timelock' ? '⏱' : '🃏';
const label = (t.name || 'Unbenannte Vorlage');
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = t.templateId;
div.innerHTML = `${badge} ${label}`;
div.addEventListener('mousedown', e => {
e.preventDefault();
hidden.value = t.templateId;
input.value = badge + ' ' + label;
selectedTemplate = t;
dropdown.classList.remove('open');
clearFieldError('rowTemplate');
onTemplateChanged(t);
});
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
input.addEventListener('focus', () => renderDropdown(input.value));
input.addEventListener('blur', () => {
setTimeout(() => {
dropdown.classList.remove('open');
if (!hidden.value) input.value = '';
}, 150);
});
}
// ── Lockee-Combobox ──
function setupLockeeCombo() {
const input = document.getElementById('lockeeInput');
const dropdown = document.getElementById('lockeeDropdown');
const hidden = document.getElementById('lockeeValue');
function renderDropdown(query) {
const q = query.toLowerCase().trim();
const selfMatch = 'ich selbst'.includes(q);
const filtered = allFriends.filter(f => f.name.toLowerCase().includes(q));
dropdown.innerHTML = '';
comboActiveIdx = -1;
if (!selfMatch && filtered.length === 0) {
dropdown.innerHTML = `<div class="combo-empty">${q ? 'Keine Treffer.' : 'Keine Freunde vorhanden.'}</div>`;
} else {
if (selfMatch) {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = myUserId; div.dataset.name = 'Ich selbst';
div.innerHTML = 'Ich selbst<span class="combo-hint">(Self-Lock)</span>';
div.addEventListener('mousedown', e => { e.preventDefault(); selectLockee(myUserId, 'Ich selbst'); });
dropdown.appendChild(div);
}
filtered.forEach(f => {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = f.userId; div.dataset.name = f.name;
div.textContent = f.name;
div.addEventListener('mousedown', e => { e.preventDefault(); selectLockee(f.userId, f.name); });
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
function selectLockee(id, name) {
hidden.value = id;
input.value = name;
dropdown.classList.remove('open');
onLockeeChanged(id);
}
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
input.addEventListener('focus', () => renderDropdown(input.value));
input.addEventListener('blur', () => {
setTimeout(() => {
dropdown.classList.remove('open');
if (!hidden.value) { input.value = 'Ich selbst'; hidden.value = myUserId; onLockeeChanged(myUserId); }
}, 150);
});
}
function onLockeeChanged(lockeeId) {
const isFriend = lockeeId && lockeeId !== myUserId;
const khInput = document.getElementById('keyholderInput');
const khHidden = document.getElementById('keyholderValue');
if (isFriend) {
khInput.value = myUserName || 'Ich selbst';
khHidden.value = myUserId;
khInput.readOnly = true;
khInput.style.opacity = '0.6';
document.getElementById('rowTestLock').style.display = 'none';
document.getElementById('rowDetailsVisible').style.display = '';
} else {
khInput.readOnly = false;
khInput.style.opacity = '';
if (!khHidden.value) khInput.value = '';
document.getElementById('rowTestLock').style.display = '';
document.getElementById('rowDetailsVisible').style.display = 'none';
}
updateCodeLinesVisibility();
}
// Self-Lock-Felder beim Start ausblenden (werden durch onLockeeChanged gesetzt)
document.getElementById('rowTestLock').style.display = '';
// ── Keyholder-Combobox ──
function setupKeyholderCombo() {
const input = document.getElementById('keyholderInput');
const dropdown = document.getElementById('keyholderDropdown');
const hidden = document.getElementById('keyholderValue');
function renderDropdown(query) {
if (input.readOnly) return;
const q = query.toLowerCase().trim();
const filtered = q ? allFriends.filter(f => f.name.toLowerCase().includes(q)) : allFriends;
dropdown.innerHTML = '';
comboActiveIdx = -1;
if (filtered.length === 0) {
dropdown.innerHTML = `<div class="combo-empty">${q ? 'Keine Freunde gefunden.' : 'Keine Freunde vorhanden.'}</div>`;
} else {
filtered.forEach(f => {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = f.userId; div.dataset.name = f.name;
div.textContent = f.name;
div.addEventListener('mousedown', e => { e.preventDefault(); hidden.value = f.userId; input.value = f.name; dropdown.classList.remove('open'); });
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
input.addEventListener('focus', () => renderDropdown(input.value));
input.addEventListener('blur', () => { setTimeout(() => { dropdown.classList.remove('open'); if (!hidden.value) input.value = ''; }, 150); });
}
// ── Template-Typ: Sektionen umschalten ──
function onTemplateChanged(t) {
const isTimeLock = t._type === 'timelock';
document.getElementById('rowMaxDuration').style.display = isTimeLock ? 'none' : '';
document.getElementById('rowTimeLockInfo').style.display = isTimeLock ? '' : 'none';
if (isTimeLock) {
const minM = t.minTimeInMinutes || 0;
const maxM = t.maxTimeInMinutes || 0;
document.getElementById('timeLockDurationText').textContent =
`${fmtMinutes(minM)} – ${fmtMinutes(maxM)}`;
}
}
function fmtMinutes(m) {
if (!m) return '0 Min.';
const d = Math.floor(m / 1440);
const h = Math.floor((m % 1440) / 60);
const min = m % 60;
const parts = [];
if (d) parts.push(d + ' Tag' + (d !== 1 ? 'e' : ''));
if (h) parts.push(h + ' Std');
if (min) parts.push(min + ' Min.');
return parts.join(' ') || '0 Min.';
}
// ── LockControl-Auswahl ──
function selectLockControl(type) {
const ids = { UNLOCK_CODE: 'lcOptUnlockCode', TRUST: 'lcOptTrust', TTLOCK: 'lcOptTtlock' };
if (type === 'TTLOCK' && (!hasPaidSubscription || !ttlockReady)) return;
selectedLockControl = type;
Object.entries(ids).forEach(([t, id]) => {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle('lc-selected', t === type);
el.querySelector('input').checked = (t === type);
});
updateCodeLinesVisibility();
}
function updateCodeLinesVisibility() {
const show = selectedLockControl === 'UNLOCK_CODE' || selectedLockControl === 'TTLOCK';
const lockeeIsFriend = document.getElementById('lockeeValue').value !== myUserId
&& !!document.getElementById('lockeeValue').value;
document.getElementById('rowUnlockCodeLines').style.display = (show && !lockeeIsFriend) ? '' : 'none';
// Label je nach Typ anpassen
const label = document.querySelector('#rowUnlockCodeLines > label');
if (label) {
label.textContent = selectedLockControl === 'TTLOCK'
? 'PIN-Länge (4–9 Ziffern)'
: 'Anzahl Ziffern des Entsperrcodes';
}
// Für TTLock: min=4, max=9; Standard: min=1, max=20
const input = document.getElementById('unlockCodeLines');
if (selectedLockControl === 'TTLOCK') {
input.min = 4; input.max = 9;
if (parseInt(input.value) < 4) input.value = 6;
if (parseInt(input.value) > 9) input.value = 9;
} else {
input.min = 1; input.max = 20;
}
}
// ── Zeitpicker ──
function tpChange(prefix, delta, seg) {
let d = parseInt(document.getElementById(prefix + '_d').value) || 0;
let h = parseInt(document.getElementById(prefix + '_h')?.value) || 0;
if (seg === 'h') h += delta;
else d += delta;
if (h >= 24) { d += Math.floor(h / 24); h %= 24; }
if (h < 0) { const b = Math.ceil(-h / 24); d -= b; h += b * 24; }
if (d < 0) d = 0;
document.getElementById(prefix + '_d').value = d;
if (document.getElementById(prefix + '_h'))
document.getElementById(prefix + '_h').value = String(h).padStart(2, '0');
}
// ── Längste Dauer → LocalDateTime ──
function durationToLatestOpening() {
const days = parseInt(document.getElementById('dur_d').value) || 0;
const hours = parseInt(document.getElementById('dur_h').value) || 0;
if (days === 0 && hours === 0) return null;
const ms = (days * 24 * 3600 + hours * 3600) * 1000;
const dt = new Date(Date.now() + ms);
const pad = n => String(n).padStart(2, '0');
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
}
// ── Fehler ──
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.style.display = '';
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function showActiveLockError() {
const el = document.getElementById('errorMsg');
el.innerHTML = 'Du befindest dich bereits in einem aktiven Lock. '
+ '<a href="/games/chastity/meine-locks.html" style="color:inherit;text-decoration:underline;">Zum aktiven Lock</a>';
el.style.display = '';
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function setFieldError(rowId, msg) {
const row = document.getElementById(rowId);
if (!row) return;
row.classList.add('field-error');
let el = row.querySelector('.field-error-msg');
if (!el) { el = document.createElement('div'); el.className = 'field-error-msg'; row.appendChild(el); }
el.textContent = msg;
}
function clearFieldError(rowId) {
const row = document.getElementById(rowId);
if (!row) return;
row.classList.remove('field-error');
row.querySelector('.field-error-msg')?.remove();
}
// ── Karten aus Template aufbauen ──
function buildInitialCardsFromTemplate(t) {
const cards = [];
CARD_DEFS.forEach(c => {
const minVal = t.cardCountsMin?.[c.id] ?? 0;
const maxVal = t.cardCountsMax?.[c.id] ?? 0;
const n = minVal + Math.floor(Math.random() * (maxVal - minVal + 1));
for (let i = 0; i < n; i++) cards.push(c.id);
});
return cards;
}
// ── Plausibilitätsprüfung für TimeLock ──
function validateTimeLockPlausibility(t) {
const errors = [];
const hasTasks = t.tasks && t.tasks.length > 0;
const spinEntries = t.spinningWheelEntries || [];
// Spinning Wheel enthält Task-Felder, aber keine Aufgaben definiert
if (spinEntries.some(e => e.type === 'TASK') && !hasTasks) {
errors.push('Das Spinning Wheel enthält Aufgaben-Felder (TASK), aber die Vorlage hat keine Aufgaben definiert. Bitte die Vorlage bearbeiten.');
}
// Aufgaben-Häufigkeit konfiguriert, aber keine Aufgaben vorhanden
if ((t.taskEveryMinutes > 0 || t.minTasksPerDay > 0) && !hasTasks) {
errors.push('Aufgaben sind zeitlich konfiguriert, aber keine Aufgaben in der Vorlage definiert. Bitte die Vorlage bearbeiten.');
}
// Unbegrenztes Einfrieren ohne Auftau-Eintrag
if (spinEntries.some(e => e.type === 'FREEZE') && !spinEntries.some(e => e.type === 'UNFREEZE')) {
errors.push('Das Spinning Wheel enthält ein unbegrenztes Einfrieren (FREEZE), aber keinen Auftau-Eintrag (UNFREEZE). Das Lock könnte dauerhaft eingefroren bleiben.');
}
return errors;
}
function showPlausibilityErrors(errors) {
const el = document.getElementById('errorMsg');
if (errors.length === 1) {
el.textContent = errors[0];
} else {
el.innerHTML = 'Die Vorlage enthält inkonsistente Einstellungen:<ul style="margin:0.4rem 0 0 1.2rem;padding:0;">'
+ errors.map(e => `<li>${e}</li>`).join('')
+ '</ul>';
}
el.style.display = '';
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// ── Absenden ──
async function createSession() {
document.getElementById('errorMsg').style.display = 'none';
const templateId = document.getElementById('templateValue').value;
if (!templateId) {
setFieldError('rowTemplate', 'Bitte eine Vorlage wählen.');
document.getElementById('rowTemplate').scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
clearFieldError('rowTemplate');
const t = selectedTemplate || allTemplates.find(x => x.templateId === templateId);
if (!t) { showError('Vorlage nicht gefunden.'); return; }
if (t._type === 'timelock') {
const plausErrors = validateTimeLockPlausibility(t);
if (plausErrors.length > 0) {
showPlausibilityErrors(plausErrors);
return;
}
}
const lockeeVal = document.getElementById('lockeeValue').value;
const keyholderVal = document.getElementById('keyholderValue').value;
const isFriendLockee = lockeeVal && lockeeVal !== myUserId;
const unlockCodeLen = isFriendLockee ? null : (parseInt(document.getElementById('unlockCodeLines').value) || 5);
const isTestLock = isFriendLockee ? false : document.getElementById('testLock').checked;
let endpoint, body;
if (t._type === 'timelock') {
endpoint = '/keyholder/timelock';
body = {
templateId: t.templateId,
lockeeUserId: isFriendLockee ? lockeeVal : null,
lockeeDetailsVisible: isFriendLockee ? document.getElementById('lockeeDetailsVisible').checked : false,
keyholder: isFriendLockee ? null : (keyholderVal || null),
testLock: isTestLock,
unlockCodeLength: unlockCodeLen,
controllType: selectedLockControl,
};
} else {
// CardLock
const initialCards = buildInitialCardsFromTemplate(t);
if (initialCards.length === 0) {
showError('Die gewählte Vorlage enthält keine Karten. Bitte Vorlage prüfen.');
return;
}
endpoint = '/keyholder/cardlock';
body = {
name: t.name,
lockeeUserId: isFriendLockee ? lockeeVal : null,
lockeeDetailsVisible: isFriendLockee ? document.getElementById('lockeeDetailsVisible').checked : false,
keyholder: isFriendLockee ? null : (keyholderVal || null),
initialCards,
pickEveryMinute: t.pickEveryMinute,
accumulatePicks: t.accumulatePicks,
showRemainingCards: t.showRemainingCards,
latestOpeningtime: durationToLatestOpening(),
hygineOpeningEveryMinites: t.hygineOpeningEveryMinites || null,
hygineOpeningDurationMinutes: t.hygineOpeningDurationMinutes || null,
tasks: t.tasks || [],
taskCardMode: t.taskCardMode || 'RANDOM',
unlockCodeLines: unlockCodeLen,
requiresVerification: t.requiresVerification,
testLock: isTestLock,
controllType: selectedLockControl,
};
}
if (selectedLockControl === 'TTLOCK') {
document.getElementById('ttlLoadingOverlay').classList.add('open');
}
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
document.getElementById('ttlLoadingOverlay').classList.remove('open');
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
if (res.status === 409 && errData.error === 'active_lock_exists') {
showActiveLockError();
} else if (res.status === 403 && errData.error === 'subscription_required') {
showError('TTLock erfordert ein aktives Abonnement.');
} else if (res.status === 400) {
showError('Ungültige Eingabe. Bitte alle Felder prüfen.');
} else {
showError('Fehler beim Erstellen des Locks.');
}
return;
}
const data = await res.json();
if (data.lockeeInvitationSent) {
window.location.href = '/games/common/einladungen.html?tab=gesendet';
} else if (!data.unlockCode) {
// Trust: kein Code, direkt weiter
const isTimeLock = selectedTemplate && selectedTemplate._type === 'timelock';
window.location.href = (isTimeLock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html')
+ '?lockId=' + data.lockId + (data.keyholderPending ? '&keyholderPending=1' : '');
} else if (selectedLockControl === 'TTLOCK') {
showTtlockStartModal(data.unlockCode, data.lockId, data.keyholderPending);
} else {
showUnlockCodeModal(data.unlockCode, data.lockId, data.keyholderPending);
}
}
// ── TTLock-Startmodal (kein Scramble, stattdessen Relock im Hintergrund) ──
function showTtlockStartModal(code, lockId, keyholderPending) {
const isTimeLock = selectedTemplate && selectedTemplate._type === 'timelock';
const lockType = isTimeLock ? 'timelock' : 'cardlock';
const targetUrl = (isTimeLock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html')
+ '?lockId=' + lockId + (keyholderPending ? '&keyholderPending=1' : '');
document.getElementById('unlockCodeDisplay').textContent = code;
document.getElementById('unlockModalTitle').textContent = 'Dein Startcode';
document.getElementById('unlockModalHint').textContent =
"Öffne das TTLock mit dem Code, lege den Schlüssel in das TTLock und verschließe es anschließend wieder. Der Code verliert anschließend seine Gültigkeit";
if (keyholderPending) document.getElementById('unlockKeyholderHint').style.display = '';
const btn = document.getElementById('unlockModalBtn');
btn.textContent = "🔒 Los geht's";
btn.onclick = async () => {
btn.disabled = true;
document.getElementById('unlockModal').classList.remove('open');
document.getElementById('ttlLoadingOverlay').classList.add('open');
try {
await fetch(`/keyholder/${lockType}/${lockId}/relock`, { method: 'POST' });
} catch { /* Fehler ignorieren – Weiterleitung trotzdem */ }
window.location.href = targetUrl;
};
document.getElementById('unlockModal').classList.add('open');
}
// ── Entsperrcode-Modal ──
function showUnlockCodeModal(code, lockId, keyholderPending) {
document.getElementById('unlockCodeDisplay').textContent = code;
if (keyholderPending) document.getElementById('unlockKeyholderHint').style.display = '';
const isTimeLock = selectedTemplate && selectedTemplate._type === 'timelock';
const targetPage = isTimeLock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html';
const url = targetPage + '?lockId=' + lockId + (keyholderPending ? '&keyholderPending=1' : '');
document.getElementById('unlockModalBtn').onclick = () => startCodeScramble(code, url);
document.getElementById('unlockModal').classList.add('open');
}
function startCodeScramble(realCode, url) {
const display = document.getElementById('unlockCodeDisplay');
const btn = document.getElementById('unlockModalBtn');
const hint = document.getElementById('unlockModalHint');
const countdown = document.getElementById('unlockModalCountdown');
const len = realCode.length;
const DURATION = 3 * 60;
let remaining = DURATION;
let stopped = false;
function randomCode() {
return Array.from({ length: len }, () => Math.floor(Math.random() * 10)).join('');
}
function finish() {
stopped = true;
clearInterval(scrambleInterval);
clearInterval(countdownInterval);
window.location.href = url;
}
if (hint) hint.style.display = 'none';
countdown.style.display = '';
document.getElementById('unlockModalTitle').textContent = 'Nun vergessen wir den Code…';
btn.textContent = 'Abbrechen';
btn.onclick = finish;
const scrambleInterval = setInterval(() => {
if (!stopped) display.textContent = randomCode();
}, 80);
const countdownInterval = setInterval(() => {
if (stopped) return;
remaining--;
const m = Math.floor(remaining / 60);
const s = remaining % 60;
countdown.textContent = `Weiterleitung in ${m}:${String(s).padStart(2,'0')}`;
if (remaining <= 0) finish();
}, 1000);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0; url=/games/chastity/neulock.html">
</head>
<body></body>
</html>

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code-Historie – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.history-list { display:flex; flex-direction:column; gap:1rem; margin-top:1.25rem; }
.history-card {
background:var(--color-secondary); border:1px solid var(--color-secondary);
border-radius:10px; padding:1rem 1.2rem;
}
.history-header { display:flex; align-items:center; justify-content:space-between; gap:0.5rem; margin-bottom:0.6rem; }
.history-lock-name { font-weight:700; font-size:0.95rem; }
.history-source {
font-size:0.75rem; color:var(--color-muted);
background:var(--color-card); border-radius:6px;
padding:0.15rem 0.5rem; white-space:nowrap;
}
.history-code {
font-family: monospace; font-size:0.85rem;
background:var(--color-card); border-radius:6px;
padding:0.5rem 0.75rem; word-break:break-all; line-height:1.5;
margin-bottom:0.5rem;
}
.history-time { font-size:0.78rem; color:var(--color-muted); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.75rem; }
.page-hint { font-size:0.85rem; color:var(--color-muted); margin:0.25rem 0 0; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin:0 0 0.25rem;">🔙 Entsperrcode-Historie</h1>
<p class="page-hint">Die letzten 10 Entsperrcodes, die dir angezeigt wurden.</p>
<div class="history-list" id="historyList">
<span class="empty-hint">Wird geladen…</span>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const SOURCE_LABELS = {
GREEN_CARD: 'Grüne Karte',
HYGIENE_OPEN: 'Hygiene-Öffnung',
HYGIENE_CLOSE: 'Hygiene-Öffnung (neu)',
KEYHOLDER_UNLOCK: 'Freigabe durch Keyholder',
};
async function load() {
const res = await fetch('/keyholder/cardlock/unlock-history');
const list = document.getElementById('historyList');
if (!res.ok) { list.innerHTML = '<span class="empty-hint">Fehler beim Laden.</span>'; return; }
const entries = await res.json();
if (!entries.length) { list.innerHTML = '<span class="empty-hint">Noch keine Entsperrcodes erhalten.</span>'; return; }
list.innerHTML = '';
for (const e of entries) {
const dt = new Date(e.receivedAt);
const formatted = dt.toLocaleString('de-DE', {
day:'2-digit', month:'2-digit', year:'numeric',
hour:'2-digit', minute:'2-digit'
});
const sourceLabel = SOURCE_LABELS[e.source] || e.source;
const card = document.createElement('div');
card.className = 'history-card';
card.innerHTML = `
<div class="history-header">
<span class="history-lock-name">${escHtml(e.lockName)}</span>
<span class="history-source">${escHtml(sourceLabel)}</span>
</div>
<div class="history-code">${escHtml(e.unlockCode)}</div>
<div class="history-time">Erhalten am ${escHtml(formatted)}</div>
`;
list.appendChild(card);
}
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
load();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,982 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einladungen – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* Tabs */
.tabs-bar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-secondary);
margin-bottom: 1.5rem;
}
.tab-btn {
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
padding: 0.6rem 1.25rem;
font-size: 0.92rem;
font-weight: 600;
color: var(--color-muted);
cursor: pointer;
width: auto;
border-radius: 0;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: var(--color-text); background: none; }
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Liste */
.inv-list { display: flex; flex-direction: column; gap: 0.5rem; }
.inv-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
display: flex; align-items: center; gap: 0.9rem;
padding: 0.75rem 1rem;
}
/* Avatar mit Typ-Badge */
.inv-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.inv-avatar {
width: 52px; height: 52px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
}
.inv-avatar img { width: 100%; height: 100%; object-fit: cover; }
.inv-type-badge {
position: absolute;
top: -6px; left: -6px;
width: 26px; height: 26px;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1.08rem;
z-index: 1;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.inv-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.inv-line1 { font-size: 0.78rem; color: var(--color-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inv-line2 { font-weight: 700; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inv-line3 { font-size: 0.78rem; color: var(--color-muted); }
.empty-hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.25rem; }
/* Paging */
.paging-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
font-size: 0.88rem;
color: var(--color-muted);
}
.paging-bar button {
width: auto;
padding: 0.4rem 0.9rem;
font-size: 0.85rem;
}
.paging-bar button:disabled {
opacity: 0.35;
cursor: default;
}
/* Lockee-Einladungs-Dialog */
.lockee-dialog-bg {
display: none; position: fixed; inset: 0; z-index: 400;
align-items: center; justify-content: center;
}
.lockee-dialog-bg.open { display: flex; }
.lockee-dialog-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
.lockee-dialog-box {
position: relative; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 12px;
padding: 1.75rem 1.5rem 1.5rem; max-width: 420px; width: 92%; z-index: 1;
display: flex; flex-direction: column; gap: 1rem;
max-height: 90vh; overflow-y: auto;
}
.lockee-dialog-header { display: flex; align-items: center; gap: 0.75rem; }
.lockee-dialog-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; flex-shrink: 0; overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
}
.lockee-dialog-avatar img { width: 100%; height: 100%; object-fit: cover; }
.lockee-dialog-title { font-weight: 700; font-size: 1rem; }
.lockee-dialog-sub { font-size: 0.82rem; color: var(--color-muted); margin-top: 0.1rem; }
.lockee-dialog-detail {
background: var(--color-secondary); border-radius: 8px;
padding: 0.75rem 1rem; font-size: 0.88rem;
}
.lockee-dialog-detail dt { color: var(--color-muted); font-size: 0.75rem; margin-bottom: 0.1rem; }
.lockee-dialog-detail dd { font-weight: 600; margin: 0 0 0.5rem 0; }
.lockee-dialog-detail dd:last-child { margin-bottom: 0; }
.lockee-dialog-codelines { display: flex; align-items: center; gap: 0.6rem; }
.lockee-dialog-codelines label { font-size: 0.88rem; font-weight: 600; white-space: nowrap; }
.lockee-dialog-codelines input { width: 72px; text-align: center; }
.lockee-dialog-codelines span { font-size: 0.88rem; color: var(--color-muted); }
.lockee-dialog-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
.lockee-dialog-actions button { width: auto; padding: 0.6rem 1.3rem; font-size: 0.9rem; }
.btn-accept { background: var(--color-success, #27ae60) !important; }
.btn-accept:hover { background: #219150 !important; }
.btn-decline { background: #c0392b !important; }
.btn-decline:hover { background: #a93226 !important; }
.lockee-dialog-error { color: #e74c3c; font-size: 0.82rem; display: none; }
/* Lock-Details im Dialog */
.lock-details-section { display: flex; flex-direction: column; gap: 0.5rem; }
.lock-details-cards {
display: grid; grid-template-columns: repeat(auto-fill, minmax(68px, 1fr)); gap: 0.4rem;
}
.lock-details-card-item {
background: var(--color-secondary); border-radius: 6px;
padding: 0.4rem 0.3rem;
display: flex; flex-direction: column; align-items: center; gap: 0.2rem; text-align: center;
}
.lock-details-card-item img { width: 36px; height: auto; border-radius: 3px; }
.lock-details-card-item .ldc-count { font-weight: 700; font-size: 0.9rem; }
.lock-details-card-item .ldc-name { font-size: 0.65rem; color: var(--color-muted); line-height: 1.2; }
.lock-details-meta { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.lock-details-badge {
background: var(--color-secondary); border-radius: 20px;
padding: 0.2rem 0.6rem; font-size: 0.75rem; color: var(--color-muted);
}
.blind-hint {
background: var(--color-secondary); border-radius: 8px; padding: 0.9rem 1rem;
display: flex; gap: 0.6rem; align-items: flex-start;
font-size: 0.85rem; color: var(--color-muted); line-height: 1.5;
}
.blind-hint-icon { font-size: 1.4rem; flex-shrink: 0; }
/* Bestätigungs-Modal */
.confirm-modal-bg {
display: none; position: fixed; inset: 0; z-index: 600;
align-items: center; justify-content: center;
}
.confirm-modal-bg.open { display: flex; }
.confirm-modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); }
.confirm-modal-box {
position: relative; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 12px;
padding: 1.75rem 1.5rem 1.5rem; max-width: 380px; width: 92%; z-index: 1;
display: flex; flex-direction: column; gap: 1rem;
}
.confirm-modal-title {
font-weight: 700; font-size: 1rem; padding-right: 1.5rem;
}
.confirm-modal-text {
font-size: 0.9rem; color: var(--color-muted); line-height: 1.5;
}
.confirm-modal-actions {
display: flex; gap: 0.6rem; justify-content: flex-end; margin-top: 0.25rem;
}
.confirm-modal-actions button { width: auto; padding: 0.6rem 1.3rem; font-size: 0.9rem; }
.confirm-modal-cancel { background: var(--color-secondary) !important; color: var(--color-text) !important; }
.confirm-modal-ok { background: #c0392b !important; }
.confirm-modal-ok:hover { background: #a93226 !important; }
/* Entsperrcode-Modal */
.unlock-modal-bg {
display: none; position: fixed; inset: 0; z-index: 500;
align-items: center; justify-content: center;
}
.unlock-modal-bg.open { display: flex; }
.unlock-modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); }
.unlock-modal-box {
position: relative; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 12px;
padding: 1.5rem 1.5rem 1.25rem; max-width: 380px; width: 90%; z-index: 1;
display: flex; flex-direction: column; align-items: center; gap: 0.75rem; text-align: center;
}
.unlock-code-display {
font-family: monospace; font-size: 2rem; letter-spacing: 0.3em;
background: var(--color-secondary); border-radius: 8px;
padding: 1rem 1.5rem; color: var(--color-primary);
line-height: 1.8; word-break: break-all; width: 100%; box-sizing: border-box;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom:1.25rem;">Einladungen</h1>
<div class="tabs-bar">
<button class="tab-btn active" data-tab="empfangen" onclick="switchTab('empfangen')">Empfangen</button>
<button class="tab-btn" data-tab="gesendet" onclick="switchTab('gesendet')">Gesendet</button>
</div>
<!-- Tab: Empfangen -->
<div id="tab-empfangen" class="tab-panel active">
<div class="inv-list" id="recvList"></div>
<p class="empty-hint" id="recvEmpty" style="display:none;">Keine ausstehenden Einladungen.</p>
<div class="paging-bar" id="recvPaging" style="display:none;"></div>
</div>
<!-- Tab: Gesendet -->
<div id="tab-gesendet" class="tab-panel">
<div class="inv-list" id="sentList"></div>
<p class="empty-hint" id="sentEmpty" style="display:none;">Keine ausstehenden gesendeten Einladungen.</p>
<div class="paging-bar" id="sentPaging" style="display:none;"></div>
</div>
</div>
</div>
<!-- Bestätigungs-Modal -->
<div class="confirm-modal-bg" id="confirmModal">
<div class="confirm-modal-overlay" onclick="confirmCancel()"></div>
<div class="confirm-modal-box">
<button onclick="confirmCancel()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;" title="Schließen">✕</button>
<div class="confirm-modal-title" id="confirmTitle"></div>
<div class="confirm-modal-text" id="confirmText"></div>
<div class="confirm-modal-actions">
<button class="confirm-modal-cancel" onclick="confirmCancel()">Abbrechen</button>
<button class="confirm-modal-ok" id="confirmOkBtn">Bestätigen</button>
</div>
</div>
</div>
<!-- Vanilla-Einladungs-Dialog -->
<div class="lockee-dialog-bg" id="vanillaInviteDialog">
<div class="lockee-dialog-overlay" onclick="closeVanillaInviteDialog()"></div>
<div class="lockee-dialog-box">
<button onclick="closeVanillaInviteDialog()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;" title="Schließen">✕</button>
<div class="lockee-dialog-header">
<div class="lockee-dialog-avatar">🎲</div>
<div>
<div class="lockee-dialog-title" id="vanillaDialogTitle"></div>
<div class="lockee-dialog-sub">Vanilla Game – Einladung</div>
</div>
</div>
<p style="font-size:0.88rem;color:var(--color-muted);line-height:1.5;margin:0;">
Du wurdest zu einem Vanilla Game eingeladen. Wie möchtest du mitspielen?
</p>
<div class="lockee-dialog-error" id="vanillaDialogError"></div>
<div class="lockee-dialog-actions" style="flex-direction:column;gap:0.5rem;">
<button class="btn-accept" style="width:100%;" onclick="acceptVanillaOwnDevice()">Am eigenen Gerät mitspielen</button>
<button class="btn-accept" style="width:100%;background:#1a5c8a!important;" onclick="acceptVanillaHostDevice()">Am Gerät des Hosts mitspielen</button>
<button class="btn-decline" style="width:100%;" onclick="declineVanillaFromDialog()">Einladung ablehnen</button>
</div>
</div>
</div>
<!-- BDSM-Einladungs-Dialog -->
<div class="lockee-dialog-bg" id="bdsmInviteDialog">
<div class="lockee-dialog-overlay" onclick="closeBdsmInviteDialog()"></div>
<div class="lockee-dialog-box">
<button onclick="closeBdsmInviteDialog()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;" title="Schließen">✕</button>
<div class="lockee-dialog-header">
<div class="lockee-dialog-avatar" id="bdsmDialogAvatar">⛓️</div>
<div>
<div class="lockee-dialog-title" id="bdsmDialogTitle"></div>
<div class="lockee-dialog-sub">BDSM Game – Einladung</div>
</div>
</div>
<p style="font-size:0.88rem;color:var(--color-muted);line-height:1.5;margin:0;">
Du wurdest zu einem BDSM Game eingeladen. Wie möchtest du mitspielen?
</p>
<div class="lockee-dialog-error" id="bdsmDialogError"></div>
<div class="lockee-dialog-actions" style="flex-direction:column;gap:0.5rem;">
<button class="btn-accept" style="width:100%;" onclick="acceptBdsmOwnDevice()">Am eigenen Gerät mitspielen</button>
<button class="btn-accept" style="width:100%;background:#1a5c8a!important;" onclick="acceptBdsmHostDevice()">Am Gerät des Hosts mitspielen</button>
<button class="btn-decline" style="width:100%;" onclick="declineBdsmFromDialog()">Einladung ablehnen</button>
</div>
</div>
</div>
<!-- Lockee-Einladungs-Dialog -->
<div class="lockee-dialog-bg" id="lockeeInviteDialog">
<div class="lockee-dialog-overlay" onclick="closeLockeeInviteDialog()"></div>
<div class="lockee-dialog-box">
<button onclick="closeLockeeInviteDialog()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;" title="Schließen">✕</button>
<div class="lockee-dialog-header">
<div class="lockee-dialog-avatar" id="dialogAvatar">🔒</div>
<div>
<div class="lockee-dialog-title" id="dialogTitle"></div>
<div class="lockee-dialog-sub" id="dialogSub"></div>
</div>
</div>
<dl class="lockee-dialog-detail" id="dialogDetail"></dl>
<div id="dialogDetailsArea"></div>
<div>
<div class="lockee-dialog-codelines">
<label for="dialogCodeLines">Ziffern des Entsperrcodes:</label>
<input type="number" id="dialogCodeLines" min="1" max="20" value="5">
<span>Ziffern</span>
</div>
</div>
<div class="lockee-dialog-error" id="dialogError"></div>
<div class="lockee-dialog-actions">
<button class="btn-decline" onclick="declineLockeeInviteDialog()">✕ Ablehnen</button>
<button class="btn-accept" onclick="acceptLockeeInviteDialog()">✓ Annehmen</button>
</div>
</div>
</div>
<!-- Entsperrcode-Modal -->
<div class="unlock-modal-bg" id="unlockModal">
<div class="unlock-modal-overlay"></div>
<div class="unlock-modal-box">
<div style="font-size:2rem;">🔒</div>
<h3 id="unlockModalTitle" style="margin:0;">Dein Entsperrcode</h3>
<p id="unlockModalHint" style="color:var(--color-muted);font-size:0.85rem;margin:0;">
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
</p>
<div class="unlock-code-display" id="unlockCodeDisplay"></div>
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);font-family:monospace;"></div>
<button id="unlockModalBtn" style="width:100%;margin-top:0.25rem;">Weiter</button>
</div>
</div>
<script src="/js/card-defs.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
// ── Tabs ──
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-' + name));
history.replaceState(null, '', '?tab=' + name);
}
const urlTab = new URLSearchParams(window.location.search).get('tab');
if (urlTab === 'gesendet') switchTab('gesendet');
// ── Konstanten ──
const PAGE_SIZE = 10;
// ── State ──
let recvItems = [];
let sentItems = [];
let recvPage = 0;
let sentPage = 0;
// ── Hilfsfunktionen ──
function fmtDate(iso) {
const dt = new Date(iso);
return dt.toLocaleDateString('de-DE') + ', ' + dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr';
}
function buildAvatarHtml(picBase64, type) {
const badge = type === 'keyholder' ? '🔑' : type === 'bdsm' ? '⛓️' : type === 'vanilla' ? '🎲' : '🔒';
const inner = picBase64
? `<div class="inv-avatar"><img src="data:image/jpeg;base64,${picBase64}" alt=""></div>`
: `<div class="inv-avatar">👤</div>`;
return `<div class="inv-avatar-wrap"><span class="inv-type-badge">${badge}</span>${inner}</div>`;
}
function renderPaging(barId, page, total, onNav) {
const bar = document.getElementById(barId);
if (total <= 1) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
bar.innerHTML = `
<button onclick="${onNav}(${page - 1})" ${page === 0 ? 'disabled' : ''}>‹ Zurück</button>
<span>Seite ${page + 1} von ${total}</span>
<button onclick="${onNav}(${page + 1})" ${page >= total - 1 ? 'disabled' : ''}>Weiter ›</button>`;
}
// ── Empfangen laden ──
async function loadReceivedInvitations() {
try {
const [lockeeRes, khRes, bdsmRes, vanillaRes] = await Promise.all([
fetch('/lockee/invitations/mine'),
fetch('/keyholder/invitations/mine'),
fetch('/bdsm/einladung/pending'),
fetch('/vanilla/einladung/pending'),
]);
const lockeeInvs = lockeeRes.ok ? await lockeeRes.json() : [];
const khInvs = khRes.ok ? await khRes.json() : [];
const bdsmInvs = bdsmRes.ok ? await bdsmRes.json() : [];
const vanillaInvs = vanillaRes.ok ? await vanillaRes.json() : [];
lockeeInvs.forEach(inv => { inv._type = 'lockee'; inv._key = inv.token; inv._otherName = inv.keyholderName; inv._otherPic = inv.keyholderProfilePic; });
khInvs.forEach(inv => { inv._type = 'keyholder'; inv._key = inv.token; inv._otherName = inv.lockeeName; inv._otherPic = inv.lockeeProfilePic; });
bdsmInvs.forEach(inv => { inv._type = 'bdsm'; inv._key = inv.einladungId; inv._otherName = inv.inviterName; inv._otherPic = inv.inviterAvatar; });
vanillaInvs.forEach(inv => { inv._type = 'vanilla'; inv._key = inv.einladungId; inv._otherName = inv.inviterName; inv._otherPic = inv.inviterAvatar || ''; });
recvItems = [...lockeeInvs, ...khInvs, ...bdsmInvs, ...vanillaInvs].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
recvPage = 0;
renderRecvPage();
} catch(e) { console.error(e); }
}
function renderRecvPage() {
const list = document.getElementById('recvList');
const empty = document.getElementById('recvEmpty');
list.innerHTML = '';
if (recvItems.length === 0) {
empty.style.display = '';
document.getElementById('recvPaging').style.display = 'none';
return;
}
empty.style.display = 'none';
const totalPages = Math.ceil(recvItems.length / PAGE_SIZE);
const start = recvPage * PAGE_SIZE;
const pageItems = recvItems.slice(start, start + PAGE_SIZE);
pageItems.forEach(inv => {
const av = buildAvatarHtml(inv._otherPic, inv._type);
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'recvinv-' + inv._key;
if (inv._type === 'lockee') card.dataset.detailsVisible = inv.detailsVisible ? '1' : '0';
let typeLabel, line2, actions;
if (inv._type === 'lockee') {
typeLabel = 'Lockee-Einladung';
line2 = 'Lockee: ' + esc(inv.lockName);
actions = `
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="declineLockeeInvitation('${esc(inv.token)}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Ablehnen</button>
<button onclick="openLockeeInviteDialog('${esc(inv.token)}')" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:var(--color-success,#27ae60);border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✓ Details</button>
</div>`;
} else if (inv._type === 'keyholder') {
typeLabel = 'Keyholder-Einladung';
line2 = 'Keyholder: ' + esc(inv.lockName);
actions = `
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="declineKhInvitation('${esc(inv.token)}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Ablehnen</button>
<a href="/keyholder/invitation/${esc(inv.token)}" style="display:block;text-align:center;padding:0.45rem 1rem;font-size:0.85rem;background:var(--color-success);color:#fff;border-radius:6px;text-decoration:none;font-weight:600;">✓ Annehmen</a>
</div>`;
} else if (inv._type === 'vanilla') {
typeLabel = 'Vanilla Game';
line2 = 'Spieleinladung';
actions = `
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="openVanillaInviteDialog('${esc(inv.einladungId)}', '${esc(inv._otherName)}')" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:var(--color-success,#27ae60);border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">🎲 Details</button>
</div>`;
} else {
typeLabel = 'BDSM Game';
line2 = 'Spieleinladung';
actions = `
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="openBdsmInviteDialog('${esc(inv.einladungId)}', '${esc(inv._otherName)}', '${esc(inv._otherPic || '')}')" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:var(--color-success,#27ae60);border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">⛓️ Details</button>
</div>`;
}
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv._otherName)}</div>
<div class="inv-line2">${line2}</div>
<div class="inv-line3">${typeLabel} · ${fmtDate(inv.createdAt)}</div>
</div>
${actions}`;
list.appendChild(card);
});
renderPaging('recvPaging', recvPage, totalPages, 'goRecvPage');
}
function goRecvPage(page) {
const total = Math.ceil(recvItems.length / PAGE_SIZE);
if (page < 0 || page >= total) return;
recvPage = page;
renderRecvPage();
document.getElementById('recvList').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function removeRecvItem(key) {
recvItems = recvItems.filter(i => i._key !== key);
const total = Math.ceil(recvItems.length / PAGE_SIZE);
if (recvPage >= total && recvPage > 0) recvPage = total - 1;
renderRecvPage();
}
// ── Gesendet laden ──
async function loadSentInvitations() {
try {
const [lockeeRes, khRes, vanillaRes] = await Promise.all([
fetch('/lockee/invitations/sent'),
fetch('/keyholder/invitations/sent'),
fetch('/vanilla/einladung/sent'),
]);
const lockeeInvs = lockeeRes.ok ? await lockeeRes.json() : [];
const khInvs = khRes.ok ? await khRes.json() : [];
const vanillaInvs = vanillaRes.ok ? await vanillaRes.json() : [];
lockeeInvs.forEach(inv => { inv._type = 'lockee'; inv._key = inv.token; inv._otherName = inv.lockeeName; inv._otherPic = inv.lockeeProfilePic; });
khInvs.forEach(inv => { inv._type = 'keyholder'; inv._key = inv.token; inv._otherName = inv.keyholderName; inv._otherPic = inv.keyholderProfilePic; });
vanillaInvs.forEach(inv => { inv._type = 'vanilla'; inv._key = inv.einladungId; inv._otherName = inv.inviteeName; inv._otherPic = ''; });
sentItems = [...lockeeInvs, ...khInvs, ...vanillaInvs].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
sentPage = 0;
renderSentPage();
} catch(e) { console.error(e); }
}
function renderSentPage() {
const list = document.getElementById('sentList');
const empty = document.getElementById('sentEmpty');
list.innerHTML = '';
if (sentItems.length === 0) {
empty.style.display = '';
document.getElementById('sentPaging').style.display = 'none';
return;
}
empty.style.display = 'none';
const totalPages = Math.ceil(sentItems.length / PAGE_SIZE);
const start = sentPage * PAGE_SIZE;
const pageItems = sentItems.slice(start, start + PAGE_SIZE);
pageItems.forEach(inv => {
const av = buildAvatarHtml(inv._otherPic, inv._type);
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'sentinv-' + inv._key;
let typeLabel, line2sent, extra = '';
if (inv._type === 'lockee') {
typeLabel = 'Lockee-Einladung';
line2sent = 'Lockee: ' + esc(inv.lockName);
extra = inv.detailsVisible
? ' &nbsp;<span style="font-size:0.72rem;">👁 Details sichtbar</span>'
: ' &nbsp;<span style="font-size:0.72rem;">🙈 Details verborgen</span>';
} else if (inv._type === 'vanilla') {
typeLabel = 'Vanilla Game';
line2sent = 'Spieleinladung';
} else {
typeLabel = 'Keyholder-Einladung';
line2sent = 'Keyholder: ' + esc(inv.lockName);
}
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv._otherName)}</div>
<div class="inv-line2">${line2sent}</div>
<div class="inv-line3">${typeLabel} · ${fmtDate(inv.createdAt)}${extra}</div>
</div>
<div style="flex-shrink:0;">
<button onclick="cancelSentInvitation('${esc(inv._key)}', '${inv._type}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Zurückziehen</button>
</div>`;
list.appendChild(card);
});
renderPaging('sentPaging', sentPage, totalPages, 'goSentPage');
}
function goSentPage(page) {
const total = Math.ceil(sentItems.length / PAGE_SIZE);
if (page < 0 || page >= total) return;
sentPage = page;
renderSentPage();
document.getElementById('sentList').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function removeSentItem(key) {
sentItems = sentItems.filter(i => i._key !== key);
const total = Math.ceil(sentItems.length / PAGE_SIZE);
if (sentPage >= total && sentPage > 0) sentPage = total - 1;
renderSentPage();
}
// ── Bestätigungs-Modal ──
let _confirmResolve = null;
function showConfirm(title, text) {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmText').textContent = text;
document.getElementById('confirmModal').classList.add('open');
document.querySelector('#confirmModal .confirm-modal-cancel').style.display = '';
return new Promise(resolve => {
_confirmResolve = resolve;
document.getElementById('confirmOkBtn').onclick = () => { confirmClose(true); };
});
}
function showInfo(title, text) {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmText').textContent = text;
document.getElementById('confirmModal').classList.add('open');
document.querySelector('#confirmModal .confirm-modal-cancel').style.display = 'none';
return new Promise(resolve => {
_confirmResolve = resolve;
document.getElementById('confirmOkBtn').onclick = () => { confirmClose(true); };
});
}
function confirmCancel() { confirmClose(false); }
function confirmClose(result) {
document.getElementById('confirmModal').classList.remove('open');
if (_confirmResolve) { _confirmResolve(result); _confirmResolve = null; }
}
// ── Aktionen: Empfangen ──
async function declineLockeeInvitation(token, btn) {
if (!await showConfirm('Einladung ablehnen', 'Bist du sicher, dass du diese Einladung ablehnen möchtest?')) return;
btn.disabled = true;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) { removeRecvItem(token); }
else { btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
async function declineKhInvitation(token, btn) {
if (!await showConfirm('Einladung ablehnen', 'Bist du sicher, dass du diese Einladung ablehnen möchtest?')) return;
btn.disabled = true;
try {
const res = await fetch('/keyholder/invitations/mine/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) { removeRecvItem(token); }
else { btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
// ── Aktionen: Gesendet ──
async function cancelSentInvitation(key, type, btn) {
const title = 'Einladung zurückziehen';
const text = type === 'lockee'
? 'Das Lock wird gelöscht und der Lockee wird benachrichtigt.'
: type === 'vanilla'
? 'Der eingeladene Spieler wird benachrichtigt.'
: 'Der Keyholder wird benachrichtigt.';
if (!await showConfirm(title, text)) return;
btn.disabled = true;
const url = type === 'lockee'
? '/lockee/invitations/sent/' + encodeURIComponent(key)
: type === 'vanilla'
? '/vanilla/einladung/' + encodeURIComponent(key)
: '/keyholder/invitations/sent/' + encodeURIComponent(key);
try {
const res = await fetch(url, { method: 'DELETE' });
if (res.ok || res.status === 204) { removeSentItem(key); }
else { btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
// ── Lockee-Einladungs-Dialog ──
// CARD_DEFS wird von /js/card-defs.js bereitgestellt.
function fmtMinutes(min) {
if (!min) return '–';
const d = Math.floor(min / (24 * 60));
const h = Math.floor((min % (24 * 60)) / 60);
const m = min % 60;
const parts = [];
if (d) parts.push(d + 'd');
if (h) parts.push(h + 'h');
if (m) parts.push(m + 'min');
return parts.join(' ') || '–';
}
function renderLockDetails(inv) {
if (!inv.detailsVisible) {
return `<div class="blind-hint">
<span class="blind-hint-icon">🙈</span>
<span>Der Keyholder hat die Lock-Details nicht freigegeben. Du weißt nicht, worauf du dich einlässt.</span>
</div>`;
}
const cardCounts = inv.cardCounts || {};
const totalCards = Object.values(cardCounts).reduce((a, b) => a + b, 0);
const cardsHtml = CARD_DEFS
.filter(c => cardCounts[c.id] > 0)
.map(c => `<div class="lock-details-card-item">
<img src="${c.img}" alt="${c.name}">
<span class="ldc-count">${cardCounts[c.id]}×</span>
<span class="ldc-name">${c.name}</span>
</div>`).join('');
const badges = [];
badges.push(`🃏 ${totalCards} Karten`);
badges.push(`⏱ Ziehen alle ${fmtMinutes(inv.pickEveryMinute)}`);
if (inv.accumulatePicks) badges.push('📦 Picks akkumulieren');
if (inv.showRemainingCards) badges.push('👁 Karten sichtbar');
if (inv.hygineOpeningEveryMinites) badges.push(`🚿 Hygiene alle ${fmtMinutes(inv.hygineOpeningEveryMinites)} (${fmtMinutes(inv.hygineOpeningDurationMinutes)})`);
if (inv.taskCount > 0) badges.push(`✅ ${inv.taskCount} Aufgabe${inv.taskCount !== 1 ? 'n' : ''}`);
if (inv.requiresVerification) badges.push('🔍 Verifikation erforderlich');
return `<div class="lock-details-section">
<div class="lock-details-cards">${cardsHtml}</div>
<div class="lock-details-meta">${badges.map(b => `<span class="lock-details-badge">${b}</span>`).join('')}</div>
</div>`;
}
let activeDialogToken = null;
async function openLockeeInviteDialog(token) {
activeDialogToken = token;
document.getElementById('dialogError').style.display = 'none';
document.getElementById('dialogCodeLines').value = '5';
document.getElementById('dialogDetailsArea').innerHTML = '<div style="color:var(--color-muted);font-size:0.85rem;">Lade Details…</div>';
document.getElementById('lockeeInviteDialog').classList.add('open');
const card = document.getElementById('recvinv-' + token);
const line1 = card?.querySelector('.inv-line1')?.textContent || '';
const line2 = card?.querySelector('.inv-line2')?.textContent || '';
const line3 = card?.querySelector('.inv-line3')?.textContent || '';
const imgEl = card?.querySelector('.inv-avatar img');
const avatarEl = document.getElementById('dialogAvatar');
avatarEl.innerHTML = imgEl ? `<img src="${imgEl.src}" alt="">` : '👤';
document.getElementById('dialogTitle').textContent = line2;
document.getElementById('dialogSub').textContent = line1 + ' lädt dich als Lockee ein';
document.getElementById('dialogDetail').innerHTML =
`<dt>Keyholder</dt><dd>${esc(line1)}</dd>` +
`<dt>Lock-Name</dt><dd>${esc(line2)}</dd>` +
`<dt>Datum</dt><dd>${esc(line3)}</dd>`;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token));
if (res.ok) {
document.getElementById('dialogDetailsArea').innerHTML = renderLockDetails(await res.json());
} else {
document.getElementById('dialogDetailsArea').innerHTML = '';
}
} catch(e) { document.getElementById('dialogDetailsArea').innerHTML = ''; }
}
function closeLockeeInviteDialog() {
document.getElementById('lockeeInviteDialog').classList.remove('open');
activeDialogToken = null;
}
async function acceptLockeeInviteDialog() {
if (!activeDialogToken) return;
const lines = parseInt(document.getElementById('dialogCodeLines').value);
if (!lines || lines < 1) { showDialogError('Bitte eine Ziffernanzahl eingeben.'); return; }
const acceptBtn = document.querySelector('.btn-accept');
acceptBtn.disabled = true;
document.getElementById('dialogError').style.display = 'none';
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(activeDialogToken) + '/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unlockCodeLines: lines })
});
if (!res.ok) {
acceptBtn.disabled = false;
if (res.status === 409) {
const data = await res.json().catch(() => ({}));
showDialogError(data.error === 'active_lock_exists'
? 'Du hast bereits ein aktives Lock als Lockee. Erst das bestehende Lock beenden, bevor ein neues angenommen werden kann.'
: 'Diese Einladung wurde bereits angenommen.');
} else {
showDialogError('Fehler beim Annehmen der Einladung.');
}
return;
}
const data = await res.json();
document.getElementById('lockeeInviteDialog').classList.remove('open');
removeRecvItem(activeDialogToken);
showUnlockCodeModal(data.unlockCode, data.lockId);
} catch(e) {
acceptBtn.disabled = false;
showDialogError('Fehler beim Annehmen der Einladung.');
}
}
async function declineLockeeInviteDialog() {
if (!activeDialogToken) return;
if (!await showConfirm('Einladung ablehnen', 'Bist du sicher, dass du diese Einladung ablehnen möchtest? Das Lock wird gelöscht.')) return;
const declineBtn = document.querySelector('.btn-decline');
declineBtn.disabled = true;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(activeDialogToken), { method: 'DELETE' });
if (res.ok || res.status === 204) {
removeRecvItem(activeDialogToken);
closeLockeeInviteDialog();
} else {
declineBtn.disabled = false;
showDialogError('Fehler beim Ablehnen der Einladung.');
}
} catch(e) { declineBtn.disabled = false; showDialogError('Fehler beim Ablehnen der Einladung.'); }
}
function showDialogError(msg) {
const el = document.getElementById('dialogError');
el.textContent = msg;
el.style.display = '';
}
// ── Entsperrcode-Modal ──
function showUnlockCodeModal(code, lockId) {
document.getElementById('unlockCodeDisplay').textContent = code;
const url = '/games/chastity/activelock.html?lockId=' + lockId;
const btn = document.getElementById('unlockModalBtn');
btn.onclick = () => startCodeScramble(code, url);
document.getElementById('unlockModal').classList.add('open');
}
function startCodeScramble(realCode, url) {
const display = document.getElementById('unlockCodeDisplay');
const btn = document.getElementById('unlockModalBtn');
const hint = document.getElementById('unlockModalHint');
const countdown = document.getElementById('unlockModalCountdown');
const len = realCode.length;
const DURATION = 3 * 60;
let remaining = DURATION;
let stopped = false;
function randomCode() {
return Array.from({ length: len }, () => Math.floor(Math.random() * 10)).join('');
}
function finish() {
stopped = true;
clearInterval(scrambleInterval);
clearInterval(countdownInterval);
window.location.href = url;
}
if (hint) hint.style.display = 'none';
countdown.style.display = '';
document.getElementById('unlockModalTitle').textContent = 'Nun vergessen wir den Code…';
btn.textContent = 'Abbrechen';
btn.onclick = finish;
function updateCountdown() {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
countdown.textContent = `${m}:${String(s).padStart(2, '0')}`;
}
updateCountdown();
const scrambleInterval = setInterval(() => { if (!stopped) display.textContent = randomCode(); }, 1000);
const countdownInterval = setInterval(() => {
if (stopped) return;
remaining--;
updateCountdown();
if (remaining <= 0) finish();
}, 1000);
}
// ── BDSM-Einladungs-Dialog ──
let activeBdsmEinladungId = null;
function openBdsmInviteDialog(einladungId, inviterName, inviterPic) {
activeBdsmEinladungId = einladungId;
document.getElementById('bdsmDialogTitle').textContent = inviterName + ' lädt dich ein';
document.getElementById('bdsmDialogError').style.display = 'none';
const avatarEl = document.getElementById('bdsmDialogAvatar');
avatarEl.innerHTML = inviterPic
? `<img src="data:image/jpeg;base64,${inviterPic}" alt="" style="width:100%;height:100%;object-fit:cover;">`
: '⛓️';
document.getElementById('bdsmInviteDialog').classList.add('open');
}
function closeBdsmInviteDialog() {
document.getElementById('bdsmInviteDialog').classList.remove('open');
activeBdsmEinladungId = null;
}
async function _bdsmAntworten(mode) {
if (!activeBdsmEinladungId) return;
const accepted = mode !== null;
const errEl = document.getElementById('bdsmDialogError');
errEl.style.display = 'none';
try {
const res = await fetch(`/bdsm/einladung/${activeBdsmEinladungId}/antwort`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted, mode }),
});
if (!res.ok) throw new Error();
const key = activeBdsmEinladungId;
closeBdsmInviteDialog();
removeRecvItem(key);
if (mode === 'OWN_DEVICE') {
window.location.href = `/games/bdsm/neubdsm.html`;
} else if (mode === 'HOST_DEVICE') {
await showInfo('Einladung angenommen', 'Das Spiel findet am Gerät des Hosts statt. Du wirst zur Startseite weitergeleitet.');
window.location.href = '/userhome.html';
}
} catch (_) {
errEl.textContent = 'Fehler beim Speichern der Antwort.';
errEl.style.display = '';
}
}
function acceptBdsmOwnDevice() { _bdsmAntworten('OWN_DEVICE'); }
function acceptBdsmHostDevice() { _bdsmAntworten('HOST_DEVICE'); }
async function declineBdsmFromDialog() {
if (!await showConfirm('Einladung ablehnen', 'Möchtest du diese BDSM-Game-Einladung wirklich ablehnen?')) return;
_bdsmAntworten(null);
}
// ── Vanilla-Einladungs-Dialog ──
let activeVanillaEinladungId = null;
function openVanillaInviteDialog(einladungId, inviterName) {
activeVanillaEinladungId = einladungId;
document.getElementById('vanillaDialogTitle').textContent = inviterName + ' lädt dich ein';
document.getElementById('vanillaDialogError').style.display = 'none';
document.getElementById('vanillaInviteDialog').classList.add('open');
}
function closeVanillaInviteDialog() {
document.getElementById('vanillaInviteDialog').classList.remove('open');
activeVanillaEinladungId = null;
}
async function _vanillaAntworten(mode) {
if (!activeVanillaEinladungId) return;
const accepted = mode !== null;
const errEl = document.getElementById('vanillaDialogError');
errEl.style.display = 'none';
try {
const res = await fetch(`/vanilla/einladung/${activeVanillaEinladungId}/antwort`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted, mode }),
});
if (!res.ok) throw new Error();
const key = activeVanillaEinladungId;
closeVanillaInviteDialog();
removeRecvItem(key);
if (mode === 'OWN_DEVICE') {
window.location.href = '/games/vanilla/neuvanilla.html';
} else if (mode === 'HOST_DEVICE') {
await showInfo('Einladung angenommen', 'Das Spiel findet am Gerät des Hosts statt. Du wirst zur Startseite weitergeleitet.');
window.location.href = '/userhome.html';
}
} catch (_) {
errEl.textContent = 'Fehler beim Speichern der Antwort.';
errEl.style.display = '';
}
}
function acceptVanillaOwnDevice() { _vanillaAntworten('OWN_DEVICE'); }
function acceptVanillaHostDevice() { _vanillaAntworten('HOST_DEVICE'); }
async function declineVanillaFromDialog() {
if (!await showConfirm('Einladung ablehnen', 'Möchtest du diese Vanilla-Game-Einladung wirklich ablehnen?')) return;
_vanillaAntworten(null);
}
// ── Esc schließt Dialog ──
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (document.getElementById('vanillaInviteDialog').classList.contains('open')) closeVanillaInviteDialog();
if (document.getElementById('bdsmInviteDialog').classList.contains('open')) closeBdsmInviteDialog();
if (document.getElementById('lockeeInviteDialog').classList.contains('open')) closeLockeeInviteDialog();
}
});
// ── Alles laden ──
loadReceivedInvitations();
loadSentInvitations();
</script>
</body>
</html>

View File

@@ -0,0 +1,642 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toys – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Section ── */
.section + .section { margin-top: 2.5rem; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
margin: 0;
}
.btn-add {
display: flex;
align-items: center;
gap: 0.4rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-add:hover { background: #c73652; }
/* ── Toy grid ── */
.toy-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 0.85rem;
}
/* ── Toy card ── */
.toy-card {
display: flex;
align-items: center;
gap: 0.85rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 0.8rem 0.9rem;
transition: border-color 0.15s;
position: relative;
}
.toy-card { cursor: pointer; }
.toy-card:hover { border-color: var(--color-primary); }
.toy-card.selected {
border-color: var(--color-primary);
background: rgba(233,69,96,0.06);
}
.toy-img {
width: 52px; height: 52px;
border-radius: 7px;
object-fit: cover;
flex-shrink: 0;
}
.toy-img-placeholder {
width: 52px; height: 52px;
border-radius: 7px;
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
color: var(--color-muted);
}
.toy-info { flex: 1; min-width: 0; }
.toy-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toy-desc {
font-size: 0.78rem;
color: var(--color-muted);
margin-top: 0.2rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Section action buttons ── */
.section-actions { display: flex; align-items: center; gap: 0.5rem; }
.btn-action {
background: var(--color-secondary);
color: var(--color-text);
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s, opacity 0.15s;
}
.btn-action:disabled { opacity: 0.35; cursor: default; }
.btn-action:not(:disabled):hover { background: var(--color-primary); color: #fff; }
.btn-action-danger:not(:disabled):hover { background: rgba(233,69,96,0.18); color: var(--color-primary); }
.action-error {
font-size: 0.82rem;
color: var(--color-primary);
min-height: 1.1em;
margin-bottom: 0.4rem;
}
/* ── Empty / Loading ── */
.empty, .loading { color: var(--color-muted); font-size: 0.9rem; padding: 0.75rem 0; }
/* ── Inline-Fehler im Grid ── */
.grid-error {
font-size: 0.85rem;
color: var(--color-primary);
padding: 0.5rem 0;
}
/* ── Modal ── */
.modal-backdrop {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-backdrop.open { display: flex; }
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 420px;
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
}
.modal h2 {
color: var(--color-primary);
font-size: 1.1rem;
margin-bottom: 1.25rem;
}
.modal label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-top: 1rem;
margin-bottom: 0.3rem;
}
.modal input[type="text"],
.modal textarea {
width: 100%;
padding: 0.6rem 0.85rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
resize: vertical;
}
.modal input[type="text"]:focus,
.modal textarea:focus { border-color: var(--color-primary); }
.modal input[type="file"] {
font-size: 0.85rem;
color: var(--color-muted);
margin-top: 0.25rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.modal-actions .btn-cancel {
background: var(--color-secondary);
color: var(--color-text);
border: none;
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
.modal-actions .btn-cancel:hover { background: #1a4a8a; }
.modal-actions .btn-save {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.modal-actions .btn-save:hover { background: #c73652; }
.modal-actions .btn-save:disabled { opacity: 0.5; cursor: default; }
.modal-error {
color: var(--color-primary);
font-size: 0.82rem;
margin-top: 0.75rem;
display: none;
}
@media (max-width: 768px) {
.toy-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body class="app">
<!-- Erstell-/Bearbeitungs-Modal -->
<div class="modal-backdrop" id="createModal">
<div class="modal">
<h2 id="modalTitle">Neues Toy</h2>
<label for="toyName">Name *</label>
<input type="text" id="toyName" placeholder="z.B. Vibrator" maxlength="100">
<label for="toyDesc">Beschreibung</label>
<textarea id="toyDesc" rows="3" placeholder="Kurze Beschreibung…" maxlength="500"></textarea>
<label>Bild (optional)</label>
<div id="currentImageWrap" style="display:none; align-items:center; gap:0.5rem; margin-bottom:0.4rem;">
<img id="currentImage" style="max-width:64px; max-height:64px; border-radius:6px;" src="" alt="">
<span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild – neues Bild wählen zum Ersetzen</span>
</div>
<input type="file" id="toyBild" accept="image/*">
<div class="modal-error" id="modalError"></div>
<div class="modal-actions">
<button class="btn-cancel" id="cancelBtn">Abbrechen</button>
<button class="btn-save" id="saveBtn">Speichern</button>
</div>
</div>
</div>
<div class="main">
<div class="content">
<!-- Meine Toys -->
<div class="section">
<div class="section-header">
<h2 class="section-title">Meine Toys</h2>
<div class="section-actions">
<button class="btn-action" id="editBtn" disabled>✎ Bearbeiten</button>
<button class="btn-action btn-action-danger" id="deleteBtn" disabled>✕ Löschen</button>
<button class="btn-add" id="openCreateBtn">+ Neu</button>
</div>
</div>
<div class="action-error" id="actionError"></div>
<div class="toy-grid" id="userGrid"></div>
<div id="userLoading" class="loading" style="display:none;"></div>
<div id="userSentinel"></div>
</div>
<!-- System-Toys -->
<div class="section">
<div class="section-header">
<h2 class="section-title">System-Toys</h2>
<div class="section-actions">
<button class="btn-action" id="copyBtn" disabled>⊕ In meine Toys kopieren</button>
</div>
</div>
<div class="action-error" id="systemActionError"></div>
<div class="toy-grid" id="systemGrid"></div>
<div id="systemLoading" class="loading" style="display:none;"></div>
<div id="systemSentinel"></div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const PAGE_SIZE = 12;
let userPage = 0, userTotalPages = 1, userLoading = false;
let systemPage = 0, systemTotalPages = 1, systemLoading = false;
// ── Infinite-scroll observers ──
const userObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadUserPage();
}, { rootMargin: '200px' });
const systemObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadSystemPage();
}, { rootMargin: '200px' });
// ── Auth + initial load ──
fetch('/login/me')
.then(r => { if (r.status === 401) { window.location.href = '/login.html'; return null; } return r.ok ? r.json() : null; })
.then(user => {
if (!user) return;
userObserver.observe(document.getElementById('userSentinel'));
systemObserver.observe(document.getElementById('systemSentinel'));
})
.catch(() => { window.location.href = '/login.html'; });
// ── Load user toys (append, füllt Viewport automatisch auf) ──
async function loadUserPage() {
if (userLoading || userPage >= userTotalPages) return;
userLoading = true;
const loadEl = document.getElementById('userLoading');
try {
do {
loadEl.textContent = 'Wird geladen…';
loadEl.style.display = 'block';
const r = await fetch(`/toy/list/user?page=${userPage}&size=${PAGE_SIZE}`);
const data = await r.json();
userTotalPages = data.totalPages || 1;
appendGrid('userGrid', data.content, 'selectToy');
userPage++;
loadEl.style.display = 'none';
} while (userPage < userTotalPages && sentinelVisible('userSentinel'));
} catch (_) {
loadEl.textContent = 'Fehler beim Laden.';
} finally {
userLoading = false;
}
}
function reloadUserToys() {
userPage = 0;
userTotalPages = 1;
resetSelection();
document.getElementById('userGrid').innerHTML = '';
loadUserPage();
}
// ── Load system toys (append, füllt Viewport automatisch auf) ──
async function loadSystemPage() {
if (systemLoading || systemPage >= systemTotalPages) return;
systemLoading = true;
const loadEl = document.getElementById('systemLoading');
try {
do {
loadEl.textContent = 'Wird geladen…';
loadEl.style.display = 'block';
const r = await fetch(`/toy/list/system?page=${systemPage}&size=${PAGE_SIZE}`);
const data = await r.json();
systemTotalPages = data.totalPages || 1;
appendGrid('systemGrid', data.content, 'selectSystemToy');
systemPage++;
loadEl.style.display = 'none';
} while (systemPage < systemTotalPages && sentinelVisible('systemSentinel'));
} catch (_) {
loadEl.textContent = 'Fehler beim Laden.';
} finally {
systemLoading = false;
}
}
function reloadSystemToys() {
systemPage = 0;
systemTotalPages = 1;
resetSystemSelection();
document.getElementById('systemGrid').innerHTML = '';
loadSystemPage();
}
// ── Prüft ob ein Sentinel noch im (erweiterten) Viewport liegt ──
function sentinelVisible(id) {
const el = document.getElementById(id);
return el ? el.getBoundingClientRect().top <= window.innerHeight + 200 : false;
}
// ── Append items to a grid ──
function appendGrid(gridId, toys, selectFn) {
const grid = document.getElementById(gridId);
if (!toys || toys.length === 0) {
if (!grid.querySelector('.toy-card')) {
grid.innerHTML = '<p class="empty">Keine Einträge vorhanden.</p>';
}
return;
}
const emptyEl = grid.querySelector('.empty');
if (emptyEl) emptyEl.remove();
grid.insertAdjacentHTML('beforeend', toys.map(toy => `
<div class="toy-card" data-id="${esc(toy.toyId)}"
${selectFn ? `onclick="${selectFn}('${esc(toy.toyId)}')"` : ''}>
${toy.bild
? `<img class="toy-img" src="data:image/png;base64,${toy.bild}" alt="${esc(toy.name)}">`
: `<div class="toy-img-placeholder">◈</div>`}
<div class="toy-info">
<div class="toy-name">${esc(toy.name)}</div>
${toy.beschreibung ? `<div class="toy-desc">${esc(toy.beschreibung)}</div>` : ''}
</div>
</div>
`).join(''));
}
// ── Selection ──
let selectedUserToyId = null;
function selectToy(toyId) {
const prev = document.querySelector('#userGrid .toy-card.selected');
if (prev) prev.classList.remove('selected');
if (selectedUserToyId === toyId) {
selectedUserToyId = null;
} else {
selectedUserToyId = toyId;
document.querySelector(`#userGrid .toy-card[data-id="${toyId}"]`).classList.add('selected');
}
const has = selectedUserToyId != null;
document.getElementById('editBtn').disabled = !has;
document.getElementById('deleteBtn').disabled = !has;
document.getElementById('actionError').textContent = '';
}
function resetSelection() {
selectedUserToyId = null;
document.getElementById('editBtn').disabled = true;
document.getElementById('deleteBtn').disabled = true;
document.getElementById('actionError').textContent = '';
}
// ── System-Toy selection ──
let selectedSystemToyId = null;
function selectSystemToy(toyId) {
const prev = document.querySelector('#systemGrid .toy-card.selected');
if (prev) prev.classList.remove('selected');
if (selectedSystemToyId === toyId) {
selectedSystemToyId = null;
} else {
selectedSystemToyId = toyId;
document.querySelector(`#systemGrid .toy-card[data-id="${toyId}"]`).classList.add('selected');
}
document.getElementById('copyBtn').disabled = selectedSystemToyId == null;
document.getElementById('systemActionError').textContent = '';
}
function resetSystemSelection() {
selectedSystemToyId = null;
document.getElementById('copyBtn').disabled = true;
document.getElementById('systemActionError').textContent = '';
}
// ── Copy system toy ──
document.getElementById('copyBtn').addEventListener('click', () => {
if (!selectedSystemToyId) return;
const btn = document.getElementById('copyBtn');
btn.disabled = true;
fetch(`/toy/copy/${selectedSystemToyId}`, { method: 'POST' })
.then(r => {
if (r.ok || r.status === 201) {
reloadUserToys();
document.getElementById('systemActionError').textContent = '';
} else if (r.status === 409 && r.headers.get('X-Error') === 'duplicate-name') {
document.getElementById('systemActionError').textContent =
'Du hast bereits ein Toy mit diesem Namen.';
btn.disabled = false;
} else {
document.getElementById('systemActionError').textContent =
'Fehler beim Kopieren (HTTP ' + r.status + ').';
btn.disabled = false;
}
})
.catch(() => {
document.getElementById('systemActionError').textContent = 'Verbindungsfehler.';
btn.disabled = false;
});
});
// ── Header action buttons ──
document.getElementById('editBtn').addEventListener('click', () => {
if (selectedUserToyId) openModal(selectedUserToyId);
});
document.getElementById('deleteBtn').addEventListener('click', () => {
if (!selectedUserToyId) return;
if (!confirm('Toy wirklich löschen?')) return;
const btn = document.getElementById('deleteBtn');
btn.disabled = true;
const toyId = selectedUserToyId;
fetch(`/toy/${toyId}`, { method: 'DELETE' })
.then(r => {
if (r.status === 409) {
showActionError('Wird in Aufgaben verwendet – nicht löschbar.');
btn.disabled = false;
} else if (r.status === 403) {
showActionError('Keine Berechtigung.');
btn.disabled = false;
} else if (r.ok || r.status === 202) {
reloadUserToys();
} else {
showActionError('Fehler beim Löschen.');
btn.disabled = false;
}
})
.catch(() => { showActionError('Verbindungsfehler.'); btn.disabled = false; });
});
function showActionError(msg) {
const el = document.getElementById('actionError');
el.textContent = msg;
setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 4000);
}
// ── Create / Edit modal ──
const modal = document.getElementById('createModal');
const saveBtn = document.getElementById('saveBtn');
let currentEditId = null;
function openModal(editId) {
currentEditId = editId || null;
document.getElementById('modalError').style.display = 'none';
document.getElementById('toyBild').value = '';
if (currentEditId) {
fetch(`/toy/${currentEditId}`)
.then(r => r.ok ? r.json() : null)
.then(toy => {
if (!toy) return;
document.getElementById('modalTitle').textContent = 'Toy bearbeiten';
document.getElementById('toyName').value = toy.name || '';
document.getElementById('toyDesc').value = toy.beschreibung || '';
const imgWrap = document.getElementById('currentImageWrap');
if (toy.bild) {
document.getElementById('currentImage').src = 'data:image/png;base64,' + toy.bild;
imgWrap.style.display = 'flex';
} else {
imgWrap.style.display = 'none';
}
modal.classList.add('open');
document.getElementById('toyName').focus();
})
.catch(() => alert('Fehler beim Laden des Toys.'));
} else {
document.getElementById('modalTitle').textContent = 'Neues Toy';
document.getElementById('toyName').value = '';
document.getElementById('toyDesc').value = '';
document.getElementById('currentImageWrap').style.display = 'none';
modal.classList.add('open');
document.getElementById('toyName').focus();
}
}
document.getElementById('openCreateBtn').addEventListener('click', () => openModal(null));
document.getElementById('cancelBtn').addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
function closeModal() { modal.classList.remove('open'); }
function editToy(toyId) { openModal(toyId); }
saveBtn.addEventListener('click', async () => {
const name = document.getElementById('toyName').value.trim();
if (!name) {
showModalError('Bitte einen Namen eingeben.');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Speichert…';
let bildBase64 = null;
const fileInput = document.getElementById('toyBild');
if (fileInput.files.length > 0) {
bildBase64 = await toBase64(fileInput.files[0]);
}
const payload = {
name,
beschreibung: document.getElementById('toyDesc').value.trim() || null,
bild: bildBase64
};
const isEdit = currentEditId != null;
fetch(isEdit ? `/toy/${currentEditId}` : '/toy', {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(r => {
if (r.ok || r.status === 201) {
closeModal();
reloadUserToys();
} else if (r.status === 409 && r.headers.get('X-Error') === 'duplicate-name') {
showModalError('Ein Toy mit diesem Namen existiert bereits.');
} else {
showModalError('Fehler beim Speichern (HTTP ' + r.status + ').');
}
})
.catch(() => showModalError('Verbindungsfehler.'))
.finally(() => { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; });
});
function showModalError(msg) {
const el = document.getElementById('modalError');
el.textContent = msg;
el.style.display = 'block';
}
function toBase64(file) {
const MAX = 128;
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > MAX || h > MAX) {
if (w >= h) { h = Math.max(1, Math.round(MAX * h / w)); w = MAX; }
else { w = Math.max(1, Math.round(MAX * w / h)); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/png').split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
// ── XSS-Schutz ──
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vanilla Game – Info – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="app">
<div class="main">
<div class="content">
<h1>Vanilla Game</h1>
<p>Informationen zum Vanilla Game folgen hier.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vanilla Game – Neue Session – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="app">
<div class="main">
<div class="content">
<h1>Vanilla Game – Neue Session</h1>
<p>Session-Setup für das Vanilla Game folgt hier.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/games/vanilla/neuvanilla.html">
<title>Vanilla Game – xXx Sphere</title>
</head>
<body>
<script>window.location.replace('/games/vanilla/neuvanilla.html');</script>
</body>
</html>

View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Impressum – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header {
margin-bottom: 2rem;
}
.hilfe-header h1 {
font-size: 1.6rem;
margin: 0 0 0.4rem 0;
}
.hilfe-header p {
color: var(--color-muted);
font-size: 0.92rem;
margin: 0;
line-height: 1.6;
}
.impressum-block {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 0.75rem;
}
.impressum-block h2 {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
margin: 0 0 0.75rem 0;
}
.impressum-block p,
.impressum-block address {
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.8;
margin: 0;
font-style: normal;
}
.impressum-block a {
color: var(--color-muted);
text-decoration: none;
transition: color 0.15s;
}
.impressum-block a:hover {
color: var(--color-text);
}
.impressum-block + .impressum-block {
margin-top: 0;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="hilfe-header">
<h1>📄 Impressum</h1>
<p>Angaben gemäß § 5 TMG</p>
</div>
<div class="impressum-block">
<h2>Verantwortlich</h2>
<address>
Vorname Nachname<br>
Musterstraße 1<br>
12345 Musterstadt<br>
Deutschland
</address>
</div>
<div class="impressum-block">
<h2>Kontakt</h2>
<p>
E-Mail: <a href="mailto:kontakt@xxx-sphere.de">kontakt@xxx-sphere.de</a>
</p>
</div>
<div class="impressum-block">
<h2>Hinweis</h2>
<p>
xXx Sphere ist ein privat betriebenes Projekt ohne kommerzielle Absicht.
Die Plattform richtet sich ausschließlich an volljährige Personen.
</p>
</div>
<div class="impressum-block">
<h2>Haftungsausschluss</h2>
<p>
Trotz sorgfältiger inhaltlicher Kontrolle übernehmen wir keine Haftung für die Inhalte externer Links.
Für den Inhalt verlinkter Seiten sind ausschließlich deren Betreiber verantwortlich.
</p>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,265 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kontakt & Feedback – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header {
margin-bottom: 2rem;
}
.hilfe-header h1 {
font-size: 1.6rem;
margin: 0 0 0.4rem 0;
}
.hilfe-header p {
color: var(--color-muted);
font-size: 0.92rem;
margin: 0;
line-height: 1.6;
}
/* ── E-Mail-Hinweis ── */
.mail-hint {
background: rgba(var(--color-primary-rgb, 120,80,200), 0.08);
border-left: 3px solid var(--color-primary);
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-bottom: 1.5rem;
}
.mail-hint strong { color: var(--color-text); }
.mail-hint a { color: var(--color-text); }
/* ── Formular-Card ── */
.feedback-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1.1rem;
}
.form-group:last-of-type {
margin-bottom: 1.5rem;
}
.form-group label {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-group input,
.form-group select,
.form-group textarea {
background: var(--color-secondary);
border: 1px solid transparent;
border-radius: 8px;
padding: 0.65rem 0.9rem;
color: var(--color-text);
font-size: 0.92rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: var(--color-primary);
}
.form-group input[readonly],
.form-group input:disabled {
opacity: 0.55;
cursor: default;
}
.form-group textarea {
resize: vertical;
min-height: 130px;
}
.form-group select option {
background: var(--color-card);
}
.char-counter {
font-size: 0.78rem;
color: var(--color-muted);
text-align: right;
}
.char-counter.warn { color: #e74c3c; }
.btn-send {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.5rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-send:hover { opacity: 0.85; }
.btn-send:disabled { opacity: 0.45; cursor: default; }
.feedback-success {
display: none;
background: rgba(39,174,96,0.1);
border-left: 3px solid #27ae60;
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.9rem;
color: var(--color-muted);
margin-top: 1rem;
}
.feedback-success strong { color: #27ae60; }
.feedback-error {
display: none;
background: rgba(231,76,60,0.08);
border-left: 3px solid #e74c3c;
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.9rem;
color: var(--color-muted);
margin-top: 1rem;
}
.feedback-error strong { color: #e74c3c; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="hilfe-header">
<h1>✉️ Kontakt &amp; Feedback</h1>
<p>Hast du Fragen, Ideen oder einen Fehler gefunden? Schreib uns!</p>
</div>
<div class="mail-hint">
<strong>Alternativ per E-Mail:</strong> Du kannst uns auch direkt schreiben –
<a href="mailto:kontakt@xxx-sphere.de">kontakt@xxx-sphere.de</a>
</div>
<div class="feedback-card">
<div class="form-group">
<label for="fb-name">Name</label>
<input type="text" id="fb-name" readonly placeholder="Wird geladen…">
</div>
<div class="form-group">
<label for="fb-seite">Seite</label>
<input type="text" id="fb-seite" readonly>
</div>
<div class="form-group">
<label for="fb-grund">Kontaktgrund</label>
<select id="fb-grund">
<option value="Fehlermeldung">🐛 Fehlermeldung</option>
<option value="Feedback">💬 Feedback</option>
<option value="Idee & Verbesserungsvorschlag">💡 Idee &amp; Verbesserungsvorschlag</option>
<option value="Nachfrage">❓ Nachfrage</option>
</select>
</div>
<div class="form-group">
<label for="fb-text">Nachricht</label>
<textarea id="fb-text" maxlength="1000" placeholder="Beschreibe dein Anliegen…"></textarea>
<span class="char-counter" id="fb-counter">0 / 1000</span>
</div>
<button class="btn-send" id="fb-send" onclick="sendFeedback()">✉️ Absenden</button>
<div class="feedback-success" id="fb-success">
<strong>Vielen Dank!</strong> Deine Nachricht wurde erfolgreich übermittelt.
</div>
<div class="feedback-error" id="fb-error">
<strong>Fehler:</strong> Die Nachricht konnte nicht gesendet werden. Bitte versuche es später erneut.
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
// Name vorausfüllen
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
const field = document.getElementById('fb-name');
if (user && user.name) {
field.value = user.name;
} else {
field.value = 'Gast';
}
})
.catch(() => { document.getElementById('fb-name').value = 'Gast'; });
// Seite vorausfüllen
(function () {
const field = document.getElementById('fb-seite');
try {
const ref = document.referrer;
if (ref) {
const url = new URL(ref);
field.value = url.pathname;
} else {
field.value = 'Direkt aufgerufen';
}
} catch (_) {
field.value = 'Unbekannt';
}
})();
// Zeichenzähler
document.getElementById('fb-text').addEventListener('input', function () {
const len = this.value.length;
const counter = document.getElementById('fb-counter');
counter.textContent = len + ' / 1000';
counter.className = 'char-counter' + (len > 900 || len < 10 ? ' warn' : '');
});
async function sendFeedback() {
const btn = document.getElementById('fb-send');
const text = document.getElementById('fb-text').value.trim();
if (text.length < 10) { document.getElementById('fb-text').focus(); return; }
btn.disabled = true;
document.getElementById('fb-success').style.display = 'none';
document.getElementById('fb-error').style.display = 'none';
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('fb-name').value,
seite: document.getElementById('fb-seite').value,
grund: document.getElementById('fb-grund').value,
text: text
})
});
if (res.ok) {
document.getElementById('fb-success').style.display = 'block';
document.getElementById('fb-text').value = '';
document.getElementById('fb-counter').textContent = '0 / 1000';
} else {
document.getElementById('fb-error').style.display = 'block';
btn.disabled = false;
}
} catch (_) {
document.getElementById('fb-error').style.display = 'block';
btn.disabled = false;
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hilfe-Übersicht – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header {
margin-bottom: 2rem;
}
.hilfe-header h1 {
font-size: 1.6rem;
margin: 0 0 0.4rem 0;
}
.hilfe-header p {
color: var(--color-muted);
font-size: 0.92rem;
margin: 0;
line-height: 1.6;
}
/* ── Kategorien-Grid ── */
.hilfe-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.hilfe-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.hilfe-card-title {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1rem;
font-weight: 700;
border-bottom: 1px solid var(--color-secondary);
padding-bottom: 0.6rem;
}
.hilfe-card-desc {
font-size: 0.85rem;
color: var(--color-muted);
line-height: 1.6;
}
.hilfe-card-links {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.25rem;
}
.hilfe-card-links a {
font-size: 0.85rem;
color: var(--color-muted);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.4rem;
transition: color 0.15s;
}
.hilfe-card-links a:hover {
color: var(--color-text);
}
.hilfe-card-links a::before {
content: '›';
font-size: 1rem;
color: var(--color-primary);
flex-shrink: 0;
}
/* ── Abschnitt-Überschrift ── */
.hilfe-section-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
margin: 1.75rem 0 0.75rem;
}
.hilfe-section-label:first-child {
margin-top: 0;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="hilfe-header">
<h1>❓ Hilfe-Übersicht</h1>
<p>Hier findest du Anleitungen und Erklärungen zu allen Bereichen von xXx Sphere.</p>
</div>
<!-- ── Einstellungen & Konto ── -->
<div class="hilfe-section-label">Einstellungen &amp; Konto</div>
<div class="hilfe-grid">
<div class="hilfe-card">
<div class="hilfe-card-title">⚙️ Allgemeine Einstellungen</div>
<div class="hilfe-card-desc">Profil, Benachrichtigungen, Datenschutz und weitere Kontoeinstellungen.</div>
<div class="hilfe-card-links">
<a href="#">Profil bearbeiten</a>
<a href="#">Benachrichtigungen konfigurieren</a>
<a href="#">Passwort ändern</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">🔒 TTLock-Integration</div>
<div class="hilfe-card-desc">Verbinde deine physische Schlüsselbox mit xXx Sphere für automatische Code-Verwaltung.</div>
<div class="hilfe-card-links">
<a href="/help/ttlock.html#sec-intro">Was ist TTLock?</a>
<a href="/help/ttlock.html#sec-table">Voraussetzungen</a>
<a href="/help/ttlock.html#sec-howto">TTLock einrichten</a>
<a href="/help/ttlock.html#sec-faq1">Warum nur für Abonnenten?</a>
<a href="/help/ttlock.html#sec-faq2">Notfall-Öffnung</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">💳 Abonnements</div>
<div class="hilfe-card-desc">Informationen zu Premium-Funktionen und wie du dein Abonnement verwaltest.</div>
<div class="hilfe-card-links">
<a href="#">Premium-Funktionen im Überblick</a>
<a href="#">Abonnement kündigen</a>
</div>
</div>
</div>
<!-- ── Spiele ── -->
<div class="hilfe-section-label">Spiele</div>
<div class="hilfe-grid">
<div class="hilfe-card">
<div class="hilfe-card-title">🔒 Chastity Game</div>
<div class="hilfe-card-desc">Alles rund um Schlösser, Keyholder, Karten und Aufgaben im Chastity Game.</div>
<div class="hilfe-card-links">
<a href="#">Neues Lock starten</a>
<a href="#">Die Rolle als Keyholder</a>
<a href="#">Karten und Aufgaben</a>
<a href="#">TimeLock erklärt</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">⛓️ BDSM Game</div>
<div class="hilfe-card-desc">Sessions erstellen, Spieler einladen und Aufgaben verwalten.</div>
<div class="hilfe-card-links">
<a href="#">Session starten</a>
<a href="#">Spieler einladen</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">⚪ Vanilla Game</div>
<div class="hilfe-card-desc">Leichtere Spiele ohne strenge Regeln – für den entspannten Einstieg.</div>
<div class="hilfe-card-links">
<a href="#">Vanilla-Session starten</a>
</div>
</div>
</div>
<!-- ── Community ── -->
<div class="hilfe-section-label">Community</div>
<div class="hilfe-grid">
<div class="hilfe-card">
<div class="hilfe-card-title">👥 Gruppen</div>
<div class="hilfe-card-desc">Gruppen erstellen, beitreten und verwalten.</div>
<div class="hilfe-card-links">
<a href="#">Gruppe erstellen</a>
<a href="#">Mitglieder verwalten</a>
<a href="#">Beiträge und Abstimmungen</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">📰 Feed &amp; Profil</div>
<div class="hilfe-card-desc">Beiträge teilen, Profile entdecken und die Community kennenlernen.</div>
<div class="hilfe-card-links">
<a href="#">Feed nutzen</a>
<a href="#">Profil gestalten</a>
<a href="#">Personen suchen und folgen</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">🏆 Community Votes</div>
<div class="hilfe-card-desc">Verifikationen bewerten und an Community-Abstimmungen teilnehmen.</div>
<div class="hilfe-card-links">
<a href="#">Wie funktionieren Votes?</a>
</div>
</div>
</div>
<!-- ── Sonstiges ── -->
<div class="hilfe-section-label">Sonstiges</div>
<div class="hilfe-grid">
<div class="hilfe-card">
<div class="hilfe-card-title">🔐 Sicherheit &amp; Datenschutz</div>
<div class="hilfe-card-desc">Wie deine Daten gespeichert werden und welche Sicherheitsmaßnahmen wir treffen.</div>
<div class="hilfe-card-links">
<a href="#">Datenspeicherung</a>
<a href="#">Passwort-Hashing</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">🐛 Fehler melden</div>
<div class="hilfe-card-desc">Hast du einen Fehler gefunden oder einen Verbesserungsvorschlag?</div>
<div class="hilfe-card-links">
<a href="#">Feedback senden</a>
</div>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,345 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SEITENTITEL – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Hilfe-Seite Basis ── */
.hilfe-header {
margin-bottom: 2rem;
}
.hilfe-header h1 {
font-size: 1.6rem;
margin: 0 0 0.4rem 0;
}
.hilfe-header p {
color: var(--color-muted);
font-size: 0.92rem;
margin: 0;
line-height: 1.6;
}
/* ── Abschnitte (Accordion) ── */
.hilfe-section {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.hilfe-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.hilfe-section-header:hover {
background: rgba(255,255,255,0.03);
}
.hilfe-section-title {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1rem;
font-weight: 600;
}
.hilfe-section-arrow {
font-size: 0.75rem;
color: var(--color-muted);
transition: transform 0.2s;
}
.hilfe-section.open .hilfe-section-arrow {
transform: rotate(90deg);
}
.hilfe-section-body {
display: none;
padding: 0 1.25rem 1.25rem;
border-top: 1px solid var(--color-secondary);
}
.hilfe-section.open .hilfe-section-body {
display: block;
}
/* ── Fließtext in Abschnitten ── */
.hilfe-section-body p {
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.7;
margin: 0.9rem 0 0;
}
.hilfe-section-body p:first-child {
margin-top: 1rem;
}
/* ── Schritt-für-Schritt Liste ── */
.hilfe-steps {
list-style: none;
padding: 0;
margin: 1rem 0 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hilfe-steps li {
display: flex;
align-items: flex-start;
gap: 0.85rem;
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.6;
}
.hilfe-steps li .step-num {
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.1rem;
}
/* ── Hinweis-Box ── */
.hilfe-hint {
background: rgba(var(--color-primary-rgb, 120,80,200), 0.08);
border-left: 3px solid var(--color-primary);
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
.hilfe-hint strong {
color: var(--color-text);
}
/* ── Warn-Box ── */
.hilfe-warn {
background: rgba(231,76,60,0.08);
border-left: 3px solid #e74c3c;
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
.hilfe-warn strong {
color: #e74c3c;
}
/* ── Info-Box (neutral) ── */
.hilfe-info {
background: var(--color-secondary);
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
/* ── Einfache Tabelle ── */
.hilfe-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
margin-top: 1rem;
}
.hilfe-table th {
text-align: left;
color: var(--color-text);
font-weight: 600;
padding: 0.4rem 0.75rem 0.4rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.hilfe-table td {
color: var(--color-muted);
padding: 0.5rem 0.75rem 0.5rem 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
vertical-align: top;
line-height: 1.5;
}
.hilfe-table tr:last-child td {
border-bottom: none;
}
/* ── Trennlinie ── */
.hilfe-divider {
border: none;
border-top: 1px solid var(--color-secondary);
margin: 1.25rem 0 0;
}
/* ── Inline-Badge ── */
.hilfe-badge {
display: inline-block;
background: var(--color-secondary);
border-radius: 4px;
padding: 0.1rem 0.45rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-muted);
vertical-align: middle;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<!-- ── Kopfzeile ─────────────────────────────────────── -->
<div class="hilfe-header">
<h1>🔒 SEITENTITEL</h1>
<p>Kurze Beschreibung, worum es auf dieser Hilfeseite geht.</p>
</div>
<!-- ── Abschnitt: Einfacher Fließtext ───────────────── -->
<div class="hilfe-section open" id="sec-intro">
<div class="hilfe-section-header" onclick="toggleSection('sec-intro')">
<span class="hilfe-section-title">📖 Was ist das?</span>
<span class="hilfe-section-arrow">▸</span>
</div>
<div class="hilfe-section-body">
<p>
Hier steht ein einleitender Text. Du kannst mehrere Absätze verwenden,
um das Thema zu erklären.
</p>
<p>
Zweiter Absatz mit weiteren Informationen. Links gehen so:
<a href="/games/chastity/neulock.html">neues Lock starten</a>.
</p>
<!-- Hinweis-Box (lila) -->
<div class="hilfe-hint">
<strong>Hinweis:</strong> Hier steht ein wichtiger, aber freundlicher Hinweis.
</div>
</div>
</div>
<!-- ── Abschnitt: Schritt-für-Schritt ───────────────── -->
<div class="hilfe-section" id="sec-howto">
<div class="hilfe-section-header" onclick="toggleSection('sec-howto')">
<span class="hilfe-section-title">🚀 So funktioniert es</span>
<span class="hilfe-section-arrow">▸</span>
</div>
<div class="hilfe-section-body">
<p>Führe diese Schritte der Reihe nach aus:</p>
<ol class="hilfe-steps">
<li>
<span class="step-num">1</span>
<span>Erster Schritt – was der Nutzer hier tun muss.</span>
</li>
<li>
<span class="step-num">2</span>
<span>Zweiter Schritt – weitere Aktion mit Erklärung.</span>
</li>
<li>
<span class="step-num">3</span>
<span>Dritter Schritt – Abschluss oder Ergebnis.</span>
</li>
</ol>
<!-- Warn-Box (rot) -->
<div class="hilfe-warn">
<strong>Achtung:</strong> Hier steht eine Warnung, z. B. dass eine Aktion nicht rückgängig gemacht werden kann.
</div>
</div>
</div>
<!-- ── Abschnitt: Tabelle ────────────────────────────── -->
<div class="hilfe-section" id="sec-table">
<div class="hilfe-section-header" onclick="toggleSection('sec-table')">
<span class="hilfe-section-title">📋 Übersicht</span>
<span class="hilfe-section-arrow">▸</span>
</div>
<div class="hilfe-section-body">
<table class="hilfe-table">
<thead>
<tr>
<th>Funktion</th>
<th>Beschreibung</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="hilfe-badge">Beispiel A</span></td>
<td>Erklärung zu Funktion A.</td>
</tr>
<tr>
<td><span class="hilfe-badge">Beispiel B</span></td>
<td>Erklärung zu Funktion B.</td>
</tr>
<tr>
<td><span class="hilfe-badge">Beispiel C</span></td>
<td>Erklärung zu Funktion C.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ── Abschnitt: FAQ ────────────────────────────────── -->
<div class="hilfe-section" id="sec-faq1">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq1')">
<span class="hilfe-section-title">❓ Häufige Frage 1?</span>
<span class="hilfe-section-arrow">▸</span>
</div>
<div class="hilfe-section-body">
<p>
Antwort auf die erste häufige Frage. Kann auch mehrere Absätze haben.
</p>
<div class="hilfe-info">
Neutrale Info-Box für ergänzende Details ohne Wertung.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-faq2">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq2')">
<span class="hilfe-section-title">❓ Häufige Frage 2?</span>
<span class="hilfe-section-arrow">▸</span>
</div>
<div class="hilfe-section-body">
<p>
Antwort auf die zweite häufige Frage.
</p>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

View File

@@ -0,0 +1,360 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hilfe TTLock – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Hilfe-Seite Basis ── */
.hilfe-header {
margin-bottom: 2rem;
}
.hilfe-header h1 {
font-size: 1.6rem;
margin: 0 0 0.4rem 0;
}
.hilfe-header p {
color: var(--color-muted);
font-size: 0.92rem;
margin: 0;
line-height: 1.6;
}
/* ── Abschnitte (Accordion) ── */
.hilfe-section {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.hilfe-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.hilfe-section-header:hover {
background: rgba(255,255,255,0.03);
}
.hilfe-section-title {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1rem;
font-weight: 600;
}
.hilfe-section-arrow {
font-size: 0.75rem;
color: var(--color-muted);
transition: transform 0.2s;
}
.hilfe-section.open .hilfe-section-arrow {
transform: rotate(90deg);
}
.hilfe-section-body {
display: none;
padding: 0 1.25rem 1.25rem;
border-top: 1px solid var(--color-secondary);
}
.hilfe-section.open .hilfe-section-body {
display: block;
}
/* ── Fließtext in Abschnitten ── */
.hilfe-section-body p {
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.7;
margin: 0.9rem 0 0;
}
.hilfe-section-body p:first-child {
margin-top: 1rem;
}
/* ── Schritt-für-Schritt Liste ── */
.hilfe-steps {
list-style: none;
padding: 0;
margin: 1rem 0 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hilfe-steps li {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.85rem;
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.6;
}
.hilfe-steps li::before {
display: none;
}
.hilfe-steps li .step-num {
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.1rem;
}
.hilfe-steps li strong,
.hilfe-steps li em {
color: inherit;
}
/* ── Hinweis-Box ── */
.hilfe-hint {
background: rgba(var(--color-primary-rgb, 120,80,200), 0.08);
border-left: 3px solid var(--color-primary);
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
.hilfe-hint strong {
color: var(--color-text);
}
/* ── Warn-Box ── */
.hilfe-warn {
background: rgba(231,76,60,0.08);
border-left: 3px solid #e74c3c;
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
.hilfe-warn strong {
color: #e74c3c;
}
/* ── Info-Box (neutral) ── */
.hilfe-info {
background: var(--color-secondary);
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
/* ── Einfache Tabelle ── */
.hilfe-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
margin-top: 1rem;
}
.hilfe-table th {
text-align: left;
color: var(--color-text);
font-weight: 600;
padding: 0.4rem 0.75rem 0.4rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.hilfe-table td {
color: var(--color-muted);
padding: 0.5rem 0.75rem 0.5rem 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
vertical-align: top;
line-height: 1.5;
}
.hilfe-table tr:last-child td {
border-bottom: none;
}
/* ── Trennlinie ── */
.hilfe-divider {
border: none;
border-top: 1px solid var(--color-secondary);
margin: 1.25rem 0 0;
}
/* ── Inline-Badge ── */
.hilfe-badge {
display: inline-block;
background: var(--color-secondary);
border-radius: 4px;
padding: 0.1rem 0.45rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-muted);
vertical-align: middle;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<!-- ── Kopfzeile ─────────────────────────────────────── -->
<div class="hilfe-header">
<h1>🔒 TTLock</h1>
<p>Hilfe zur Einrichtung der Kommunikation mit einer TTLock-Schlüsselbox</p>
</div>
<!-- ── Abschnitt: Einfacher Fließtext ───────────────── -->
<div class="hilfe-section open" id="sec-intro">
<div class="hilfe-section-header" onclick="toggleSection('sec-intro')">
<span class="hilfe-section-title">📖 Was ist das?</span>
<span class="hilfe-section-arrow">▸</span>
</div>
<div class="hilfe-section-body">
<p>
<a href="https://ttlock.com/#/">TTLock</a> ist ein weit verbreitetes System für die Verwaltung von smarten Schlössern und Schlüsselboxen. Die Hardware kommuniziert in der Regel via Bluetooth, lässt sich aber über ein G2-Gateway auch aus der Ferne steuern.
</p>
<p>
Für Entwickler und Unternehmen bietet die TTLock Open Platform eine leistungsstarke REST-API. Damit lässt sich die Schlossverwaltung in eigene Anwendungen integrieren.
Diese API verwenden wir, um die Codes deiner Schlüsselbox zu steuern - kein nerviges manuelles Eintragen des generierten Schlüssels mehr.
</p>
<p>
TTLock steht allen Premium-Abonenten zur Verfügung.
</p>
</div>
</div>
<!-- ── Abschnitt: Tabelle ────────────────────────────── -->
<div class="hilfe-section" id="sec-table">
<div class="hilfe-section-header" onclick="toggleSection('sec-table')">
<span class="hilfe-section-title">📋 Voraussetzungen</span>
<span class="hilfe-section-arrow">▸</span>
</div>
<div class="hilfe-section-body">
<div class="hilfe-warn">
<strong>Achtung:</strong> Für die Verwendung ist zwingend ein G2-Gateway für die Kommunikation von TTLock-Server zu Deiner Schlüsselbox notwendig.
</div>
<div class="hilfe-hint">
<strong>Hinweis:</strong> Für die Verwendung einer TTLock-Schlüsselbox in Spielen ist zwingend ein Premium-Abonement notwendig.
</div>
</div>
</div>
<!-- ── Abschnitt: Schritt-für-Schritt ───────────────── -->
<div class="hilfe-section" id="sec-howto">
<div class="hilfe-section-header" onclick="toggleSection('sec-howto')">
<span class="hilfe-section-title">🚀 So funktioniert es</span>
<span class="hilfe-section-arrow">▸</span>
</div>
<div class="hilfe-section-body">
<p>Führe diese Schritte der Reihe nach aus:</p>
<ol class="hilfe-steps">
<li>
<span class="step-num">1</span>
<span>App-Setup: Verbinde deine Schlüsselbox in der TTLock-App. Wichtig: Für die Fernsteuerung muss ein Gateway (G2) eingerichtet und aktiv sein.</span>
</li>
<li>
<span class="step-num">2</span>
<span>Fernzugriff aktivieren: Aktiviere die Funktion in den App-Einstellungen. Tipp: Schalte das WLAN an deinem Handy aus und versuche, die Box über mobile Daten zu öffnen. Funktioniert das? Dann ist alles bereit.</span>
</li>
<li>
<span class="step-num">3</span>
<span>Accounts verknüpfen: Trage deine TTLock-Zugangsdaten unter Einstellungen &gt; TTLock ein.</span>
</li>
<li>
<span class="step-num">4</span>
<span>Lock-ID hinterlegen: Gib die ID deiner Box an (zu finden unter MAC/ID). Wichtig: Nur den Teil hinter dem Schrägstrich nutzen (z.B. bei 00:11.../123456 nur 123456).</span>
</li>
<li>
<span class="step-num">5</span>
<span>Verbindung testen: Klicke auf „Verbindung testen“. Erst nach einem grünen Licht ist das System aktiv.</span>
</li>
</ol>
<!-- Hinweis-Box (lila) -->
<div class="hilfe-hint">
<strong>Hinweis:</strong> Wir speichern dein Passwort nicht im Klartext in der Datenbank sondern nur als MD5-Hash.
</div>
</div>
</div>
<!-- ── Abschnitt: FAQ ────────────────────────────────── -->
<div class="hilfe-section" id="sec-faq1">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq1')">
<span class="hilfe-section-title">❓ Warum steht dieser Dienst nur für Abonennten zur Verfügung?</span>
<span class="hilfe-section-arrow">▸</span>
</div>
<div class="hilfe-section-body">
<p>
Die Verwendung der API von TTLock ist nur begrenzt kostenlos verwendbar. Ab bestimmten Kontingenten wird die Verwendung für uns kostenpflichtig.
</p>
<div class="hilfe-info">
Es gilt weiter der Grundsatz, XXX-Sphere soll niemanden reich machen - Die Abonemments dienen dazu die laufenden Kosten (Server, API-Schnittstellen etc.) zu decken.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-faq2">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq2')">
<span class="hilfe-section-title">❓ Hilfe - ich komme nicht mehr raus...Was machen Sachen?</span>
<span class="hilfe-section-arrow">▸</span>
</div>
<div class="hilfe-section-body">
<p>
Sollte sich der Schlüssel noch in der Box befinden und ihr nicht in einem aktiven Lock sein, besteht die Möglichkeit einen neuen Code für eine Notfallöffnung zu generieren:
</p>
<ol class="hilfe-steps">
<li>
<span class="step-num">1</span>
<span>Öffne die Einstellungen</span>
</li>
<li>
<span class="step-num">2</span>
<span>Navigiere zum Bereich '🔒 TTLock'</span>
</li>
<li>
<span class="step-num">3</span>
<span>Drücke '🔒 Öffnen'</span>
</li>
</ol>
<p>
Der temporäre Code zum Öffnen wird euch angezeigt - damit lässt sich die Box dann öffnen.
</p>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="icon" href="/img/icon.png" type="image/png">
</head>
<body>
<img src="img/icon.png" alt="Icon">
<h1>Kinky Games und Communities</h1>
<div id="authButtons" style="display:flex; gap:1rem;">
<a class="btn" href="/login.html">Anmelden</a>
<a class="btn" href="/registration.html" style="background:#0f3460;">Registrieren</a>
</div>
<div id="fillerMsg" class="message" style="max-width:320px;"></div>
<script>
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
if (!user) return;
const btns = document.getElementById('authButtons');
btns.insertAdjacentHTML('beforebegin',
`<p style="text-align:center;">Willkommen zurück, <strong style="color:var(--color-text);">${user.name}</strong></p>`
);
btns.innerHTML = '<a class="btn" href="/userhome.html">Betreten</a>';
})
.catch(() => {});
</script>
</body>
</html>

View File

@@ -0,0 +1,86 @@
/**
* Zentrale Kartendefinitionen für das Chastity Game.
*
* Exportiert (global):
* CARD_DEFS – Array mit { id, img, name, desc, defMin, defMax }
* CARD_LABELS – Object { ID: { name, img, desc } } (Lookup für card-display.js u.a.)
*/
const CARD_DEFS = [
{
id: 'RED',
img: '/img/card_red.png',
name: 'Rote Karte',
desc: 'Niete - Viel Erfolg beim nächsten Zug',
defMin: 5,
defMax: 10,
},
{
id: 'GREEN',
img: '/img/card_green.png',
name: 'Grüne Karte',
desc: 'Öffnet das Lock. Kann wieder ins Deck zurück gelegt werden',
defMin: 1,
defMax: 2,
},
{
id: 'YELLOW',
img: '/img/card_yellow.png',
name: 'Gelbe Karte',
desc: 'Per Zufall werden rote Karten entfernt oder hinzugefügt',
defMin: 1,
defMax: 2,
},
{
id: 'TASK',
img: '/img/card_task.png',
name: 'Aufgabe',
desc: 'Keyholder*In, Community oder der Zufall teilt eine Aufgabe zu.',
defMin: 0,
defMax: 0,
},
{
id: 'FREEZE',
img: '/img/card_freeze.png',
name: 'Freeze',
desc: 'Friert das Lock für eine festgelegte Zeit ein – in diesem Zeitraum können keine Karten gezogen werden.',
defMin: 0,
defMax: 0,
},
{
id: 'RESET',
img: '/img/card_reset.png',
name: 'Reset',
desc: 'Setzt das Kartendeck auf den Ausgangszustand zurück. Alle bisher gezogenen Karten kommen wieder rein.',
defMin: 0,
defMax: 0,
},
{
id: 'DOUBLE_UP',
img: '/img/card_doubleup.png',
name: 'Double Up',
desc: 'Verdoppelt alle noch im Deck vorhandenen Karten.',
defMin: 0,
defMax: 0,
},
{
id: 'CUM',
img: '/img/card_cum.png',
name: 'Cum',
desc: 'Du wirst entsperrt, nutze diese Entsperrung um zu kommen. Je länger du brauchst, desto schlimmer.',
defMin: 0,
defMax: 0,
},
{
id: 'CUM_IN_CAGE',
img: '/img/card_cum_caged.png',
name: 'Cum in Cage',
desc: 'Komme in deinem Keuschheitsgürtel, wie du es anstellst ist deine Sache.',
defMin: 0,
defMax: 0,
},
];
/** Lookup-Objekt für Konsumenten, die nach ID auf Name/Bild/Beschreibung zugreifen. */
const CARD_LABELS = Object.fromEntries(
CARD_DEFS.map(c => [c.id, { name: c.name, img: c.img, desc: c.desc }])
);

View File

@@ -0,0 +1,60 @@
/**
* Gemeinsame Kartenanzeige für Chastity Game.
* Benötigt: /js/card-defs.js (CARD_LABELS muss bereits global verfügbar sein)
* Exportiert: cardTypeGridHtml(cardCounts)
*/
(function () {
const style = document.createElement('style');
style.textContent = `
.card-type-grid {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 0.4rem;
}
.card-type-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
width: calc((100% - 6 * 0.6rem) / 14);
min-width: 28px;
}
.card-type-item img {
width: 100%;
height: auto;
border-radius: 4px;
display: block;
}
.card-type-badge {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.2;
}
`;
document.head.appendChild(style);
})();
/**
* Gibt HTML für ein Karten-Typ-Raster zurück (ein Bild pro Typ, Anzahl-Badge).
* @param {Object} cardCounts – { RED: 3, GREEN: 1, … }
* @returns {string} HTML-String
*/
function cardTypeGridHtml(cardCounts) {
if (!cardCounts || Object.keys(cardCounts).length === 0) {
return '<span style="color:var(--color-muted);font-size:0.85rem;">Keine Karten mehr im Stapel.</span>';
}
const items = Object.entries(cardCounts)
.filter(([, n]) => n > 0)
.map(([type, n]) => {
const info = CARD_LABELS[type] || { img: '/img/card.png', name: type };
return `<div class="card-type-item">
<img src="${info.img}" alt="${info.name}">
<span class="card-type-badge">${n}</span>
</div>`;
}).join('');
return items
? `<div class="card-type-grid">${items}</div>`
: '<span style="color:var(--color-muted);font-size:0.85rem;">Keine Karten mehr im Stapel.</span>';
}

View File

@@ -0,0 +1,190 @@
/**
* Zentrale Icon-Verwaltung – XXX The Game
*
* Typen:
* emoji – Standard-Emoji oder Unicode-Zeichen (value: string)
* symbol – Unicode-Symbol (value: string)
* image – Pfad zu einer Bilddatei (value: string)
* compound – Doppel-Icon: base-Icon + kleines Overlay-Icon (bottom-right)
* Felder: base { type, value }, overlay { type, value }
* base kann emoji, symbol oder image sein.
*/
window.ICONS = {
// ── Navigation / Sidebar ──────────────────────────────────────────────
HOME: { type: 'emoji', value: '🏠' },
VANILLA: { type: 'emoji', value: '⚪' },
BDSM: { type: 'emoji', value: '⛓️' },
CHASTITY: { type: 'emoji', value: '🔒' },
// ── Aktionen ──────────────────────────────────────────────────────────
PLAY_NEW: { type: 'emoji', value: '🆕' },
PLAY_ACTIVE: { type: 'emoji', value: '▶️' },
ACTIVE_LOCK: { type: 'emoji', value: '▶️' },
WAITING: { type: 'emoji', value: '⏳' },
CHECK: { type: 'emoji', value: '✅' },
DISCOVER: { type: 'emoji', value: '🗺️' },
ARROW: { type: 'emoji', value: '▶️' },
REFRESH: { type: 'emoji', value: '🔄' }, // Erneuern / Neu laden
START: { type: 'emoji', value: '🚀' }, // Starten / Los
CELEBRATE: { type: 'emoji', value: '🎉' }, // Erfolg / Abschluss
// ── UI-Symbole ────────────────────────────────────────────────────────
CLOSE: { type: 'symbol', value: '✕' }, // Schließen / Ablehnen / Löschen
CONFIRM: { type: 'symbol', value: '✓' }, // Bestätigen / Abschließen / Annehmen
LIKE: { type: 'symbol', value: '♥' }, // Like-Button
AVATAR: { type: 'symbol', value: '◉' }, // Avatar-Platzhalter (kein Bild)
ERROR: { type: 'emoji', value: '❌' }, // Fehlerzustand
TIMER: { type: 'emoji', value: '⏱️' }, // Zeitanzeige / Stoppuhr
LIGHTNING: { type: 'emoji', value: '⚡' }, // Aktion (z. B. Zeit entfernen)
EMOJI_PICKER: { type: 'emoji', value: '😊' }, // Emoji-Picker öffnen
REMOVE: { type: 'symbol', value: '⊗' }, // Eintrag/Spiel entfernen
EDIT: { type: 'symbol', value: '✎' }, // Bearbeiten-Button
TRASH: { type: 'emoji', value: '🗑' }, // Löschen-Button
WARNING: { type: 'emoji', value: '⚠️' }, // Warnung / Hinweis
REPORT: { type: 'symbol', value: '⚑' }, // Melden-Button (Flag)
VISIBILITY: { type: 'emoji', value: '👁' }, // Sichtbar / Details sichtbar
THUMBS_UP: { type: 'emoji', value: '👍' }, // Upvote / Zustimmung
THUMBS_DOWN: { type: 'emoji', value: '👎' }, // Downvote / Ablehnung
ARROW_UP: { type: 'symbol', value: '⬆' }, // Sortierung aufsteigend
ARROW_DOWN: { type: 'symbol', value: '⬇' }, // Sortierung absteigend
NAV_PREV: { type: 'symbol', value: '←' }, // Zurück / Vorheriges Bild
NAV_NEXT: { type: 'symbol', value: '→' }, // Weiter / Nächstes Bild
CAROUSEL_PREV: { type: 'symbol', value: '‹' }, // Karussell zurück
CAROUSEL_NEXT: { type: 'symbol', value: '›' }, // Karussell weiter
TIP: { type: 'emoji', value: '💡' }, // Hinweis / Tipp
DOT_RED: { type: 'emoji', value: '🔴' }, // Status-Indikator rot
COMING_SOON: { type: 'emoji', value: '🚧' }, // In Entwicklung / Demnächst
// ── Chastity Game ─────────────────────────────────────────────────────
NEW_LOCK: { type: 'emoji', value: '🆕' },
LOCK: { type: 'emoji', value: '🔒' },
UNLOCK: { type: 'emoji', value: '🔓' }, // Entsperren
LOCKED_SECURE: { type: 'emoji', value: '🔐' }, // Sicher gesperrt (mit Schlüssel)
KEY: { type: 'emoji', value: '🔑' },
HISTORY: { type: 'emoji', value: '🔙' },
VOTES: { type: 'emoji', value: '🗳️' },
TRUST: { type: 'emoji', value: '🤝' }, // Trust-Lock
EMERGENCY: { type: 'emoji', value: '🆘' }, // Notfall-Entsperrung
HYGIENE: { type: 'emoji', value: '🚿' }, // Hygiene-Öffnung
FROZEN: { type: 'emoji', value: '❄️' }, // Eingefroren (zeitlich)
FROZEN_HARD: { type: 'emoji', value: '🧊' }, // Eingefroren (unlimitiert)
UNFREEZE: { type: 'emoji', value: '🌊' }, // Aufgetaut / Unfreeze
CODE_DIGITS: { type: 'emoji', value: '🔢' }, // Zahlenkombination / PIN-Länge
// ── CardLock ──────────────────────────────────────────────────────────
CARD: { type: 'emoji', value: '🃏' }, // Karte (standalone)
DICE: { type: 'emoji', value: '🎲' }, // Zufällig / Würfeln
// ── TimeLock / Spinning Wheel ──────────────────────────────────────────
SPINNING_WHEEL: { type: 'emoji', value: '🎡' }, // Glücksrad drehen
TASK_ACTIVE: { type: 'emoji', value: '🎯' }, // Aktuelle Aufgabe
CLOCK: { type: 'emoji', value: '🕐' }, // Uhr / Zeitpunkt
// ── Social ────────────────────────────────────────────────────────────
FEED: { type: 'emoji', value: '📰' },
SEARCH: { type: 'emoji', value: '🔍' },
FRIENDS: { type: 'emoji', value: '❤️' },
MESSAGES: { type: 'emoji', value: '💬' },
NOTIFICATIONS: { type: 'emoji', value: '🔔' },
GROUPS: { type: 'emoji', value: '👥' },
INVITATIONS: { type: 'emoji', value: '✨' },
SETTINGS: { type: 'emoji', value: '⚙️' },
LOGOUT: { type: 'emoji', value: '⏏️' },
PROFILE: { type: 'emoji', value: '👤' },
HELP: { type: 'emoji', value: '❓' },
CONTACT: { type: 'emoji', value: '✉️' }, // Kontakt / E-Mail
// ── Medien / Dateien ──────────────────────────────────────────────────
PHOTO: { type: 'emoji', value: '📷' }, // Foto / Kamera
FILE_UPLOAD: { type: 'emoji', value: '📁' }, // Datei auswählen / Upload
TEMPLATE: { type: 'emoji', value: '📋' }, // Vorlage / Template
DOCUMENT: { type: 'emoji', value: '📄' }, // Dokument / Impressum
GUIDE: { type: 'emoji', value: '📖' }, // Anleitung / Hilfeseite
STATS: { type: 'emoji', value: '📊' }, // Statistik / Umfrage-Ergebnis
PACKAGE: { type: 'emoji', value: '📦' }, // Paket / Einladung
MAILBOX: { type: 'emoji', value: '📬' }, // Posteingang (Admin)
// ── Abo / Premium ─────────────────────────────────────────────────────
PREMIUM: { type: 'emoji', value: '⭐' }, // Abonnement / Premium
TROPHY: { type: 'emoji', value: '🏆' }, // Auszeichnung / Erfolg
PAYMENT: { type: 'emoji', value: '💳' }, // Zahlung / Abonnement
// ── TTLock / Technik ──────────────────────────────────────────────────
MOBILE: { type: 'emoji', value: '📱' }, // TTLock-App / Mobilgerät
CONNECTION: { type: 'emoji', value: '🔌' }, // Verbindung / Integration
GAMEPAD: { type: 'emoji', value: '🕹️' }, // Spielsteuerung
SHIELD: { type: 'emoji', value: '🛡️' }, // Sicherheit / Datenschutz
ADMIN_TOOLS: { type: 'emoji', value: '🔧' }, // Admin / Werkzeuge
// ── Aufgaben / Items ──────────────────────────────────────────────────
TOYS: { type: 'emoji', value: '➰' },
// ── Spielhistorie – Spieltypen ────────────────────────────────────────
GAME_BDSM: { type: 'emoji', value: '⛓️' },
GAME_VANILLA: { type: 'emoji', value: '❤️' },
// Doppel-Icons: großes Basis-Icon + kleines 🔒-Overlay
GAME_CARDLOCK: {
type: 'compound',
base: { type: 'image', value: '/img/card.png' },
overlay: { type: 'emoji', value: '🔒' }
},
GAME_TIMELOCK: {
type: 'compound',
base: { type: 'emoji', value: '⏰' },
overlay: { type: 'emoji', value: '🔒' }
},
// ── Spielhistorie – Rollen-Badges ─────────────────────────────────────
ROLE_KEYHOLDER: { type: 'emoji', value: '🔑' },
ROLE_LOCKEE: { type: 'emoji', value: '🔒' },
};
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
/** Gibt den rohen Wert-String zurück (nur für einfache Icons; '' für compound). */
window.IC = function(key) {
const icon = window.ICONS[key];
return (icon && icon.type !== 'compound') ? (icon.value || '') : '';
};
/**
* Gibt ein fertiges HTML-Fragment zurück, das das Icon darstellt.
*
* @param {string} key – Schlüssel aus window.ICONS
* @param {number} [size] – Basisgröße in rem (Standard: 2.7)
* @returns {string} HTML-String
*/
window.IChtml = function(key, size) {
const icon = window.ICONS[key];
if (!icon) return '';
return _iconToHtml(icon, size != null ? size : 2.7);
};
function _iconToHtml(icon, size) {
switch (icon.type) {
case 'emoji':
case 'symbol':
return `<span style="font-size:${size}rem;line-height:1;">${icon.value}</span>`;
case 'image':
return `<img src="${icon.value}" style="width:${size}rem;height:${size}rem;object-fit:contain;display:block;" alt="">`;
case 'compound': {
const baseHtml = _compoundBase(icon.base, size);
const overlayHtml = _compoundOverlay(icon.overlay, size * 0.48);
return `<span style="position:relative;display:inline-block;line-height:1;">${baseHtml}${overlayHtml}</span>`;
}
default:
return '';
}
}
function _compoundBase(base, size) {
if (base.type === 'image') {
return `<img src="${base.value}" style="width:${size}rem;height:${size}rem;object-fit:contain;display:block;" alt="">`;
}
return `<span style="font-size:${size}rem;line-height:1;">${base.value}</span>`;
}
function _compoundOverlay(overlay, size) {
return `<span style="position:absolute;bottom:-2px;right:-4px;font-size:${size.toFixed(2)}rem;line-height:1;">${overlay.value}</span>`;
}

View File

@@ -0,0 +1,237 @@
// ─────────────────────────────────────────────────────────────────────────────
// image-viewer.js – Universelle Bild-Lightbox
//
// Einbinden: <script src="/js/shared.js"></script> (vorher)
// <script src="/js/image-viewer.js"></script>
//
// Zwei Modi:
// Modus A – Nur Bild (kein Like, keine Kommentare):
// imageViewer.open({ images: [{ src }] })
//
// Modus B – Galerie mit Like + Kommentare:
// imageViewer.open({
// images: [{ src, id, likedByMe, likeCount }],
// index: 0,
// showLike: true,
// showComments: true,
// myUserId: '...',
// onLike: async (img) => {} // optional; sonst POST /social/profile-images/{id}/like
// })
//
// Globale Instanz: window.imageViewer
// ─────────────────────────────────────────────────────────────────────────────
class ImageViewer {
constructor() {
this._cfg = null;
this._idx = 0;
this.isOpen = false;
this._injectStyles();
this._injectHTML();
this._bindEvents();
}
// ── Öffentliche API ───────────────────────────────────────────────────────
open(cfg) {
this._cfg = cfg;
this._idx = cfg.index || 0;
this.isOpen = true;
const multi = cfg.images.length > 1;
const showLike = !!cfg.showLike;
const showCom = !!cfg.showComments;
this._q('ivPrev').style.display = multi ? '' : 'none';
this._q('ivNext').style.display = multi ? '' : 'none';
this._q('ivCounter').style.display = multi ? '' : 'none';
this._q('ivLikeBtn').style.display = showLike ? '' : 'none';
this._q('ivComments').style.display = showCom ? '' : 'none';
this._render();
this._q('imageViewer').classList.add('open');
this._updateLayout();
}
close() {
this._q('imageViewer').classList.remove('open');
this.isOpen = false;
this._cfg = null;
}
/** Kommentare im offenen Viewer neu laden (z.B. nach externem Löschen) */
reloadComments() {
if (this.isOpen && this._cfg?.showComments) this._loadComments();
}
// ── Internes Rendering ────────────────────────────────────────────────────
_q(id) { return document.getElementById(id); }
_render() {
const img = this._cfg.images[this._idx];
this._q('ivImg').src = img.src;
const total = this._cfg.images.length;
this._q('ivCounter').textContent = `${this._idx + 1} / ${total}`;
this._q('ivPrev').disabled = this._idx === 0;
this._q('ivNext').disabled = this._idx === total - 1;
if (this._cfg.showLike) this._syncLike();
if (this._cfg.showComments) this._loadComments();
}
_syncLike() {
const img = this._cfg.images[this._idx];
const btn = this._q('ivLikeBtn');
btn.className = 'btn-like' + (img.likedByMe ? ' liked' : '');
this._q('ivLikeCount').textContent = img.likeCount;
}
async _loadComments() {
const img = this._cfg.images[this._idx];
const res = await fetch(`/social/kommentare?targetType=IMAGE&targetId=${img.id}`);
const comments = await res.json();
const myUserId = this._cfg.myUserId || null;
this._q('ivCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin-bottom:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, 'IMAGE', img.id, { myUserId, showReplies: true })).join('');
}
async _postComment() {
const input = this._q('ivCommentInput');
const text = input.value.trim();
if (!text) return;
const img = this._cfg.images[this._idx];
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'IMAGE', targetId: img.id, text })
});
input.value = '';
await this._loadComments();
}
async _toggleLike() {
const img = this._cfg.images[this._idx];
const onLike = this._cfg.onLike;
img.likedByMe = !img.likedByMe;
img.likeCount += img.likedByMe ? 1 : -1;
this._syncLike();
try {
if (onLike) await onLike(img);
else await fetch('/social/profile-images/' + img.id + '/like', { method: 'POST' });
} catch {
img.likedByMe = !img.likedByMe;
img.likeCount += img.likedByMe ? 1 : -1;
this._syncLike();
}
}
_prev() { if (this._idx > 0) { this._idx--; this._render(); } }
_next() { if (this._idx < this._cfg.images.length - 1) { this._idx++; this._render(); } }
_updateLayout() {
const el = this._q('ivLayout');
if (!el) return;
const bp = parseInt(getComputedStyle(document.documentElement)
.getPropertyValue('--breakpoint-mobile').trim()) || 768;
el.classList.toggle('iv-narrow', window.innerWidth <= bp);
}
// ── CSS + HTML Injection ──────────────────────────────────────────────────
_injectStyles() {
if (document.getElementById('iv-styles')) return;
const s = document.createElement('style');
s.id = 'iv-styles';
s.textContent = `
#imageViewer{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:500;align-items:center;justify-content:center;padding:2rem}
#imageViewer.open{display:flex}
#ivLayout{display:flex;flex-direction:row;gap:1rem;height:min(78vh,660px);max-width:calc(100vw - 4rem);align-items:stretch}
#ivImageSide{width:660px;flex-shrink:1;min-width:0;display:flex;flex-direction:column}
.iv-image-box{flex:1;position:relative;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;overflow:hidden;display:flex;align-items:center;justify-content:center}
#ivImg{width:100%;height:100%;object-fit:contain;display:block}
.iv-overlay{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,0.6));border-radius:0 0 12px 12px;padding:2rem 0.75rem 0.6rem;display:flex;align-items:center;justify-content:space-between;gap:0.5rem}
.iv-nav-btn{background:rgba(0,0,0,0.35);border:1px solid rgba(255,255,255,0.2);border-radius:6px;color:#fff;padding:0.3rem 0.75rem;cursor:pointer;margin:0;width:auto;font-size:1rem;flex-shrink:0;transition:background 0.15s}
.iv-nav-btn:hover{background:rgba(0,0,0,0.65)}
.iv-nav-btn:disabled{opacity:.25;cursor:default}
.iv-overlay-center{display:flex;align-items:center;gap:0.6rem;flex:1;justify-content:center}
#ivCounter{font-size:0.8rem;color:rgba(255,255,255,0.75)}
.iv-close{position:fixed;top:1rem;right:1rem;background:rgba(0,0,0,0.55);border:1px solid rgba(255,255,255,0.2);color:#fff;font-size:1.1rem;width:2.2rem;height:2.2rem;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;margin:0;z-index:502;transition:background 0.15s}
.iv-close:hover{background:rgba(180,30,30,0.8)}
#ivComments{background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;width:280px;flex-shrink:0;display:flex;flex-direction:column;overflow:hidden}
.iv-comments-header{font-size:0.78rem;font-weight:600;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.06em;padding:0.7rem 1rem;border-bottom:1px solid var(--color-secondary);flex-shrink:0}
#ivCommentsList{flex:1;overflow-y:auto;padding:0.65rem 0.75rem;scrollbar-width:thin;scrollbar-color:var(--color-secondary) transparent}
.iv-comment-compose{display:flex;gap:0.4rem;padding:0.65rem 0.75rem;border-top:1px solid var(--color-secondary);flex-shrink:0;align-items:center}
.iv-comment-compose input{flex:1;padding:0.4rem 0.65rem;font-size:0.85rem}
.iv-comment-compose button{width:auto;padding:0.4rem 0.7rem;font-size:0.82rem;white-space:nowrap}
#ivLayout.iv-narrow{flex-direction:column;height:auto;max-height:90vh;overflow-y:auto;width:calc(100vw - 1rem);max-width:calc(100vw - 1rem)}
#ivLayout.iv-narrow #ivImageSide{width:100%;flex-shrink:0}
#ivLayout.iv-narrow .iv-image-box{height:min(45vh,360px);flex:none}
#ivLayout.iv-narrow #ivComments{width:100%;max-height:40vh;flex-shrink:0}
`;
document.head.appendChild(s);
}
_injectHTML() {
if (document.getElementById('imageViewer')) return;
const div = document.createElement('div');
div.id = 'imageViewer';
div.innerHTML = `
<button class="iv-close" id="ivClose">✕</button>
<div id="ivLayout">
<div id="ivImageSide">
<div class="iv-image-box">
<img id="ivImg" src="" alt="">
<div class="iv-overlay">
<button class="iv-nav-btn" id="ivPrev">&#8592;</button>
<div class="iv-overlay-center">
<span id="ivCounter"></span>
<button class="btn-like" id="ivLikeBtn">♥ <span id="ivLikeCount">0</span></button>
</div>
<button class="iv-nav-btn" id="ivNext">&#8594;</button>
</div>
</div>
</div>
<div id="ivComments">
<div class="iv-comments-header">Kommentare</div>
<div id="ivCommentsList"></div>
<div class="iv-comment-compose">
<input type="text" id="ivCommentInput" placeholder="Kommentar schreiben…" maxlength="500">
<button type="button" onclick="toggleEmojiPicker(this,'ivCommentInput')" title="Emoji"
style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.35rem 0.6rem;font-size:0.95rem;cursor:pointer;margin:0;width:auto;">😊</button>
<button id="ivCommentSend">Senden</button>
</div>
</div>
</div>`;
document.body.appendChild(div);
}
_bindEvents() {
const init = () => {
this._q('ivClose').addEventListener('click', () => this.close());
this._q('imageViewer').addEventListener('click', e => {
if (e.target === this._q('imageViewer')) this.close();
});
this._q('ivPrev').addEventListener('click', () => this._prev());
this._q('ivNext').addEventListener('click', () => this._next());
this._q('ivLikeBtn').addEventListener('click', () => this._toggleLike());
this._q('ivCommentSend').addEventListener('click', () => this._postComment());
this._q('ivCommentInput').addEventListener('keydown', e => {
if (e.key === 'Enter') this._postComment();
});
window.addEventListener('resize', () => this._updateLayout());
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
document.addEventListener('keydown', e => {
if (!this.isOpen) return;
if (e.key === 'Escape') this.close();
if (e.key === 'ArrowLeft') this._prev();
if (e.key === 'ArrowRight') this._next();
});
}
}
window.imageViewer = new ImageViewer();

View File

@@ -0,0 +1,87 @@
/**
* Wiederverwendbares Meldungs-Modul.
* Bietet openMeldungDialog(zielTyp, zielId) und renderMeldenBtn(zielTyp, zielId).
*/
(function () {
// Dialog einmalig in den DOM einfügen
if (!document.getElementById('meldungDialog')) {
document.body.insertAdjacentHTML('beforeend', `
<div id="meldungDialog" style="
display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,0.6); align-items:center; justify-content:center;">
<div style="background:var(--color-card);border:1px solid var(--color-secondary);
border-radius:12px;padding:1.5rem;width:min(420px,90vw);position:relative;">
<h3 style="margin:0 0 1rem 0;color:var(--color-primary)">Inhalt melden</h3>
<p id="meldungDialogLabel" style="color:var(--color-muted);font-size:0.9rem;margin:0 0 0.75rem 0;"></p>
<textarea id="meldungGrund" placeholder="Grund (optional)"
style="width:100%;box-sizing:border-box;padding:0.5rem;border-radius:6px;
border:1px solid var(--color-secondary);background:var(--color-card);
color:var(--color-text);resize:vertical;min-height:80px;font-family:inherit;"></textarea>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
<button id="meldungAbbrechen" style="padding:0.45rem 1rem;border-radius:6px;
border:1px solid var(--color-secondary);background:transparent;
color:var(--color-text);cursor:pointer;">Abbrechen</button>
<button id="meldungSenden" style="padding:0.45rem 1rem;border-radius:6px;
border:none;background:var(--color-primary);color:#fff;cursor:pointer;font-weight:600;">
Melden</button>
</div>
<p id="meldungMsg" style="margin:0.5rem 0 0 0;font-size:0.85rem;color:var(--color-primary);display:none;"></p>
</div>
</div>
`);
document.getElementById('meldungAbbrechen').addEventListener('click', () => closeMeldungDialog());
document.getElementById('meldungDialog').addEventListener('click', function (e) {
if (e.target === this) closeMeldungDialog();
});
}
let _zielTyp = null, _zielId = null;
window.openMeldungDialog = function (zielTyp, zielId) {
_zielTyp = zielTyp;
_zielId = zielId;
document.getElementById('meldungGrund').value = '';
document.getElementById('meldungMsg').style.display = 'none';
document.getElementById('meldungDialogLabel').textContent =
zielTyp === 'PROFIL' ? 'Profil melden' : 'Post melden';
document.getElementById('meldungDialog').style.display = 'flex';
document.getElementById('meldungSenden').onclick = async function () {
const grund = document.getElementById('meldungGrund').value.trim();
const r = await fetch('/meldung', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zielTyp: _zielTyp, zielId: _zielId, grund: grund || null })
});
const msg = document.getElementById('meldungMsg');
msg.style.display = 'block';
if (r.status === 201) {
msg.style.color = 'var(--color-success, #2ecc71)';
msg.textContent = 'Meldung wurde übermittelt.';
setTimeout(closeMeldungDialog, 1500);
} else if (r.status === 409) {
msg.style.color = 'var(--color-primary)';
msg.textContent = 'Du hast diesen Inhalt bereits gemeldet.';
} else {
msg.style.color = 'var(--color-primary)';
msg.textContent = 'Fehler beim Senden.';
}
};
};
window.closeMeldungDialog = function () {
document.getElementById('meldungDialog').style.display = 'none';
};
/**
* Erzeugt einen kleinen "Melden"-Button-HTML-String.
* Verwendung: in innerHTML-Templates, wo onclick genutzt werden kann.
*/
window.renderMeldenBtn = function (zielTyp, zielId) {
return `<button onclick="openMeldungDialog('${zielTyp}','${zielId}')"
style="background:none;border:none;color:var(--color-muted,#888);
font-size:0.8rem;cursor:pointer;padding:0.2rem 0.4rem;border-radius:4px;"
title="Melden">⚑ Melden</button>`;
};
})();

View File

@@ -0,0 +1,237 @@
// ─────────────────────────────────────────────────────────────────────────────
// shared.js – Gemeinsame Helfer & Komponenten
// Einbinden: <script src="/js/shared.js"></script>
// (vor allen Seiten-Skripten, nach CSS-Links)
// ─────────────────────────────────────────────────────────────────────────────
// ── CSS-Injection (Comment + Carousel) ────────────────────────────────────────
(function injectSharedStyles() {
if (document.getElementById('shared-styles')) return;
const s = document.createElement('style');
s.id = 'shared-styles';
s.textContent = `
/* ── Karussell ── */
.post-carousel{position:relative;margin-top:0.5rem}
.car-slide{display:none}
.car-slide.active{display:block}
.car-btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.55);border:none;color:#fff;font-size:2.2rem;width:auto;min-width:2.4rem;height:3.2rem;border-radius:8px;cursor:pointer;z-index:5;display:flex;align-items:center;justify-content:center;padding:0 0.5rem;margin:0;line-height:1}
.car-prev{left:0.3rem}
.car-next{right:0.3rem}
.car-indicator{text-align:center;font-size:0.75rem;color:var(--color-muted);margin-top:0.25rem}
/* ── Like / Löschen-Buttons ── */
.btn-like{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:0.2rem 0.65rem;color:var(--color-muted);font-size:0.78rem;cursor:pointer;display:inline-flex;align-items:center;gap:0.3rem;margin:0;width:auto;transition:border-color 0.15s,color 0.15s}
.btn-like:hover,.btn-like.liked{border-color:var(--color-primary);color:var(--color-primary)}
.btn-delete-small{background:none;border:none;color:rgba(200,50,50,0.6);font-size:0.78rem;cursor:pointer;margin:0;width:auto;padding:0}
.btn-delete-small:hover{color:var(--color-primary)}
.btn-text{background:none;border:none;color:var(--color-muted);font-size:0.78rem;cursor:pointer;margin:0;width:auto;padding:0;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.2)}
.btn-text:hover{color:var(--color-text)}
/* ── Kommentare ── */
.comment-item{display:flex;gap:0.5rem;margin-bottom:0.5rem}
.comment-avatar{width:28px;height:28px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:0.75rem;flex-shrink:0;overflow:hidden}
.comment-avatar img{width:100%;height:100%;object-fit:cover}
.comment-body{flex:1;background:rgba(255,255,255,0.04);border-radius:6px;padding:0.5rem 0.65rem}
.comment-author{font-size:0.8rem;font-weight:600;color:var(--color-text)}
.comment-date{font-size:0.72rem;color:var(--color-muted);margin-left:0.4rem}
.comment-text{font-size:0.85rem;color:rgba(255,255,255,0.75);margin-top:0.2rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
.comment-actions{display:flex;gap:0.4rem;margin-top:0.3rem;align-items:center}
.replies-section{margin-top:0.5rem;padding-left:0.5rem;border-left:2px solid rgba(255,255,255,0.06)}
.comment-write{display:flex;gap:0.4rem;margin-top:0.5rem}
.comment-write input{flex:1;padding:0.4rem 0.75rem;font-size:0.85rem}
.comment-write button{width:auto;padding:0.4rem 0.75rem;font-size:0.82rem;white-space:nowrap}
`;
document.head.appendChild(s);
})();
// ── HTML-Escape ────────────────────────────────────────────────────────────────
function esc(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/\n/g, '<br>');
}
// ── Datum-Format ──────────────────────────────────────────────────────────────
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
// ── Emoji-Picker ──────────────────────────────────────────────────────────────
const EMOJIS = ['😊','😂','❤️','😍','🔥','👍','🥰','😎','🤔','😘','💕','🎉','✨','💋','😈','🫦','🍑','🍆','🔞','🥵','😭','😢','😤','🙄','🤦','🤷','🙏','💪','😏','🤩'];
let _emojiTarget = null;
function toggleEmojiPicker(btn, targetId) {
_emojiTarget = document.getElementById(targetId);
let picker = document.getElementById('sharedEmojiPicker');
if (!picker) {
picker = document.createElement('div');
picker.id = 'sharedEmojiPicker';
picker.style.cssText = 'position:fixed;z-index:9000;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:0.5rem;display:flex;flex-wrap:wrap;gap:0.2rem;max-width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
EMOJIS.forEach(em => {
const b = document.createElement('button');
b.textContent = em;
b.style.cssText = 'background:none;border:none;font-size:1.3rem;cursor:pointer;padding:0.2rem;margin:0;width:auto;line-height:1;';
b.onclick = e => { e.stopPropagation(); insertEmoji(em); };
picker.appendChild(b);
});
document.body.appendChild(picker);
}
if (picker.style.display === 'flex') { picker.style.display = 'none'; return; }
picker.style.display = 'flex';
requestAnimationFrame(() => {
const rect = btn.getBoundingClientRect();
const ph = picker.offsetHeight, pw = picker.offsetWidth;
let top = rect.top - ph - 8;
let left = rect.left;
if (top < 8) top = rect.bottom + 8;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
});
}
function insertEmoji(emoji) {
if (!_emojiTarget) return;
const start = _emojiTarget.selectionStart ?? _emojiTarget.value.length;
const end = _emojiTarget.selectionEnd ?? start;
_emojiTarget.value = _emojiTarget.value.slice(0, start) + emoji + _emojiTarget.value.slice(end);
_emojiTarget.selectionStart = _emojiTarget.selectionEnd = start + emoji.length;
_emojiTarget.focus();
}
document.addEventListener('click', e => {
const picker = document.getElementById('sharedEmojiPicker');
if (picker && picker.style.display === 'flex'
&& !picker.contains(e.target)
&& !e.target.closest('[onclick*="toggleEmojiPicker"]')) {
picker.style.display = 'none';
}
});
// ── Bild-Karussell ────────────────────────────────────────────────────────────
function bilderCarousel(bilder) {
if (!bilder || bilder.length === 0) return '';
if (bilder.length === 1) {
return `<div style="margin-top:0.5rem;"><img class="post-bild" src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>`;
}
const slides = bilder.map((b, i) =>
`<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>`
).join('');
return `<div class="post-carousel">
${slides}
<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button>
<div class="car-indicator"><span class="car-cur">1</span>/${bilder.length}</div>
</div>`;
}
function carNav(btn, dir) {
const car = btn.closest('.post-carousel');
const slides = Array.from(car.querySelectorAll('.car-slide'));
const cur = slides.findIndex(s => s.classList.contains('active'));
slides[cur].classList.remove('active');
const next = (cur + dir + slides.length) % slides.length;
slides[next].classList.add('active');
const ind = car.querySelector('.car-cur');
if (ind) ind.textContent = next + 1;
}
// ── Kommentar-Rendering ───────────────────────────────────────────────────────
// opts: { myUserId, showReplies }
// Seite muss definieren: deleteKommentar(kommentarId, targetType, targetId)
function renderKommentarHtml(k, targetType, targetId, opts) {
const { myUserId = null, showReplies = false } = opts || {};
const avatarHtml = k.authorPicture
? `<img src="data:image/png;base64,${k.authorPicture}" alt="">`
: '◉';
const canDelete = k.authorId === myUserId;
const replyLabel = k.replyCount > 0 ? `Antworten (${k.replyCount})` : 'Antworten';
return `<div class="comment-item" id="kom-${k.kommentarId}">
<div class="comment-avatar">${avatarHtml}</div>
<div class="comment-body">
<span class="comment-author">${esc(k.authorName)}</span>
<span class="comment-date">${fmtDate(k.createdAt)}</span>
<div class="comment-text">${esc(k.text)}</div>
<div class="comment-actions">
<button class="btn-like${k.likedByMe ? ' liked' : ''}" id="lk-kom-${k.kommentarId}"
onclick="toggleKommentarLike('${k.kommentarId}')">♥ <span id="lkc-kom-${k.kommentarId}">${k.likeCount}</span></button>
${showReplies ? `<button class="btn-text" onclick="toggleReplies('${k.kommentarId}')">${replyLabel}</button>` : ''}
${canDelete ? `<button class="btn-delete-small" onclick="deleteKommentar('${k.kommentarId}','${targetType}','${targetId}')">✕</button>` : ''}
</div>
${showReplies ? `<div class="replies-section" id="replies-${k.kommentarId}" style="display:none;"></div>` : ''}
</div>
</div>`;
}
function renderReplyHtml(r, parentId) {
const avatarHtml = r.authorPicture
? `<img src="data:image/png;base64,${r.authorPicture}" alt="">`
: '◉';
const canDelete = typeof window.myUserId !== 'undefined' && r.authorId === window.myUserId;
return `<div class="comment-item" id="kom-${r.kommentarId}" style="margin-bottom:0.35rem;">
<div class="comment-avatar" style="width:22px;height:22px;font-size:0.75rem;">${avatarHtml}</div>
<div class="comment-body" style="padding:0.35rem 0.55rem;">
<span class="comment-author">${esc(r.authorName)}</span>
<span class="comment-date">${fmtDate(r.createdAt)}</span>
<div class="comment-text">${esc(r.text)}</div>
<div class="comment-actions">
<button class="btn-like${r.likedByMe ? ' liked' : ''}" id="lk-kom-${r.kommentarId}"
onclick="toggleKommentarLike('${r.kommentarId}')">♥ <span id="lkc-kom-${r.kommentarId}">${r.likeCount}</span></button>
${canDelete ? `<button class="btn-delete-small" onclick="deleteReply('${r.kommentarId}','${parentId}')">✕</button>` : ''}
</div>
</div>
</div>`;
}
async function toggleKommentarLike(kommentarId) {
await fetch('/social/kommentare/' + kommentarId + '/like', { method: 'POST' });
const btn = document.getElementById('lk-kom-' + kommentarId);
const lc = document.getElementById('lkc-kom-' + kommentarId);
if (!btn || !lc) return;
const was = btn.classList.contains('liked');
btn.classList.toggle('liked', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
}
async function toggleReplies(kommentarId) {
const section = document.getElementById('replies-' + kommentarId);
if (section.style.display === 'none') {
section.style.display = '';
await loadReplies(kommentarId);
} else {
section.style.display = 'none';
}
}
async function loadReplies(kommentarId) {
const res = await fetch(`/social/kommentare?targetType=KOMMENTAR&targetId=${kommentarId}`);
const replies = await res.json();
const section = document.getElementById('replies-' + kommentarId);
section.innerHTML = (replies.length === 0
? '<p style="color:var(--color-muted);font-size:0.78rem;margin-bottom:0.35rem;">Noch keine Antworten.</p>'
: replies.map(r => renderReplyHtml(r, kommentarId)).join(''))
+ `<div class="comment-write">
<input type="text" id="ri-${kommentarId}" placeholder="Antwort schreiben…" maxlength="500"
onkeydown="if(event.key==='Enter') postReply('${kommentarId}')">
<button onclick="postReply('${kommentarId}')">Senden</button>
</div>`;
}
async function postReply(kommentarId) {
const input = document.getElementById('ri-' + kommentarId);
const text = input.value.trim();
if (!text) return;
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'KOMMENTAR', targetId: kommentarId, text })
});
input.value = '';
await loadReplies(kommentarId);
}
async function deleteReply(replyId, parentId) {
await fetch('/social/kommentare/' + replyId, { method: 'DELETE' });
await loadReplies(parentId);
}

View File

@@ -0,0 +1,254 @@
(function () {
const path = window.location.pathname;
const I = window.IC || function() { return ''; };
const groups = [
{
label: 'Vanilla Game',
icon: I('VANILLA'),
items: [
{ href: '/games/vanilla/neuvanilla.html', icon: I('PLAY_NEW'), label: 'Neue Session', id: 'navVanillaNeu' },
{ href: '#', icon: I('WAITING'), label: 'Aktive Session', id: 'navVanillaAktiv' },
{ href: '/games/vanilla/vanillaingame.html', icon: I('PLAY_ACTIVE'), label: 'Im Spiel', id: 'navVanillaImSpiel' },
{ href: '/games/common/aufgaben.html', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/common/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/chastity/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' },
]
},
{
label: 'BDSM Game',
icon: I('BDSM'),
items: [
{ href: '/games/bdsm/neubdsm.html', icon: I('PLAY_NEW'), label: 'Neue Session', id: 'navBdsmNeu' },
{ href: '#', icon: I('WAITING'), label: 'Aktive Session', id: 'navBdsmAktiv' },
{ href: '/games/bdsm/bdsmingame.html', icon: I('PLAY_ACTIVE'), label: 'Im Spiel', id: 'navBdsmImSpiel' },
{ href: '/games/common/aufgaben.html?mode=bdsm', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/common/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/chastity/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' },
]
},
{
label: 'Chastity Game',
icon: I('CHASTITY'),
items: [
{ href: '/games/chastity/neulock.html', icon: I('NEW_LOCK'), label: 'Neues Lock', id: 'navChastityNeu' },
{ href: '#', icon: I('ACTIVE_LOCK'), label: 'Aktives Lock', id: 'navChastityAktiv' },
{ href: '/games/chastity/communityvotes.html', icon: I('VOTES'), label: 'Community Votes' },
{ href: '/games/chastity/meine-locks.html', icon: I('LOCK'), label: 'Meine Vorlagen' },
{ href: '/games/chastity/entdecken-vorlagen.html', icon: I('DISCOVER'), label: 'Entdecken' },
{ href: '/games/chastity/keyholder-finden.html', icon: I('FRIENDS'), label: 'Keyholder finden' },
{ href: '/games/chastity/keyholder.html', icon: I('KEY'), label: 'Keyholder' },
{ href: '/games/chastity/unlock-history.html', icon: I('HISTORY'), label: 'Code-Historie' },
]
},
];
const homeCls = path === '/userhome.html' ? ' class="active"' : '';
const homeItem = `
<li class="sidebar-mobile-only">
<a href="/userhome.html"${homeCls}><span class="icon">${I('HOME')}</span> Home</a>
</li>`;
// ── Community-Links (immer sichtbar, oberhalb der Spiele) ──
const socialLinks = [
{ href: '/community/feed.html', icon: I('FEED'), label: 'Feed', badgeId: null },
{ href: '/community/freunde.html', icon: I('FRIENDS'), label: 'Freunde', badgeId: 'socialFriendsBadge'},
{ href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null },
{ href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null },
{ href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'},
{ href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null },
];
const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => {
const cls = path === href ? ' class="active"' : '';
const badge = badgeId ? `<span class="social-badge" id="${badgeId}" style="display:none;"></span>` : '';
return `<li><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}${badge}</a></li>`;
}).join('');
const fullHref = path + window.location.search;
const nav = groups.map(({ label, icon, items }) => {
const isOpen = items.some(item => item.href === path || item.href === fullHref);
const openCls = isOpen ? ' open' : '';
const subItems = items.map(({ href, icon: iIcon, label: iLabel, id: iId }) => {
const cls = (href === path || href === fullHref) ? ' class="active"' : '';
const idAt = iId ? ` id="${iId}"` : '';
return `<li${idAt}><a href="${href}"${cls}><span class="icon">${iIcon}</span> ${iLabel}</a></li>`;
}).join('');
return `
<li class="sidebar-group${openCls}">
<a class="sidebar-group-toggle"><span class="icon">${icon}</span> ${label}<span class="sidebar-arrow">${I('ARROW')}</span></a>
<ul class="sidebar-sub">
${subItems}
</ul>
</li>`;
}).join('');
const adminCls = path === '/admin/admin.html' ? ' class="active"' : '';
const adminItem = `<li id="navAdminLink" style="display:none"><a href="/admin/admin.html"${adminCls}><span class="icon">${I('ADMIN') || '⚙'}</span> Administration</a></li>`;
const footerLinks = [
{ href: '/help/kontakt.html', icon: '✉️', label: 'Kontakt & Feedback' },
{ href: '/help/impressum.html', icon: '📄', label: 'Impressum' },
];
const footerNav = footerLinks.map(({ href, icon, label }) => {
const cls = path === href ? ' class="active"' : '';
return `<li><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}</a></li>`;
}).join('');
document.body.insertAdjacentHTML('afterbegin', `
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<button class="burger" id="burgerBtn" aria-label="Menü öffnen">
<span class="burger-icon"><span></span><span></span><span></span></span>
</button>
<div class="sidebar-wrapper" id="sidebar">
<aside class="sidebar">
<div class="sidebar-scroll-area">
<ul>
${socialNav}
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
${nav}
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;" id="navAdminDivider" style="display:none"></li>
${adminItem}
</ul>
</div>
</aside>
<div class="sidebar-footer">
<ul>${footerNav}</ul>
</div>
</div>
`);
// Sidebar und .main in einen zentrierten App-Wrapper verschieben
const appWrapper = document.createElement('div');
appWrapper.className = 'app-wrapper';
const sidebarEl = document.getElementById('sidebar');
const mainEl = document.querySelector('.main');
document.body.insertBefore(appWrapper, sidebarEl);
appWrapper.appendChild(sidebarEl);
if (mainEl) appWrapper.appendChild(mainEl);
// Group toggle
document.querySelectorAll('.sidebar-group-toggle').forEach(toggle => {
toggle.addEventListener('click', e => {
e.preventDefault();
toggle.closest('.sidebar-group').classList.toggle('open');
});
});
// "Im Spiel" und "Aktive Session" standardmäßig ausblenden; wird nach Session-Check ggf. wieder eingeblendet
const navNeu = document.getElementById('navBdsmNeu');
const navAktiv = document.getElementById('navBdsmAktiv');
const navImSpiel = document.getElementById('navBdsmImSpiel');
const navCAktiv = document.getElementById('navChastityAktiv');
const navVNeu = document.getElementById('navVanillaNeu');
const navVAktiv = document.getElementById('navVanillaAktiv');
const navVImSpiel = document.getElementById('navVanillaImSpiel');
if (navAktiv) navAktiv.style.display = 'none';
if (navImSpiel) navImSpiel.style.display = 'none';
if (navCAktiv) navCAktiv.style.display = 'none';
if (navVAktiv) navVAktiv.style.display = 'none';
if (navVImSpiel) navVImSpiel.style.display = 'none';
// Session-Status prüfen
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(async user => {
if (!user) return;
// BDSM Session-Status
try {
const aktivRes = await fetch('/bdsm/einladung/meine-aktive');
if (aktivRes.ok) {
const aktiv = await aktivRes.json();
if (navNeu) navNeu.style.display = 'none';
if (navImSpiel) navImSpiel.style.display = 'none';
if (navAktiv) {
navAktiv.style.display = '';
navAktiv.querySelector('a').href = aktiv.sessionId ? '/games/bdsm/bdsmingame.html' : '/games/bdsm/neubdsm.html';
}
} else {
const sessionRes = await fetch(`/bdsm?userId=${user.userId}`);
const hasSession = sessionRes.status === 200;
if (navNeu) navNeu.style.display = hasSession ? 'none' : '';
if (navImSpiel) navImSpiel.style.display = hasSession ? '' : 'none';
}
} catch (_) {}
// Vanilla Session-Status
try {
const vAktivRes = await fetch('/vanilla/einladung/meine-aktive');
if (vAktivRes.ok) {
const vAktiv = await vAktivRes.json();
if (navVNeu) navVNeu.style.display = 'none';
if (navVImSpiel) navVImSpiel.style.display = 'none';
if (navVAktiv) {
navVAktiv.style.display = '';
navVAktiv.querySelector('a').href = vAktiv.sessionId ? '/games/vanilla/vanillaingame.html' : '/games/vanilla/neuvanilla.html';
}
} else {
const vSessionRes = await fetch(`/vanilla?userId=${user.userId}`);
const vHasSession = vSessionRes.status === 200;
if (navVNeu) navVNeu.style.display = vHasSession ? 'none' : '';
if (navVImSpiel) navVImSpiel.style.display = vHasSession ? '' : 'none';
}
} catch (_) {}
// Chastity Lock-Status
try {
const lockRes = await fetch('/keyholder/mylock');
if (lockRes.ok) {
const lockData = await lockRes.json();
if (navCAktiv) {
navCAktiv.style.display = '';
navCAktiv.querySelector('a').href = '/games/chastity/activelock.html?lockId=' + lockData.lockId;
}
}
} catch (_) {}
// Admin-Link
if (user.admin) {
const navAdminLink = document.getElementById('navAdminLink');
const navAdminDivider = document.getElementById('navAdminDivider');
if (navAdminLink) navAdminLink.style.display = '';
if (navAdminDivider) navAdminDivider.style.display = '';
}
})
.catch(() => {});
const sidebar = document.getElementById('sidebar');
const burgerBtn = document.getElementById('burgerBtn');
const overlay = document.getElementById('sidebarOverlay');
function openMenu() {
sidebar.classList.add('open');
overlay.classList.add('visible');
burgerBtn.classList.add('open');
burgerBtn.setAttribute('aria-label', 'Menü schließen');
}
function closeMenu() {
sidebar.classList.remove('open');
overlay.classList.remove('visible');
burgerBtn.classList.remove('open');
burgerBtn.setAttribute('aria-label', 'Menü öffnen');
}
burgerBtn.addEventListener('click', () =>
sidebar.classList.contains('open') ? closeMenu() : openMenu()
);
overlay.addEventListener('click', closeMenu);
sidebar.querySelectorAll('a:not(.sidebar-group-toggle)').forEach(l =>
l.addEventListener('click', () => {
if (window.innerWidth <= (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--breakpoint-mobile').trim()) || 768))
closeMenu();
})
);
// Topbar und Social-Sidebar nachladen
function loadScript(src) {
const s = document.createElement('script');
s.src = src;
document.head.appendChild(s);
}
loadScript('/js/topbar.js');
loadScript('/js/social-sidebar.js');
})();

View File

@@ -0,0 +1,109 @@
(function () {
// Badge + SSE service (kein Sidebar-Rendering mehr)
// ── Badge-Zähler ──
function setBadge(ids, count, topbarType) {
ids.forEach(id => {
if (!id) return;
const el = document.getElementById(id);
if (!el) return;
el.textContent = count;
el.style.display = count > 0 ? '' : 'none';
});
if (topbarType && window.__topbarSetBadge) window.__topbarSetBadge(topbarType, count);
}
// ── Ton abspielen ──
let userHasInteracted = false;
document.addEventListener('click', () => { userHasInteracted = true; }, { passive: true });
document.addEventListener('keydown', () => { userHasInteracted = true; }, { passive: true });
document.addEventListener('touchstart', () => { userHasInteracted = true; }, { passive: true });
function playSound(src) {
if (!userHasInteracted) return;
try {
const audio = new Audio(src);
audio.volume = 0.6;
audio.play().catch(() => {});
} catch(e) {}
}
// ── Initiale Badge-Counts laden ──
fetch('/social/friends/pending/count')
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialFriendsBadge'], n, null))
.catch(() => {});
fetch('/social/messages/unread/count')
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialMsgBadge'], n, 'msg'))
.catch(() => {});
fetch('/notifications/unread/count')
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialNotifBadge'], n, 'notif'))
.catch(() => {});
Promise.all([
fetch('/gruppen/requests/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/gruppen/reports/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0)
]).then(([joins, reports]) => setBadge(['socialGruppenBadge'], joins + reports, null))
.catch(() => {});
Promise.all([
fetch('/keyholder/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/lockee/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/bdsm/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/vanilla/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0)
]).then(([kh, lockee, bdsm, vanilla]) =>
setBadge(['socialInvBadge'], kh + lockee + bdsm + vanilla, 'inv')
).catch(() => {});
// ── SSE: Echtzeit-Push vom Server ──
function connectSse() {
const es = new EventSource('/events/stream');
es.addEventListener('DM', e => {
try {
const data = JSON.parse(e.data);
setBadge(['socialMsgBadge'], data.unreadCount || 0, 'msg');
if (window.location.pathname !== '/community/nachrichten.html') {
playSound('/audio/message.mp3');
}
if (typeof window.__sseOnDm === 'function') window.__sseOnDm(data);
} catch(ex) {}
});
es.addEventListener('NOTIFICATION', e => {
try {
const data = JSON.parse(e.data);
setBadge(['socialNotifBadge'], data.unreadCount || 0, 'notif');
if (window.location.pathname !== '/community/benachrichtigungen.html') {
playSound('/audio/notification.mp3');
}
if (typeof window.__sseOnNotification === 'function') window.__sseOnNotification(data);
} catch(ex) {}
});
es.addEventListener('INVITATION', () => {
try {
if (typeof window.__topbarReloadInvBadge === 'function') window.__topbarReloadInvBadge();
} catch(ex) {}
});
es.onerror = () => {
es.close();
// Vor dem Reconnect prüfen ob noch eingeloggt (verhindert Endlos-Schleife bei abgelaufener Session)
setTimeout(() => {
fetch('/login/me', { method: 'GET' })
.then(r => { if (r.ok) connectSse(); })
.catch(() => {});
}, 5000);
};
}
// SSE nur starten wenn authentifiziert – verhindert Fehler-Spam bei nicht eingeloggten Seiten
fetch('/login/me', { method: 'GET' })
.then(r => { if (r.ok) connectSse(); })
.catch(() => {});
})();

View File

@@ -0,0 +1,405 @@
(function () {
if (document.querySelector('.topbar')) return;
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Warten bis app-wrapper existiert (sidebar.js läuft synchron davor) ──
function init() {
const appWrapper = document.querySelector('.app-wrapper');
if (!appWrapper) { setTimeout(init, 30); return; }
injectHTML(appWrapper);
loadProfile();
setupSearch();
setupOverlayButtons();
loadInitialBadges();
}
setTimeout(init, 0);
// ── HTML Struktur ──
function injectHTML(appWrapper) {
const topbar = document.createElement('div');
topbar.className = 'topbar';
topbar.id = 'topbar';
topbar.innerHTML = `
<div class="topbar-left">
<a href="/userhome.html"><img class="topbar-banner" src="/img/banner.png" alt="xXx Sphere"></a>
</div>
<div class="topbar-search-wrap">
<span class="topbar-search-icon">${IC('SEARCH')}</span>
<input type="text" id="topbarSearchInput" placeholder="Suchen…" autocomplete="off" spellcheck="false">
<div class="topbar-search-overlay" id="topbarSearchOverlay"></div>
</div>
<div class="topbar-right">
<button class="topbar-btn" id="topbarMsgBtn" title="Nachrichten">
${IC('MESSAGES')}
<span class="topbar-badge" id="topbarMsgBadge"></span>
</button>
<button class="topbar-btn" id="topbarNotifBtn" title="Benachrichtigungen">
${IC('NOTIFICATIONS')}
<span class="topbar-badge" id="topbarNotifBadge"></span>
</button>
<button class="topbar-btn" id="topbarInvBtn" title="Einladungen">
${IC('INVITATIONS')}
<span class="topbar-badge" id="topbarInvBadge"></span>
</button>
<button class="topbar-btn topbar-profile-btn" id="topbarProfileBtn">
<span class="topbar-avatar-placeholder" id="topbarAvatarWrap">${IC('PROFILE')}</span>
<span class="topbar-username" id="topbarUsername">…</span>
</button>
</div>`;
appWrapper.insertAdjacentElement('beforebegin', topbar);
// Panel-Overlays am Ende von body einfügen
document.body.insertAdjacentHTML('beforeend', `
<div class="topbar-panel" id="topbarMsgPanel">
<div class="topbar-panel-header">
<span>${IC('MESSAGES')} Nachrichten</span>
<button class="topbar-panel-close" onclick="window.__topbarCloseAll()">✕</button>
</div>
<div class="topbar-panel-body" id="topbarMsgBody"></div>
<div class="topbar-panel-footer"><a href="/community/nachrichten.html">Alle Nachrichten →</a></div>
</div>
<div class="topbar-panel" id="topbarNotifPanel">
<div class="topbar-panel-header">
<span>${IC('NOTIFICATIONS')} Benachrichtigungen</span>
<button class="topbar-panel-close" onclick="window.__topbarCloseAll()">✕</button>
</div>
<div class="topbar-panel-body" id="topbarNotifBody"></div>
<div class="topbar-panel-footer"><a href="/community/benachrichtigungen.html">Alle anzeigen →</a></div>
</div>
<div class="topbar-panel" id="topbarInvPanel">
<div class="topbar-panel-header">
<span>${IC('INVITATIONS')} Einladungen</span>
<button class="topbar-panel-close" onclick="window.__topbarCloseAll()">✕</button>
</div>
<div class="topbar-panel-body" id="topbarInvBody"></div>
<div class="topbar-panel-footer"><a href="/games/common/einladungen.html">Alle anzeigen →</a></div>
</div>
<div class="topbar-panel" id="topbarProfilePanel">
<div class="topbar-panel-header">
<span>Konto</span>
<button class="topbar-panel-close" onclick="window.__topbarCloseAll()">✕</button>
</div>
<div class="topbar-panel-body topbar-profile-body">
<div class="topbar-profile-card">
<span id="topbarPanelAvatarWrap" style="font-size:2.5rem;line-height:1;">${IC('PROFILE')}</span>
<div>
<div id="topbarPanelName" style="font-weight:700;font-size:1rem;"></div>
</div>
</div>
<hr style="border:none;border-top:1px solid var(--color-secondary);margin:0;">
<nav class="topbar-profile-nav">
<a id="topbarProfileLink" href="/community/benutzer.html" class="topbar-profile-link">
<span>${IC('PROFILE')}</span> Mein Profil
</a>
<a href="/konto/einstellungen.html" class="topbar-profile-link">
<span>${IC('SETTINGS')}</span> Einstellungen
</a>
<a href="/help/overview.html" class="topbar-profile-link">
<span>${IC('HELP')}</span> Hilfe
</a>
<hr style="border:none;border-top:1px solid var(--color-secondary);margin:0;">
<a href="/login/logout" class="topbar-profile-link topbar-profile-link--danger">
<span>${IC('LOGOUT')}</span> Abmelden
</a>
</nav>
</div>
</div>
`);
}
function IC(key) { return window.IC ? window.IC(key) : (window.ICONS?.[key]?.value || ''); }
// ── Profil laden ──
function loadProfile() {
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
if (!user) return;
const nameEl = document.getElementById('topbarUsername');
if (nameEl) nameEl.textContent = user.name;
const avatarWrap = document.getElementById('topbarAvatarWrap');
if (avatarWrap && user.profilePicture) {
avatarWrap.innerHTML = `<img src="data:image/png;base64,${user.profilePicture}" class="topbar-avatar" alt="">`;
}
const panelName = document.getElementById('topbarPanelName');
if (panelName) panelName.textContent = user.name;
const panelAvatar = document.getElementById('topbarPanelAvatarWrap');
if (panelAvatar && user.profilePicture) {
panelAvatar.innerHTML = `<img src="data:image/png;base64,${user.profilePicture}" style="width:3rem;height:3rem;border-radius:50%;object-fit:cover;" alt="">`;
}
const profileLink = document.getElementById('topbarProfileLink');
if (profileLink && user.userId) profileLink.href = '/community/benutzer.html?userId=' + user.userId;
})
.catch(() => {});
}
// ── Suche ──
function setupSearch() {
const input = document.getElementById('topbarSearchInput');
const overlay = document.getElementById('topbarSearchOverlay');
if (!input || !overlay) return;
let timer;
input.addEventListener('input', () => {
clearTimeout(timer);
const q = input.value.trim();
if (q.length < 2) { overlay.innerHTML = ''; overlay.classList.remove('open'); return; }
overlay.innerHTML = '<div class="topbar-search-hint">Suche…</div>';
overlay.classList.add('open');
timer = setTimeout(() => doSearch(q, overlay), 300);
});
document.addEventListener('click', e => {
if (!e.target.closest('.topbar-search-wrap')) {
overlay.classList.remove('open');
}
});
}
async function doSearch(q, overlay) {
try {
const res = await fetch('/social/users/search?q=' + encodeURIComponent(q));
if (!res.ok) { overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>'; return; }
const users = await res.json();
if (!users || users.length === 0) {
overlay.innerHTML = '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
return;
}
overlay.innerHTML = users.map(u => {
const av = u.profilePicture
? `<img src="data:image/png;base64,${esc(u.profilePicture)}" class="topbar-search-avatar" alt="">`
: `<span class="topbar-search-avatar topbar-search-avatar--placeholder">${IC('PROFILE')}</span>`;
return `<a href="/community/benutzer.html?userId=${esc(u.userId)}" class="topbar-search-result">
${av}
<span style="font-size:0.92rem;font-weight:600;">${esc(u.name)}</span>
</a>`;
}).join('');
} catch (e) {
overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>';
}
}
// ── Panel-Overlays ──
let _activePanel = null;
function positionPanel(panel, btn) {
const topbar = document.getElementById('topbar');
const tRect = topbar ? topbar.getBoundingClientRect() : btn.getBoundingClientRect();
panel.style.top = tRect.bottom + 'px';
panel.style.right = Math.max(4, window.innerWidth - tRect.right) + 'px';
panel.style.left = 'auto';
}
function openPanel(panelId, btnId, loadFn) {
const panel = document.getElementById(panelId);
const btn = document.getElementById(btnId);
if (!panel || !btn) return;
if (_activePanel === panel && panel.classList.contains('open')) {
closeAllPanels(); return;
}
closeAllPanels();
positionPanel(panel, btn);
panel.classList.add('open');
_activePanel = panel;
if (loadFn) loadFn();
}
function closeAllPanels() {
document.querySelectorAll('.topbar-panel.open').forEach(p => p.classList.remove('open'));
_activePanel = null;
}
window.__topbarCloseAll = closeAllPanels;
document.addEventListener('click', e => {
if (!e.target.closest('.topbar-panel') && !e.target.closest('.topbar-btn'))
closeAllPanels();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAllPanels(); });
function setupOverlayButtons() {
const msgBtn = document.getElementById('topbarMsgBtn');
const notifBtn = document.getElementById('topbarNotifBtn');
const invBtn = document.getElementById('topbarInvBtn');
const profileBtn = document.getElementById('topbarProfileBtn');
if (msgBtn) msgBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarMsgPanel', 'topbarMsgBtn', loadMessages); });
if (notifBtn) notifBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarNotifPanel', 'topbarNotifBtn', loadNotifications); });
if (invBtn) invBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarInvPanel', 'topbarInvBtn', loadInvitations); });
if (profileBtn) profileBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarProfilePanel', 'topbarProfileBtn', null); });
}
// ── Nachrichten ──
async function loadMessages() {
const body = document.getElementById('topbarMsgBody');
if (!body) return;
body.innerHTML = '<div class="topbar-panel-hint">Wird geladen…</div>';
try {
const res = await fetch('/social/messages');
if (!res.ok) { body.innerHTML = '<div class="topbar-panel-hint">Keine Nachrichten.</div>'; return; }
const convos = await res.json();
if (!convos.length) { body.innerHTML = '<div class="topbar-panel-hint">Noch keine Nachrichten.</div>'; return; }
body.innerHTML = convos.slice(0, 7).map(c => {
const av = c.partner?.profilePicture
? `<img src="data:image/png;base64,${esc(c.partner.profilePicture)}" class="topbar-item-avatar" alt="">`
: `<span class="topbar-item-avatar topbar-item-avatar--placeholder">${IC('PROFILE')}</span>`;
const bold = c.unreadCount > 0 ? 'font-weight:700;' : '';
const badge = c.unreadCount > 0
? `<span class="topbar-item-badge">${c.unreadCount > 99 ? '99+' : c.unreadCount}</span>` : '';
return `<a href="/community/nachrichten.html?userId=${esc(c.partner?.userId)}" class="topbar-panel-item">
${av}
<div class="topbar-panel-item-body">
<div style="${bold}font-size:0.88rem;">${esc(c.partner?.name || '')}</div>
<div class="topbar-panel-item-sub">${esc(c.lastMessage?.text || '')}</div>
</div>
${badge}
</a>`;
}).join('');
} catch (e) { body.innerHTML = '<div class="topbar-panel-hint">Fehler beim Laden.</div>'; }
}
// ── Benachrichtigungen ──
async function loadNotifications() {
const body = document.getElementById('topbarNotifBody');
if (!body) return;
body.innerHTML = '<div class="topbar-panel-hint">Wird geladen…</div>';
try {
const res = await fetch('/notifications');
if (!res.ok) { body.innerHTML = '<div class="topbar-panel-hint">Keine Benachrichtigungen.</div>'; return; }
const unread = (await res.json()).filter(n => !n.read);
if (!unread.length) { body.innerHTML = '<div class="topbar-panel-hint">Keine neuen Benachrichtigungen.</div>'; return; }
body.innerHTML = '';
unread.forEach(n => {
const el = document.createElement('div');
const tag = n.targetUrl ? 'a' : 'div';
const href = n.targetUrl ? `href="${esc(n.targetUrl)}"` : '';
const av = n.senderAvatar
? `<img src="data:image/png;base64,${esc(n.senderAvatar)}" class="topbar-item-avatar" alt="">`
: `<span class="topbar-item-avatar topbar-item-avatar--placeholder">${IC('PROFILE')}</span>`;
el.innerHTML = `<${tag} ${href} class="topbar-panel-item topbar-notif-item${n.read ? '' : ' topbar-notif-item--unread'}">
${av}
<div class="topbar-panel-item-body">
<div style="font-size:0.85rem;line-height:1.4;">${esc(n.text)}</div>
<div class="topbar-panel-item-sub">${n.sentAt ? new Date(n.sentAt).toLocaleString('de-DE',{dateStyle:'short',timeStyle:'short'}) : ''}</div>
</div>
</${tag}>`;
body.appendChild(el.firstElementChild);
});
// Alle als gelesen markieren
fetch('/notifications/read-all', { method: 'POST' }).then(() => setTopbarBadge('notif', 0)).catch(() => {});
} catch (e) { body.innerHTML = '<div class="topbar-panel-hint">Fehler beim Laden.</div>'; }
}
window.__topbarMarkNotifRead = async function (id) {
try {
await fetch('/notifications/' + id + '/read', { method: 'POST' });
const el = document.querySelector(`.topbar-notif-item--unread[onclick*="${id}"]`);
if (el) el.classList.remove('topbar-notif-item--unread');
const r = await fetch('/notifications/unread/count');
if (r.ok) setTopbarBadge('notif', await r.json());
} catch (e) {}
};
window.__topbarMarkAllRead = async function () {
try {
await fetch('/notifications/read-all', { method: 'POST' });
setTopbarBadge('notif', 0);
loadNotifications();
} catch (e) {}
};
// ── Einladungen ──
async function loadInvitations() {
const body = document.getElementById('topbarInvBody');
if (!body) return;
body.innerHTML = '<div class="topbar-panel-hint">Wird geladen…</div>';
try {
const [lr, kr, br, vr] = await Promise.all([
fetch('/lockee/invitations/mine'),
fetch('/keyholder/invitations/mine'),
fetch('/bdsm/einladung/pending'),
fetch('/vanilla/einladung/pending')
]);
const lockee = lr.ok ? await lr.json() : [];
const kh = kr.ok ? await kr.json() : [];
const bdsm = br.ok ? await br.json() : [];
const vanilla = vr.ok ? await vr.json() : [];
const all = [
...lockee.map(i => ({ ...i, _type: 'lockee' })),
...kh.map(i => ({ ...i, _type: 'keyholder' })),
...bdsm.map(i => ({ ...i, _type: 'bdsm' })),
...vanilla.map(i => ({ ...i, _type: 'vanilla' }))
];
if (!all.length) { body.innerHTML = '<div class="topbar-panel-hint">Keine offenen Einladungen.</div>'; return; }
body.innerHTML = '';
all.forEach(inv => body.appendChild(buildInvCard(inv)));
} catch (e) { body.innerHTML = '<div class="topbar-panel-hint">Fehler beim Laden.</div>'; }
}
function buildInvCard(inv) {
let typeIcon, typeName, line;
if (inv._type === 'lockee') {
typeIcon = IC('LOCK'); typeName = 'Lockee-Einladung'; line = inv.lockName || 'Lock';
} else if (inv._type === 'keyholder') {
typeIcon = IC('KEY'); typeName = 'Keyholder-Einladung'; line = inv.lockName || 'Lock';
} else if (inv._type === 'vanilla') {
typeIcon = IC('INVITATIONS'); typeName = 'Vanilla Game'; line = inv.inviterName || 'Einladung';
} else {
typeIcon = IC('BDSM'); typeName = 'BDSM Game'; line = inv.senderName || 'Einladung';
}
const senderPic = inv.senderAvatar || inv.lockOwnerAvatar || inv.inviterAvatar;
const av = senderPic
? `<img src="data:image/png;base64,${esc(senderPic)}" class="topbar-item-avatar" alt="">`
: `<span class="topbar-item-avatar topbar-item-avatar--placeholder">${IC('PROFILE')}</span>`;
const div = document.createElement('div');
div.className = 'topbar-panel-item topbar-inv-card';
div.style.cursor = 'pointer';
div.innerHTML = `${av}
<div class="topbar-panel-item-body">
<div class="topbar-panel-item-sub">${typeIcon} ${typeName}</div>
<div style="font-weight:600;font-size:0.88rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(line)}</div>
</div>`;
div.addEventListener('click', () => { window.location.href = '/games/common/einladungen.html'; });
return div;
}
// ── Badge-Verwaltung ──
function setTopbarBadge(type, count) {
const map = { msg: 'topbarMsgBadge', notif: 'topbarNotifBadge', inv: 'topbarInvBadge' };
const el = document.getElementById(map[type]);
if (!el) return;
el.textContent = count > 99 ? '99+' : count;
el.style.display = count > 0 ? 'inline-block' : 'none';
}
// Für social-sidebar.js zugänglich
window.__topbarSetBadge = setTopbarBadge;
function reloadInvBadge() {
Promise.all([
fetch('/lockee/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/keyholder/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/bdsm/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/vanilla/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0)
]).then(([l, k, b, v]) => setTopbarBadge('inv', l + k + b + v)).catch(() => {});
}
window.__topbarReloadInvBadge = reloadInvBadge;
function loadInitialBadges() {
fetch('/social/messages/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('msg', n)).catch(() => {});
fetch('/notifications/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('notif', n)).catch(() => {});
reloadInvBadge();
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,766 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profil – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.profile-picture-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.profile-picture {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--color-secondary);
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: var(--color-muted);
flex-shrink: 0;
}
.profile-picture img {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
}
.profile-field {
margin-bottom: 1.25rem;
}
.profile-field label {
margin-top: 0;
}
.profile-field-row {
display: flex;
align-items: stretch;
gap: 0.5rem;
}
.profile-field-row p {
flex: 1;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
font-size: 1rem;
color: var(--color-text);
margin: 0;
}
.profile-field-row button {
flex-shrink: 0;
width: 175px;
padding: 0.65rem 0.75rem;
margin-top: 0;
white-space: nowrap;
}
input[type="file"] {
font-size: 0.85rem;
color: var(--color-muted);
}
/* ── Gallery ── */
.gallery-section-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 1.75rem 0 0.75rem;
border-top: 1px solid var(--color-secondary);
padding-top: 1.5rem;
}
.gallery-upload-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
/* ── Vorlieben Tabs ── */
.vl-tabs { display: flex; gap: 0; flex-wrap: wrap;
border-bottom: 1px solid var(--color-secondary); margin-bottom: 1rem; }
.vl-tab-btn { background: none; border: none; border-bottom: 3px solid transparent;
border-radius: 0; padding: 0.5rem 1rem; font-size: 0.85rem; font-weight: 600;
color: var(--color-muted); cursor: pointer; margin-bottom: -1px;
transition: color .15s, border-color .15s; white-space: nowrap; }
.vl-tab-btn:hover { color: var(--color-text); background: none; }
.vl-tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.vl-tab-panel { display: none; }
.vl-tab-panel.active { display: block; }
/* ── Vorlieben Items ── */
.vorliebe-row {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.55rem 0.75rem; border-radius: 8px;
background: var(--color-card); border: 1px solid var(--color-secondary);
margin-bottom: 0.45rem;
}
.vorliebe-row-name { font-size: 0.9rem; flex: 1; min-width: 0; }
.vorliebe-smileys { display: flex; gap: 0.25rem; flex-shrink: 0; }
.vl-smiley {
display: inline-flex; align-items: center; cursor: pointer;
border: 2px solid transparent; border-radius: 6px;
padding: 0.1rem 0.15rem;
transition: border-color .15s, transform .1s;
user-select: none;
}
.vl-smiley img { height: 1.6rem; width: auto; display: block; }
.vl-smiley:hover { transform: scale(1.15); }
.vl-smiley.active { border-color: var(--vl-color); }
.vorlieben-hint { font-size: 0.82rem; color: var(--color-muted); margin-bottom: 1rem; }
#ownGallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.4rem;
margin-bottom: 1.5rem;
}
.own-thumb {
aspect-ratio: 1;
overflow: hidden;
border-radius: 6px;
position: relative;
background: var(--color-secondary);
cursor: pointer;
}
.own-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.own-thumb-delete {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0.6);
border: none;
color: #fff;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
font-size: 0.75rem;
line-height: 1;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
transition: background 0.15s;
}
.own-thumb:hover .own-thumb-delete { display: flex; }
.own-thumb-delete:hover { background: rgba(180,30,30,0.85); }
.btn-delete-row {
display: flex;
justify-content: flex-end;
margin-bottom: 1.25rem;
}
.btn-delete {
width: 175px;
padding: 0.65rem 0.75rem;
margin-top: 0;
background: transparent;
border: 1px solid var(--color-secondary);
border-radius: 6px;
color: var(--color-muted);
font-size: 0.85rem;
font-weight: normal;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-delete:hover {
background: #3d0f1a;
border-color: var(--color-primary);
color: var(--color-primary);
}
/* ── Profile extras ── */
.profile-extras-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1rem;
margin-bottom: 1rem;
}
.profile-extras-grid .full-col {
grid-column: 1 / -1;
}
select {
width: 100%;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
appearance: none;
-webkit-appearance: none;
}
select:focus { border-color: var(--color-primary); }
textarea {
width: 100%;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 1rem;
outline: none;
resize: vertical;
min-height: 100px;
transition: border-color 0.2s;
font-family: inherit;
}
textarea:focus { border-color: var(--color-primary); }
.char-count {
font-size: 0.75rem;
color: var(--color-muted);
text-align: right;
margin-top: 0.25rem;
}
.char-count.over { color: var(--color-primary); }
/* ── Modal ── */
.modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-backdrop.visible {
display: flex;
}
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
.modal h2 {
color: var(--color-primary);
font-size: 1.2rem;
margin-bottom: 1.25rem;
}
.modal-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.25rem;
}
.modal-actions button {
flex: 1;
margin-top: 0;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="profile-picture-wrap">
<div class="profile-picture" id="profilePicDisplay">◉</div>
<input type="file" id="picFile" accept="image/*">
</div>
<!-- Freiwillige Profilangaben -->
<div class="gallery-section-label" style="margin-top:1.25rem;">Freiwillige Angaben</div>
<div class="profile-extras-grid">
<div>
<label>Alter</label>
<input type="text" id="profileAlter" readonly style="background:transparent;cursor:default;color:var(--color-muted);" placeholder="—">
</div>
<div>
<label>Größe (cm)</label>
<input type="number" id="profileGroesse" min="100" max="250" placeholder="—">
</div>
<div>
<label>Gewicht (kg)</label>
<input type="number" id="profileGewicht" min="30" max="300" placeholder="—">
</div>
<div>
<label>Geschlecht</label>
<select id="profileGeschlecht">
<option value="">— keine Angabe —</option>
<option value="WEIBLICH">weiblich</option>
<option value="DIVERS">divers</option>
<option value="MAENNLICH">männlich</option>
</select>
</div>
<div class="full-col">
<label>Neigung</label>
<select id="profileNeigung">
<option value="">— keine Angabe —</option>
<option value="DEVOT">devot</option>
<option value="EHER_DEVOT">eher devot</option>
<option value="SWITCHER">Switcher</option>
<option value="EHER_DOMINANT">eher dominant</option>
<option value="DOMINANT">dominant</option>
<option value="KEINES">keines</option>
</select>
</div>
<div class="full-col">
<label>Beziehungsstatus</label>
<select id="profileBeziehungsstatus">
<option value="">— keine Angabe —</option>
<option value="SINGLE">single</option>
<option value="IN_EINER_BEZIEHUNG">in einer Beziehung</option>
<option value="VERHEIRATET">verheiratet</option>
<option value="IN_EINER_OFFENEN_BEZIEHUNG">in einer offenen Beziehung</option>
<option value="IN_EINER_OFFENEN_EHE">in einer offenen Ehe</option>
</select>
</div>
<div class="full-col">
<label>Über mich</label>
<textarea id="profileBeschreibung" maxlength="600" placeholder="Erzähl etwas über dich…" oninput="updateCharCount()"></textarea>
<div class="char-count" id="charCount">0 / 600</div>
</div>
</div>
<div class="message" id="message"></div>
<button class="full-width" id="saveBtn" onclick="saveProfile()">Profil speichern</button>
<div class="gallery-section-label" style="margin-top:1.5rem;">Vorlieben</div>
<p class="vorlieben-hint">Wähle für jede Vorliebe aus, wie du dazu stehst. Nicht ausgefüllte Einträge werden nicht angezeigt.</p>
<div id="vorliebenSection"><p style="color:var(--color-muted);font-size:0.85rem;">Wird geladen…</p></div>
<div class="message" id="vorliebenMessage" style="margin-top:0.75rem;display:none;"></div>
<button class="full-width" id="saveVorliebenBtn" onclick="saveVorlieben()" style="margin-bottom:1.5rem;">Vorlieben speichern</button>
<div class="gallery-section-label">Meine Bilder</div>
<div class="gallery-upload-row">
<input type="file" id="galleryFile" accept="image/*" multiple style="display:none;" onchange="handleGalleryUpload(this.files)">
<button onclick="document.getElementById('galleryFile').click()">+ Bilder hochladen</button>
<span id="galleryUploadStatus" style="font-size:0.85rem;color:var(--color-muted);"></span>
</div>
<div id="ownGallery"></div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/image-viewer.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
let currentPicture = null;
let currentPictureHq = null;
let pictureDirty = false;
let allGalleryImages = [];
fetch('/login/me')
.then(r => {
if (r.status === 401) { window.location.href = '/login.html'; return null; }
return r.json();
})
.then(user => {
if (!user) return;
if (user.profilePicture) {
currentPicture = user.profilePicture;
renderPicture(currentPicture);
}
// Fill optional profile fields
if (user.geburtsdatum) {
const birth = new Date(user.geburtsdatum);
const today = new Date();
const age = today.getFullYear() - birth.getFullYear()
- (today < new Date(today.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0);
document.getElementById('profileAlter').value = age + ' Jahre';
}
if (user.groesse) document.getElementById('profileGroesse').value = user.groesse;
if (user.gewicht) document.getElementById('profileGewicht').value = user.gewicht;
if (user.geschlecht) document.getElementById('profileGeschlecht').value = user.geschlecht;
if (user.neigung) document.getElementById('profileNeigung').value = user.neigung;
if (user.beziehungsstatus) document.getElementById('profileBeziehungsstatus').value = user.beziehungsstatus;
if (user.beschreibung) {
document.getElementById('profileBeschreibung').value = user.beschreibung;
updateCharCount();
}
myUserId = user.userId;
loadOwnGallery();
loadVorliebenEdit();
})
.catch(() => { window.location.href = '/login.html'; });
// Check if redirected back after email change
if (new URLSearchParams(window.location.search).get('emailChanged')) {
// Already handled by login.html redirect — nothing to do here
}
document.getElementById('picFile').addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
[currentPicture, currentPictureHq] = await Promise.all([toBase64(file, 96), toBase64(file, 1024)]);
pictureDirty = true;
renderPicture(currentPicture);
});
function renderPicture(base64) {
const el = document.getElementById('profilePicDisplay');
el.innerHTML = `<img src="data:image/png;base64,${base64}" alt="Profilbild">`;
}
function updateCharCount() {
const ta = document.getElementById('profileBeschreibung');
const el = document.getElementById('charCount');
const len = ta.value.length;
el.textContent = len + ' / 600';
el.classList.toggle('over', len > 600);
}
async function saveProfile() {
const btn = document.getElementById('saveBtn');
btn.disabled = true;
btn.textContent = 'Wird gespeichert…';
hideMessage();
const beschreibung = document.getElementById('profileBeschreibung').value.trim();
if (beschreibung.length > 600) {
showMessage('Die Beschreibung darf maximal 600 Zeichen lang sein.', 'error');
btn.disabled = false;
btn.textContent = 'Profil speichern';
return;
}
const toNullOrInt = id => {
const v = document.getElementById(id).value;
return v ? parseInt(v, 10) : null;
};
const toNullOrStr = id => {
const v = document.getElementById(id).value;
return v || null;
};
try {
const [picRes, profileRes] = await Promise.all([
pictureDirty ? fetch('/user/me/picture', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ picture: currentPicture, pictureHq: currentPictureHq })
}) : Promise.resolve({ ok: true }),
fetch('/user/me/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
groesse: toNullOrInt('profileGroesse'),
gewicht: toNullOrInt('profileGewicht'),
geschlecht: toNullOrStr('profileGeschlecht'),
neigung: toNullOrStr('profileNeigung'),
beziehungsstatus: toNullOrStr('profileBeziehungsstatus'),
beschreibung: beschreibung || null
})
})
]);
if (picRes.ok && profileRes.ok) {
showMessage('Gespeichert!', 'success');
} else {
showMessage(`Fehler beim Speichern.`, 'error');
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
console.error(err);
} finally {
btn.disabled = false;
btn.textContent = 'Profil speichern';
}
}
// ── Gallery ──
let myUserId = null;
async function loadOwnGallery() {
if (!myUserId) return;
const res = await fetch('/social/profile-images?userId=' + myUserId);
if (!res.ok) return;
const images = await res.json();
renderOwnGallery(images);
}
function renderOwnGallery(images) {
allGalleryImages = images;
const grid = document.getElementById('ownGallery');
if (images.length === 0) {
grid.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;grid-column:1/-1;">Noch keine Bilder hochgeladen.</p>';
return;
}
grid.innerHTML = images.map((img, i) => `
<div class="own-thumb" onclick="openGalleryViewer(${i})" style="cursor:pointer;">
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galerie-Bild">
<button class="own-thumb-delete" onclick="deleteGalleryImage('${img.imageId}', event)" title="Bild löschen">✕</button>
</div>
`).join('');
}
function openGalleryViewer(index) {
imageViewer.open({
images: allGalleryImages.map(img => ({
src: 'data:image/jpeg;base64,' + img.imageData,
id: img.imageId,
likedByMe: false,
likeCount: 0
})),
index,
showLike: false,
showComments: false
});
}
async function deleteGalleryImage(imageId, event) {
event.stopPropagation();
const res = await fetch('/social/profile-images/' + imageId, { method: 'DELETE' });
if (res.ok || res.status === 204) loadOwnGallery();
}
async function handleGalleryUpload(files) {
if (!files || files.length === 0) return;
const status = document.getElementById('galleryUploadStatus');
status.textContent = '0 / ' + files.length + ' hochgeladen…';
let done = 0;
for (const file of Array.from(files)) {
try {
const base64 = await toJpeg(file, 1024);
const res = await fetch('/social/profile-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageData: base64 })
});
if (res.status === 422) {
status.textContent = 'Limit von 20 Bildern erreicht.';
break;
}
} catch (e) {
console.error('Upload-Fehler:', e);
}
done++;
status.textContent = done + ' / ' + files.length + ' hochgeladen…';
}
status.textContent = done + ' Bild' + (done !== 1 ? 'er' : '') + ' hochgeladen.';
document.getElementById('galleryFile').value = '';
loadOwnGallery();
}
function toJpeg(file, max) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > max || h > max) {
if (w >= h) { h = Math.max(1, Math.round(max * h / w)); w = max; }
else { w = Math.max(1, Math.round(max * w / h)); h = max; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
function toBase64(file, max) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > max || h > max) {
if (w >= h) { h = Math.max(1, Math.round(max * h / w)); w = max; }
else { w = Math.max(1, Math.round(max * w / h)); h = max; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/png').split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
// ── Vorlieben ──────────────────────────────────────────────────────────────
const VL_SMILEYS = [
{ value: 'GEHT_GAR_NICHT', img: '/img/vorlieben/verynegative.png', color: '#e53935', title: 'Geht gar nicht' },
{ value: 'EHER_NICHT', img: '/img/vorlieben/negative.png', color: '#fb8c00', title: 'Eher nicht' },
{ value: 'NEUTRAL', img: '/img/vorlieben/neutral.png', color: '#fdd835', title: 'Neutral' },
{ value: 'MAG_ICH', img: '/img/vorlieben/positiv.png', color: '#81c784', title: 'Mag ich' },
{ value: 'UNBEDINGT', img: '/img/vorlieben/verypositiv.png', color: '#2e7d32', title: 'Unbedingt' },
{ value: 'WILL_AUSPROBIEREN', img: '/img/vorlieben/dunno.png', color: '#1e88e5', title: 'Will ich ausprobieren' },
];
let vorliebenRatings = {};
async function loadVorliebenEdit() {
const container = document.getElementById('vorliebenSection');
try {
const [itemsRes, meRes] = await Promise.all([
fetch('/vorlieben/items'),
fetch('/vorlieben/me'),
]);
if (!itemsRes.ok) {
container.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;">Keine Vorlieben konfiguriert.</p>';
return;
}
const kategorien = await itemsRes.json();
vorliebenRatings = meRes.ok ? await meRes.json() : {};
if (!kategorien.length) {
container.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;">Keine Vorlieben konfiguriert.</p>';
return;
}
const smileysHtml = VL_SMILEYS.map(s =>
`<span class="vl-smiley" data-val="${s.value}" title="${s.title}" style="--vl-color:${s.color}"><img src="${s.img}" alt="${s.title}"></span>`
).join('');
// Tab buttons
const tabBtns = kategorien.map((kat, i) =>
`<button class="vl-tab-btn${i === 0 ? ' active' : ''}" onclick="switchVlTab('vlkat-${kat.kategorieId}', this)">${escapeHtml(kat.name)}</button>`
).join('');
// Tab panels
const tabPanels = kategorien.map((kat, i) => `
<div class="vl-tab-panel${i === 0 ? ' active' : ''}" id="vlkat-${kat.kategorieId}">
${kat.items.map(item => `
<div class="vorliebe-row">
<span class="vorliebe-row-name">${escapeHtml(item.name)}</span>
<div class="vorliebe-smileys" data-item-id="${item.itemId}">
${smileysHtml}
</div>
</div>`).join('')}
</div>`).join('');
container.innerHTML = `<div class="vl-tabs">${tabBtns}</div>${tabPanels}`;
// Mark saved values
container.querySelectorAll('.vorliebe-smileys[data-item-id]').forEach(group => {
const saved = vorliebenRatings[group.dataset.itemId];
if (saved) {
const btn = group.querySelector(`.vl-smiley[data-val="${saved}"]`);
if (btn) btn.classList.add('active');
}
});
// Click handler
container.addEventListener('click', e => {
const btn = e.target.closest('.vl-smiley');
if (!btn) return;
const group = btn.closest('.vorliebe-smileys');
const itemId = group.dataset.itemId;
const isActive = btn.classList.contains('active');
// Deselect all in this group
group.querySelectorAll('.vl-smiley').forEach(s => s.classList.remove('active'));
if (!isActive) {
btn.classList.add('active');
vorliebenRatings[itemId] = btn.dataset.val;
} else {
delete vorliebenRatings[itemId];
}
});
} catch (e) {
container.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;">Fehler beim Laden.</p>';
}
}
function switchVlTab(panelId, btn) {
document.querySelectorAll('.vl-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vl-tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(panelId).classList.add('active');
}
async function saveVorlieben() {
const btn = document.getElementById('saveVorliebenBtn');
const msgEl = document.getElementById('vorliebenMessage');
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
// Collect: all known items → current rating or null
const ratings = {};
document.querySelectorAll('#vorliebenSection .vorliebe-smileys[data-item-id]').forEach(group => {
const active = group.querySelector('.vl-smiley.active');
ratings[group.dataset.itemId] = active ? active.dataset.val : null;
});
try {
const res = await fetch('/vorlieben/me', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ratings),
});
msgEl.textContent = res.ok ? 'Vorlieben gespeichert.' : 'Fehler beim Speichern.';
msgEl.className = `message ${res.ok ? 'success' : 'error'}`;
msgEl.style.display = 'block';
setTimeout(() => { msgEl.style.display = 'none'; }, 3000);
} catch (e) {
msgEl.textContent = 'Fehler beim Speichern.'; msgEl.className = 'message error'; msgEl.style.display = 'block';
} finally {
btn.disabled = false; btn.textContent = 'Vorlieben speichern';
}
}
function escapeHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="card">
<img src="/img/icon.png" alt="Logo">
<h1>Bitte melde dich an</h1>
<label for="email">E-Mail</label>
<input type="email" id="email" placeholder="deine@email.de" autocomplete="username" />
<label for="password">Passwort</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="current-password" />
<button class="full-width" id="loginBtn" onclick="login()">Anmelden</button>
<div class="message" id="message"></div>
<p style="text-align:center; margin-top:1.25rem; font-size:0.85rem;">
<a href="/forgot-password.html" style="color:var(--color-primary);">Passwort vergessen?</a>
</p>
<p style="text-align:center; margin-top:0.5rem; font-size:0.85rem;">
Noch kein Konto? <a href="/registration.html" style="color:var(--color-primary);">Registrieren</a>
</p>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
const email = params.get('email');
if (email) {
document.getElementById('email').value = email;
document.getElementById('password').focus();
showMessage('E-Mail-Adresse bestätigt! Du kannst dich jetzt anmelden.', 'success');
} else if (params.get('emailChanged')) {
showMessage('E-Mail-Adresse erfolgreich geändert. Bitte melde dich mit deiner neuen Adresse an.', 'success');
} else if (params.get('accountDeleted')) {
showMessage('Dein Konto wurde gelöscht.', 'success');
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Enter') login();
});
async function login() {
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const btn = document.getElementById('loginBtn');
if (!email || !password) {
showMessage('Bitte E-Mail und Passwort eingeben.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird geprüft…';
hideMessage();
try {
const response = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (response.status === 200) {
const user = await response.json();
sessionStorage.setItem('user', JSON.stringify(user));
window.location.href = '/userhome.html';
} else if (response.status === 204) {
showMessage('E-Mail oder Passwort falsch.', 'error');
btn.disabled = false;
btn.textContent = 'Anmelden';
} else {
showMessage(`Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;
btn.textContent = 'Anmelden';
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Anmelden';
console.error(err);
}
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neues Konto erstellen – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="card">
<img src="/img/icon.png" alt="Logo">
<h1>Neues Konto erstellen</h1>
<label for="name">Name</label>
<input type="text" id="name" placeholder="Dein Name" autocomplete="name" />
<label for="email">E-Mail</label>
<input type="email" id="email" placeholder="deine@email.de" autocomplete="email" />
<label for="geburtsdatum">Geburtsdatum</label>
<input type="date" id="geburtsdatum" autocomplete="bday" />
<label for="password">Passwort</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="new-password" />
<label for="passwordConfirm">Passwort wiederholen</label>
<input type="password" id="passwordConfirm" placeholder="••••••••" autocomplete="new-password" />
<button class="full-width" id="registerBtn" onclick="register()">Registrieren</button>
<div class="message" id="message"></div>
<p style="text-align:center; margin-top:1.5rem; font-size:0.85rem;">
Bereits registriert? <a href="/login.html" style="color:#e94560;">Anmelden</a>
</p>
</div>
<script>
document.addEventListener('keydown', e => {
if (e.key === 'Enter') register();
});
async function register() {
const name = document.getElementById('name').value.trim();
const email = document.getElementById('email').value.trim();
const geburtsdatum = document.getElementById('geburtsdatum').value;
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('passwordConfirm').value;
const btn = document.getElementById('registerBtn');
if (!name || !email || !geburtsdatum || !password || !passwordConfirm) {
showMessage('Bitte alle Felder ausfüllen.', 'error');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
showMessage('Bitte eine gültige E-Mail-Adresse eingeben.', 'error');
return;
}
const today = new Date();
const birth = new Date(geburtsdatum);
const age = today.getFullYear() - birth.getFullYear()
- (today < new Date(today.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0);
if (age < 18) {
showMessage('Du musst mindestens 18 Jahre alt sein, um dich zu registrieren.', 'error');
return;
}
if (password !== passwordConfirm) {
showMessage('Die Passwörter stimmen nicht überein.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird verarbeitet…';
hideMessage();
try {
const response = await fetch('/registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password, geburtsdatum })
});
if (response.status === 202) {
window.location.href = `/activate.html?email=${encodeURIComponent(email)}`;
} else if (response.status === 422) {
const body = await response.text();
const msgs = {
'EMAIL_FORMAT': 'Bitte eine gültige E-Mail-Adresse eingeben.',
'PASSWORT_ZU_KURZ':'Das Passwort muss mindestens 8 Zeichen lang sein.',
'ALTER': 'Du musst mindestens 18 Jahre alt sein, um dich zu registrieren.'
};
showMessage(msgs[body] || 'Ungültige Eingabe.', 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
} else if (response.status === 400) {
showMessage('Diese E-Mail-Adresse ist bereits registriert.', 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
} else if (response.status === 409) {
showMessage('Dieser Name ist bereits vergeben.', 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
} else {
showMessage(`Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
console.error(err);
}
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neues Passwort – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
align-items: center;
justify-content: center;
}
.overlay.active {
display: flex;
}
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
max-width: 340px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
text-align: center;
}
.modal p {
color: var(--color-text);
font-size: 0.95rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="card">
<img src="icon.png" alt="Logo">
<h1>Neues Passwort</h1>
<p class="subtitle">Gib dein neues Passwort ein.</p>
<label for="password">Neues Passwort</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="new-password" />
<label for="passwordConfirm">Passwort wiederholen</label>
<input type="password" id="passwordConfirm" placeholder="••••••••" autocomplete="new-password" />
<button class="full-width" id="submitBtn" onclick="submit()">Passwort speichern</button>
<div class="message" id="message"></div>
</div>
<div class="overlay" id="overlay">
<div class="modal">
<p>Dein Passwort wurde erfolgreich geändert. Du kannst dich jetzt anmelden.</p>
<button class="full-width" onclick="goToLogin()">Zum Login</button>
</div>
</div>
<script>
document.addEventListener('keydown', e => {
if (e.key === 'Enter') submit();
});
function getToken() {
return new URLSearchParams(window.location.search).get('token');
}
document.addEventListener('DOMContentLoaded', () => {
if (!getToken()) {
showMessage('Ungültiger oder fehlender Reset-Link.', 'error');
document.getElementById('submitBtn').disabled = true;
}
});
async function submit() {
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('passwordConfirm').value;
const btn = document.getElementById('submitBtn');
const token = getToken();
if (!password || !passwordConfirm) {
showMessage('Bitte beide Felder ausfüllen.', 'error');
return;
}
if (password !== passwordConfirm) {
showMessage('Die Passwörter stimmen nicht überein.', 'error');
return;
}
if (!token) {
showMessage('Ungültiger Reset-Link.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird gespeichert…';
hideMessage();
try {
const response = await fetch('/password-reset/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password })
});
if (response.ok) {
document.getElementById('overlay').classList.add('active');
} else {
showMessage('Der Reset-Link ist ungültig oder abgelaufen.', 'error');
btn.disabled = false;
btn.textContent = 'Passwort speichern';
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Passwort speichern';
console.error(err);
}
}
function goToLogin() {
window.location.href = '/login.html';
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home – xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.game-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem;
margin-top: 1.5rem;
}
.game-card {
background: var(--color-secondary);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.game-card-icon { font-size: 2rem; line-height: 1; }
.game-card-title { font-size: 1.1rem; font-weight: 700; margin: 0; }
.game-card-desc { font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; flex: 1; }
.game-card-btn { margin-top: 0.25rem; width: auto; align-self: flex-start; padding: 0.5rem 1.25rem; }
.welcome { font-size: 0.95rem; color: var(--color-muted); margin: 0.25rem 0 0; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin:0 0 0.15rem;">Home</h1>
<p class="welcome" id="greeting"></p>
<div class="game-grid">
<div class="game-card">
<div class="game-card-icon">♡</div>
<h2 class="game-card-title">Vanilla Game</h2>
<p class="game-card-desc">
Entdecke spielerische Rollenspiele und Aufgaben in einem entspannten Rahmen.
Ideal für den Einstieg – ohne Regeln, nur Spaß zu zweit oder in der Gruppe.
</p>
<a href="/games/vanilla/sessionvanilla.html"><button class="game-card-btn">Neue Session starten</button></a>
</div>
<div class="game-card">
<div class="game-card-icon">◆</div>
<h2 class="game-card-title">BDSM Game</h2>
<p class="game-card-desc">
Tauche ein in strukturierte Sessions mit Aufgaben, Toys und klaren Rollen.
Definiere Grenzen, vergib Aufgaben und erlebe intensive Momente mit deinem Partner.
</p>
<a href="/games/bdsm/neubdsm.html"><button class="game-card-btn">Neue Session starten</button></a>
</div>
<div class="game-card">
<div class="game-card-icon">⊗</div>
<h2 class="game-card-title">Chastity Game</h2>
<p class="game-card-desc">
Erlebe Keuschheit auf eine neue Art: Kartenbasierte Locks, Keyholder-System,
Community-Abstimmungen und tägliche Verifizierungen machen jedes Lock einzigartig.
</p>
<a href="/games/chastity/neulock.html"><button class="game-card-btn">Neues Lock erstellen</button></a>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
fetch('/login/me')
.then(r => {
if (r.status === 401) { window.location.href = '/login.html'; return null; }
return r.json();
})
.then(user => {
if (user) document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
})
.catch(() => { window.location.href = '/login.html'; });
</script>
</body>
</html>

BIN
src/main/resources/xxx.jks Normal file

Binary file not shown.