Fehlerbehebung beim Timelock, Neues Feature Keyholder finden

This commit is contained in:
2026-03-26 22:45:37 +01:00
parent 03118d339a
commit 0baf667ee7
47 changed files with 5326 additions and 2172 deletions

View File

@@ -1,5 +1,5 @@
#Wed Mar 25 21:32:43 CET 2026
#Thu Mar 26 16:50:07 CET 2026
display=\:0
host=mario-mint
process-id=43084
process-id=121337
user=mario

View File

@@ -819,3 +819,287 @@ Binding(CTRL+R,
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.debug.ui.console,,,system)
!ENTRY org.springframework.tooling.boot.ls 1 0 2026-03-26 00:13:15.217
!MESSAGE DelegatingStreamConnectionProvider - Stopping Boot LS
!SESSION 2026-03-26 07:50:09.934 -----------------------------------------------
eclipse.buildId=4.39.0.20260305-0817
java.version=21.0.6
java.vendor=Eclipse Adoptium
BootLoader constants: OS=linux, ARCH=x86_64, WS=gtk, NL=de_DE
Framework arguments: -product org.eclipse.epp.package.java.product
Command-line arguments: -os linux -ws gtk -arch x86_64 -clean -product org.eclipse.epp.package.java.product
!ENTRY ch.qos.logback.classic 1 0 2026-03-26 07:50:11.470
!MESSAGE Activated before the state location was initialized. Retry after the state location is initialized.
!ENTRY ch.qos.logback.classic 1 0 2026-03-26 07:50:14.978
!MESSAGE Logback config file: /home/mario/Workspaces/xxx-thegame/.metadata/.plugins/org.eclipse.m2e.logback/logback.2.7.101.20251017-1242.xml
!ENTRY org.eclipse.ui 2 0 2026-03-26 07:50:15.138
!MESSAGE Warnings while parsing the commands from the 'org.eclipse.ui.commands' and 'org.eclipse.ui.actionDefinitions' extension points.
!SUBENTRY 1 org.eclipse.ui 2 0 2026-03-26 07:50:15.138
!MESSAGE Commands should really have a category: plug-in='org.springframework.tooling.boot.ls', id='spring.initializr.addStarters', categoryId='org.eclipse.lsp4e.commandCategory'
!ENTRY org.eclipse.ui 2 0 2026-03-26 07:50:15.273
!MESSAGE Warnings while parsing the commands from the 'org.eclipse.ui.commands' and 'org.eclipse.ui.actionDefinitions' extension points.
!SUBENTRY 1 org.eclipse.ui 2 0 2026-03-26 07:50:15.273
!MESSAGE Commands should really have a category: plug-in='org.springframework.tooling.boot.ls', id='spring.initializr.addStarters', categoryId='org.eclipse.lsp4e.commandCategory'
!ENTRY org.eclipse.debug.core 4 125 2026-03-26 07:52:41.947
!MESSAGE Error logged from Debug Core:
!STACK 0
java.io.IOException: Stream closed
at java.base/java.io.BufferedInputStream.getBufIfOpen(BufferedInputStream.java:188)
at java.base/java.io.BufferedInputStream.read1(BufferedInputStream.java:343)
at java.base/java.io.BufferedInputStream.implRead(BufferedInputStream.java:420)
at java.base/java.io.BufferedInputStream.read(BufferedInputStream.java:405)
at java.base/java.io.FilterInputStream.read(FilterInputStream.java:95)
at org.eclipse.debug.internal.core.OutputStreamMonitor.internalRead(OutputStreamMonitor.java:235)
at org.eclipse.debug.internal.core.OutputStreamMonitor.read(OutputStreamMonitor.java:211)
at java.base/java.lang.Thread.run(Thread.java:1583)
!ENTRY org.eclipse.jface 2 0 2026-03-26 07:52:44.356
!MESSAGE Keybinding conflicts occurred. They may interfere with normal accelerator operation.
!SUBENTRY 1 org.eclipse.jface 2 0 2026-03-26 07:52:44.356
!MESSAGE A conflict occurred for CTRL+SHIFT+T:
Binding(CTRL+SHIFT+T,
ParameterizedCommand(Command(org.eclipse.jdt.ui.navigate.open.type,Open Type,
Open a type in a Java editor,
Category(org.eclipse.ui.category.navigate,Navigate,null,true),
WorkbenchHandlerServiceHandler("org.eclipse.jdt.ui.navigate.open.type"),
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.ui.contexts.window,,,system)
Binding(CTRL+SHIFT+T,
ParameterizedCommand(Command(org.eclipse.lsp4e.symbolInWorkspace,Go to Symbol in Workspace,
,
Category(org.eclipse.lsp4e.category,Language Servers,null,true),
WorkbenchHandlerServiceHandler("org.eclipse.lsp4e.symbolInWorkspace"),
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.ui.contexts.window,,,system)
!ENTRY org.springframework.tooling.boot.ls 1 0 2026-03-26 10:28:33.777
!MESSAGE DelegatingStreamConnectionProvider - Stopping Boot LS
!SESSION 2026-03-26 10:32:16.891 -----------------------------------------------
eclipse.buildId=4.39.0.20260305-0817
java.version=21.0.6
java.vendor=Eclipse Adoptium
BootLoader constants: OS=linux, ARCH=x86_64, WS=gtk, NL=de_DE
Framework arguments: -product org.eclipse.epp.package.java.product
Command-line arguments: -os linux -ws gtk -arch x86_64 -clean -product org.eclipse.epp.package.java.product
!ENTRY ch.qos.logback.classic 1 0 2026-03-26 10:32:18.391
!MESSAGE Activated before the state location was initialized. Retry after the state location is initialized.
!ENTRY ch.qos.logback.classic 1 0 2026-03-26 10:32:21.250
!MESSAGE Logback config file: /home/mario/Workspaces/xxx-thegame/.metadata/.plugins/org.eclipse.m2e.logback/logback.2.7.101.20251017-1242.xml
!ENTRY org.eclipse.ui 2 0 2026-03-26 10:32:21.401
!MESSAGE Warnings while parsing the commands from the 'org.eclipse.ui.commands' and 'org.eclipse.ui.actionDefinitions' extension points.
!SUBENTRY 1 org.eclipse.ui 2 0 2026-03-26 10:32:21.401
!MESSAGE Commands should really have a category: plug-in='org.springframework.tooling.boot.ls', id='spring.initializr.addStarters', categoryId='org.eclipse.lsp4e.commandCategory'
!ENTRY org.eclipse.ui 2 0 2026-03-26 10:32:21.529
!MESSAGE Warnings while parsing the commands from the 'org.eclipse.ui.commands' and 'org.eclipse.ui.actionDefinitions' extension points.
!SUBENTRY 1 org.eclipse.ui 2 0 2026-03-26 10:32:21.529
!MESSAGE Commands should really have a category: plug-in='org.springframework.tooling.boot.ls', id='spring.initializr.addStarters', categoryId='org.eclipse.lsp4e.commandCategory'
!ENTRY org.eclipse.jface 2 0 2026-03-26 10:32:54.845
!MESSAGE Keybinding conflicts occurred. They may interfere with normal accelerator operation.
!SUBENTRY 1 org.eclipse.jface 2 0 2026-03-26 10:32:54.845
!MESSAGE A conflict occurred for CTRL+SHIFT+T:
Binding(CTRL+SHIFT+T,
ParameterizedCommand(Command(org.eclipse.jdt.ui.navigate.open.type,Open Type,
Open a type in a Java editor,
Category(org.eclipse.ui.category.navigate,Navigate,null,true),
WorkbenchHandlerServiceHandler("org.eclipse.jdt.ui.navigate.open.type"),
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.ui.contexts.window,,,system)
Binding(CTRL+SHIFT+T,
ParameterizedCommand(Command(org.eclipse.lsp4e.symbolInWorkspace,Go to Symbol in Workspace,
,
Category(org.eclipse.lsp4e.category,Language Servers,null,true),
WorkbenchHandlerServiceHandler("org.eclipse.lsp4e.symbolInWorkspace"),
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.ui.contexts.window,,,system)
!ENTRY org.springframework.tooling.boot.ls 1 0 2026-03-26 10:47:57.323
!MESSAGE DelegatingStreamConnectionProvider - Stopping Boot LS
!SESSION 2026-03-26 11:31:28.220 -----------------------------------------------
eclipse.buildId=4.39.0.20260305-0817
java.version=21.0.6
java.vendor=Eclipse Adoptium
BootLoader constants: OS=linux, ARCH=x86_64, WS=gtk, NL=de_DE
Framework arguments: -product org.eclipse.epp.package.java.product
Command-line arguments: -os linux -ws gtk -arch x86_64 -clean -product org.eclipse.epp.package.java.product
!ENTRY ch.qos.logback.classic 1 0 2026-03-26 11:31:29.790
!MESSAGE Activated before the state location was initialized. Retry after the state location is initialized.
!ENTRY ch.qos.logback.classic 1 0 2026-03-26 11:31:36.957
!MESSAGE Logback config file: /home/mario/Workspaces/xxx-thegame/.metadata/.plugins/org.eclipse.m2e.logback/logback.2.7.101.20251017-1242.xml
!ENTRY org.eclipse.ui 2 0 2026-03-26 11:31:37.090
!MESSAGE Warnings while parsing the commands from the 'org.eclipse.ui.commands' and 'org.eclipse.ui.actionDefinitions' extension points.
!SUBENTRY 1 org.eclipse.ui 2 0 2026-03-26 11:31:37.090
!MESSAGE Commands should really have a category: plug-in='org.springframework.tooling.boot.ls', id='spring.initializr.addStarters', categoryId='org.eclipse.lsp4e.commandCategory'
!ENTRY org.eclipse.ui 2 0 2026-03-26 11:31:37.210
!MESSAGE Warnings while parsing the commands from the 'org.eclipse.ui.commands' and 'org.eclipse.ui.actionDefinitions' extension points.
!SUBENTRY 1 org.eclipse.ui 2 0 2026-03-26 11:31:37.210
!MESSAGE Commands should really have a category: plug-in='org.springframework.tooling.boot.ls', id='spring.initializr.addStarters', categoryId='org.eclipse.lsp4e.commandCategory'
!SESSION 2026-03-26 16:50:01.920 -----------------------------------------------
eclipse.buildId=4.39.0.20260305-0817
java.version=21.0.6
java.vendor=Eclipse Adoptium
BootLoader constants: OS=linux, ARCH=x86_64, WS=gtk, NL=de_DE
Framework arguments: -product org.eclipse.epp.package.java.product
Command-line arguments: -os linux -ws gtk -arch x86_64 -clean -product org.eclipse.epp.package.java.product
!ENTRY ch.qos.logback.classic 1 0 2026-03-26 16:50:03.384
!MESSAGE Activated before the state location was initialized. Retry after the state location is initialized.
!ENTRY ch.qos.logback.classic 1 0 2026-03-26 16:50:07.703
!MESSAGE Logback config file: /home/mario/Workspaces/xxx-thegame/.metadata/.plugins/org.eclipse.m2e.logback/logback.2.7.101.20251017-1242.xml
!ENTRY org.eclipse.ui 2 0 2026-03-26 16:50:07.810
!MESSAGE Warnings while parsing the commands from the 'org.eclipse.ui.commands' and 'org.eclipse.ui.actionDefinitions' extension points.
!SUBENTRY 1 org.eclipse.ui 2 0 2026-03-26 16:50:07.810
!MESSAGE Commands should really have a category: plug-in='org.springframework.tooling.boot.ls', id='spring.initializr.addStarters', categoryId='org.eclipse.lsp4e.commandCategory'
!ENTRY org.eclipse.ui 2 0 2026-03-26 16:50:07.943
!MESSAGE Warnings while parsing the commands from the 'org.eclipse.ui.commands' and 'org.eclipse.ui.actionDefinitions' extension points.
!SUBENTRY 1 org.eclipse.ui 2 0 2026-03-26 16:50:07.943
!MESSAGE Commands should really have a category: plug-in='org.springframework.tooling.boot.ls', id='spring.initializr.addStarters', categoryId='org.eclipse.lsp4e.commandCategory'
!ENTRY org.eclipse.lsp4e 2 0 2026-03-26 16:55:47.890
!MESSAGE Javadoc unavailable. Failed to obtain it.
!STACK 0
java.lang.InterruptedException
at java.base/java.util.concurrent.CompletableFuture.reportGet(CompletableFuture.java:386)
at java.base/java.util.concurrent.CompletableFuture.get(CompletableFuture.java:2096)
at org.eclipse.lsp4e.jdt.LSJavaHoverProvider.getHoverInfo2(LSJavaHoverProvider.java:66)
at org.eclipse.jdt.internal.ui.text.java.hover.BestMatchHover.getHoverInfo2(BestMatchHover.java:165)
at org.eclipse.jdt.internal.ui.text.java.hover.BestMatchHover.getHoverInfo2(BestMatchHover.java:131)
at org.eclipse.jdt.internal.ui.text.java.hover.JavaEditorTextHoverProxy.getHoverInfo2(JavaEditorTextHoverProxy.java:89)
at org.eclipse.jface.text.TextViewerHoverManager$1.run(TextViewerHoverManager.java:155)
!ENTRY org.eclipse.jface 2 0 2026-03-26 19:21:16.922
!MESSAGE Keybinding conflicts occurred. They may interfere with normal accelerator operation.
!SUBENTRY 1 org.eclipse.jface 2 0 2026-03-26 19:21:16.922
!MESSAGE A conflict occurred for CTRL+SHIFT+T:
Binding(CTRL+SHIFT+T,
ParameterizedCommand(Command(org.eclipse.jdt.ui.navigate.open.type,Open Type,
Open a type in a Java editor,
Category(org.eclipse.ui.category.navigate,Navigate,null,true),
WorkbenchHandlerServiceHandler("org.eclipse.jdt.ui.navigate.open.type"),
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.ui.contexts.window,,,system)
Binding(CTRL+SHIFT+T,
ParameterizedCommand(Command(org.eclipse.lsp4e.symbolInWorkspace,Go to Symbol in Workspace,
,
Category(org.eclipse.lsp4e.category,Language Servers,null,true),
WorkbenchHandlerServiceHandler("org.eclipse.lsp4e.symbolInWorkspace"),
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.ui.contexts.window,,,system)
!ENTRY org.eclipse.jface 2 0 2026-03-26 20:13:11.892
!MESSAGE Keybinding conflicts occurred. They may interfere with normal accelerator operation.
!SUBENTRY 1 org.eclipse.jface 2 0 2026-03-26 20:13:11.892
!MESSAGE A conflict occurred for CTRL+R:
Binding(CTRL+R,
ParameterizedCommand(Command(org.eclipse.debug.ui.commands.RunToLine,Run to Line,
Resume and break when execution reaches the current line,
Category(org.eclipse.debug.ui.category.run,Run/Debug,Run/Debug command category,true),
WorkbenchHandlerServiceHandler("org.eclipse.debug.ui.commands.RunToLine"),
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.debug.ui.debugging,,,system)
Binding(CTRL+R,
ParameterizedCommand(Command(org.springframework.ide.eclipse.boot.restart.commands.restart,Trigger Restart,
Restart Spring Boot Application,
Category(org.eclipse.debug.ui.category.run,Run/Debug,Run/Debug command category,true),
WorkbenchHandlerServiceHandler("org.springframework.ide.eclipse.boot.restart.commands.restart"),
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.debug.ui.console,,,system)
!ENTRY Activator 4 0 2026-03-26 22:43:57.249
!MESSAGE NullPointerException: Cannot invoke "org.eclipse.debug.core.ILaunchConfiguration.getAttribute(String, boolean)" because "conf" is null
!STACK 0
java.lang.NullPointerException: Cannot invoke "org.eclipse.debug.core.ILaunchConfiguration.getAttribute(String, boolean)" because "conf" is null
at org.springframework.ide.eclipse.boot.launch.BootLaunchConfigurationDelegate.getEnableJmx(BootLaunchConfigurationDelegate.java:426)
at org.springframework.ide.eclipse.boot.dash.BootDashActivator$1.stateChanged(BootDashActivator.java:195)
at org.springframework.ide.eclipse.boot.dash.BootDashActivator$1.stateChanged(BootDashActivator.java:1)
at org.springframework.ide.eclipse.boot.dash.util.RunStateTracker.updateOwnerStatesAndFireEvents(RunStateTracker.java:216)
at org.springframework.ide.eclipse.boot.dash.util.RunStateTracker.processCreated(RunStateTracker.java:273)
at org.springframework.ide.eclipse.boot.util.ProcessTracker.processCreated(ProcessTracker.java:105)
at org.springframework.ide.eclipse.boot.util.ProcessTracker.handleDebugEvent(ProcessTracker.java:71)
at org.springframework.ide.eclipse.boot.util.ProcessTracker$1.handleDebugEvents(ProcessTracker.java:48)
at org.eclipse.debug.core.DebugPlugin$EventNotifier.run(DebugPlugin.java:1422)
at org.eclipse.core.runtime.SafeRunner.run(SafeRunner.java:47)
at org.eclipse.debug.core.DebugPlugin$EventNotifier.dispatch(DebugPlugin.java:1456)
at org.eclipse.debug.core.DebugPlugin$EventDispatchJob.run(DebugPlugin.java:521)
at org.eclipse.core.internal.jobs.Worker.run(Worker.java:63)
!ENTRY Activator 4 0 2026-03-26 22:43:57.250
!MESSAGE NullPointerException: Cannot invoke "org.eclipse.debug.core.ILaunchConfiguration.getAttribute(String, String)" because "conf" is null
!STACK 0
java.lang.NullPointerException: Cannot invoke "org.eclipse.debug.core.ILaunchConfiguration.getAttribute(String, String)" because "conf" is null
at org.springframework.ide.eclipse.boot.launch.AbstractBootLaunchConfigurationDelegate.getProjectName(AbstractBootLaunchConfigurationDelegate.java:322)
at org.springframework.ide.eclipse.boot.launch.AbstractBootLaunchConfigurationDelegate.getProject(AbstractBootLaunchConfigurationDelegate.java:307)
at org.springframework.ide.eclipse.boot.dash.BootDashActivator$1.stateChanged(BootDashActivator.java:196)
at org.springframework.ide.eclipse.boot.dash.BootDashActivator$1.stateChanged(BootDashActivator.java:1)
at org.springframework.ide.eclipse.boot.dash.util.RunStateTracker.updateOwnerStatesAndFireEvents(RunStateTracker.java:216)
at org.springframework.ide.eclipse.boot.dash.util.RunStateTracker.processCreated(RunStateTracker.java:273)
at org.springframework.ide.eclipse.boot.util.ProcessTracker.processCreated(ProcessTracker.java:105)
at org.springframework.ide.eclipse.boot.util.ProcessTracker.handleDebugEvent(ProcessTracker.java:71)
at org.springframework.ide.eclipse.boot.util.ProcessTracker$1.handleDebugEvents(ProcessTracker.java:48)
at org.eclipse.debug.core.DebugPlugin$EventNotifier.run(DebugPlugin.java:1422)
at org.eclipse.core.runtime.SafeRunner.run(SafeRunner.java:47)
at org.eclipse.debug.core.DebugPlugin$EventNotifier.dispatch(DebugPlugin.java:1456)
at org.eclipse.debug.core.DebugPlugin$EventDispatchJob.run(DebugPlugin.java:521)
at org.eclipse.core.internal.jobs.Worker.run(Worker.java:63)
!ENTRY Activator 4 0 2026-03-26 22:43:57.399
!MESSAGE NullPointerException: Cannot invoke "org.eclipse.debug.core.ILaunchConfiguration.getAttribute(String, boolean)" because "conf" is null
!STACK 0
java.lang.NullPointerException: Cannot invoke "org.eclipse.debug.core.ILaunchConfiguration.getAttribute(String, boolean)" because "conf" is null
at org.springframework.ide.eclipse.boot.launch.BootLaunchConfigurationDelegate.getEnableJmx(BootLaunchConfigurationDelegate.java:426)
at org.springframework.ide.eclipse.boot.dash.BootDashActivator$1.stateChanged(BootDashActivator.java:195)
at org.springframework.ide.eclipse.boot.dash.BootDashActivator$1.stateChanged(BootDashActivator.java:1)
at org.springframework.ide.eclipse.boot.dash.util.RunStateTracker.updateOwnerStatesAndFireEvents(RunStateTracker.java:216)
at org.springframework.ide.eclipse.boot.dash.util.RunStateTracker.processCreated(RunStateTracker.java:273)
at org.springframework.ide.eclipse.boot.util.ProcessTracker.processCreated(ProcessTracker.java:105)
at org.springframework.ide.eclipse.boot.util.ProcessTracker.handleDebugEvent(ProcessTracker.java:71)
at org.springframework.ide.eclipse.boot.util.ProcessTracker$1.handleDebugEvents(ProcessTracker.java:48)
at org.eclipse.debug.core.DebugPlugin$EventNotifier.run(DebugPlugin.java:1422)
at org.eclipse.core.runtime.SafeRunner.run(SafeRunner.java:47)
at org.eclipse.debug.core.DebugPlugin$EventNotifier.dispatch(DebugPlugin.java:1456)
at org.eclipse.debug.core.DebugPlugin$EventDispatchJob.run(DebugPlugin.java:521)
at org.eclipse.core.internal.jobs.Worker.run(Worker.java:63)
!ENTRY Activator 4 0 2026-03-26 22:43:57.399
!MESSAGE NullPointerException: Cannot invoke "org.eclipse.debug.core.ILaunchConfiguration.getAttribute(String, String)" because "conf" is null
!STACK 0
java.lang.NullPointerException: Cannot invoke "org.eclipse.debug.core.ILaunchConfiguration.getAttribute(String, String)" because "conf" is null
at org.springframework.ide.eclipse.boot.launch.AbstractBootLaunchConfigurationDelegate.getProjectName(AbstractBootLaunchConfigurationDelegate.java:322)
at org.springframework.ide.eclipse.boot.launch.AbstractBootLaunchConfigurationDelegate.getProject(AbstractBootLaunchConfigurationDelegate.java:307)
at org.springframework.ide.eclipse.boot.dash.BootDashActivator$1.stateChanged(BootDashActivator.java:196)
at org.springframework.ide.eclipse.boot.dash.BootDashActivator$1.stateChanged(BootDashActivator.java:1)
at org.springframework.ide.eclipse.boot.dash.util.RunStateTracker.updateOwnerStatesAndFireEvents(RunStateTracker.java:216)
at org.springframework.ide.eclipse.boot.dash.util.RunStateTracker.processCreated(RunStateTracker.java:273)
at org.springframework.ide.eclipse.boot.util.ProcessTracker.processCreated(ProcessTracker.java:105)
at org.springframework.ide.eclipse.boot.util.ProcessTracker.handleDebugEvent(ProcessTracker.java:71)
at org.springframework.ide.eclipse.boot.util.ProcessTracker$1.handleDebugEvents(ProcessTracker.java:48)
at org.eclipse.debug.core.DebugPlugin$EventNotifier.run(DebugPlugin.java:1422)
at org.eclipse.core.runtime.SafeRunner.run(SafeRunner.java:47)
at org.eclipse.debug.core.DebugPlugin$EventNotifier.dispatch(DebugPlugin.java:1456)
at org.eclipse.debug.core.DebugPlugin$EventDispatchJob.run(DebugPlugin.java:521)
at org.eclipse.core.internal.jobs.Worker.run(Worker.java:63)

View File

@@ -1,7 +1,7 @@
[ {
"version" : "9.5.0-20260325015243+0000",
"buildTime" : "20260325015243+0000",
"commitId" : "627839c6a3532eeab60306fd7b577d6f1c866ece",
"version" : "9.5.0-20260326015913+0000",
"buildTime" : "20260326015913+0000",
"commitId" : "b62b56136fe3f28a01c3e35f77694c3d5af75916",
"current" : false,
"snapshot" : true,
"nightly" : false,
@@ -10,15 +10,15 @@
"rcFor" : "",
"milestoneFor" : "",
"broken" : false,
"downloadUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.5.0-20260325015243+0000-bin.zip",
"checksumUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.5.0-20260325015243+0000-bin.zip.sha256",
"checksum" : "f031a868b2d9707fe07c78ad0888d4be5d6bb87e3f8a118ffbf3b0b497c32922",
"wrapperChecksumUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.5.0-20260325015243+0000-wrapper.jar.sha256",
"wrapperChecksum" : "f307680272dffdb8e636f1169adfbf693513005c80aa06e8d381f20390a06e6a"
"downloadUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.5.0-20260326015913+0000-bin.zip",
"checksumUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.5.0-20260326015913+0000-bin.zip.sha256",
"checksum" : "ace6a98f3a565a82cd108c6a115f64837cd5bb8e95d563c6e2dfb884ea3a8fe5",
"wrapperChecksumUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.5.0-20260326015913+0000-wrapper.jar.sha256",
"wrapperChecksum" : "497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7"
}, {
"version" : "9.6.0-20260325005438+0000",
"buildTime" : "20260325005438+0000",
"commitId" : "4a2f60ed3e5db8c8eadc899d49da7c6abf7140ee",
"version" : "9.6.0-20260326003843+0000",
"buildTime" : "20260326003843+0000",
"commitId" : "f6b5714b236ea05298517d966a339045da81a5ee",
"current" : false,
"snapshot" : true,
"nightly" : true,
@@ -27,10 +27,10 @@
"rcFor" : "",
"milestoneFor" : "",
"broken" : false,
"downloadUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.6.0-20260325005438+0000-bin.zip",
"checksumUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.6.0-20260325005438+0000-bin.zip.sha256",
"checksum" : "1419ec3a9f2e924772188c709cd681d073c7c821dbd82beb9a6e8f6b78f7723b",
"wrapperChecksumUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.6.0-20260325005438+0000-wrapper.jar.sha256",
"downloadUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.6.0-20260326003843+0000-bin.zip",
"checksumUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.6.0-20260326003843+0000-bin.zip.sha256",
"checksum" : "9d70bda347d4cdbc4fc8ce8550d53ce5d1b2add847f4720e8543ff6c74c322b8",
"wrapperChecksumUrl" : "https://services.gradle.org/distributions-snapshots/gradle-9.6.0-20260326003843+0000-wrapper.jar.sha256",
"wrapperChecksum" : "f307680272dffdb8e636f1169adfbf693513005c80aa06e8d381f20390a06e6a"
}, {
"version" : "9.4.1",

File diff suppressed because one or more lines are too long

View File

@@ -2,14 +2,16 @@
<typeInfoHistroy>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.aufgaben{DefaultFiller.java[DefaultFiller" modifiers="1" timestamp="1772437686926"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.aufgaben.controller{FillerController.java[FillerController" modifiers="1" timestamp="1772385528555"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.timelock{TimeLockService.java[TimeLockService" modifiers="1" timestamp="1774441060175"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.ttlock{TTLockService.java[TTLockService" modifiers="1" timestamp="1774375173709"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.user{UserRepository.java[UserRepository" modifiers="513" timestamp="1774016609131"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.ttlock{TTLockUserConfigEntity.java[TTLockUserConfigEntity" modifiers="1" timestamp="1774425822887"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.keyholder{KeyholderNotificationEntity.java[KeyholderNotificationEntity" modifiers="1" timestamp="1774386563354"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.ttlock{TTLockCallback.java[TTLockCallback" modifiers="1" timestamp="1774387007874"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.cardlock{CardLockController.java[CardLockController" modifiers="1" timestamp="1774422277518"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.cardlock{CardLockController.java[CardLockController" modifiers="1" timestamp="1774508936868"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.cardlock{CardLockEntity.java[CardLockEntity" modifiers="1" timestamp="1774171624571"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.timelock{TimeLockController.java[TimeLockController" modifiers="1" timestamp="1774388746804"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.common{BaseLockEntity.java[BaseLockEntity" modifiers="1" timestamp="1774369125472"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.timelock{TimeLockController.java[TimeLockController" modifiers="1" timestamp="1774511010754"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.user{UserController.java[UserController" modifiers="1" timestamp="1774425860838"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.common{BaseLockEntity.java[BaseLockEntity" modifiers="1" timestamp="1774476260239"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.timelock{TimeLockService.java[TimeLockService" modifiers="1" timestamp="1774510681823"/>
<typeInfo handle="=xxxthegame/src\/main\/java=/gradle_scope=/main=/=/gradle_used_by_scope=/main,test=/&lt;de.oaa.xxx.games.chastity.common{BaseLockService.java[BaseLockService" modifiers="1025" timestamp="1774508825104"/>
</typeInfoHistroy>

View File

@@ -28,4 +28,5 @@
<fullyQualifiedTypeName name="java.util.Optional"/>
<fullyQualifiedTypeName name="de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigRepository"/>
<fullyQualifiedTypeName name="de.oaa.xxx.games.chastity.ttlock.TTLockCallback"/>
<fullyQualifiedTypeName name="de.oaa.xxx.games.chastity.cardlock.CardLockService"/>
</qualifiedTypeNameHistroy>

View File

@@ -9,3 +9,7 @@
2026-03-24 11:26:24,107 [Worker-2: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is up-to-date. Trying to read.
2026-03-25 07:26:14,133 [Worker-5: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is out-of-date. Trying to update.
2026-03-25 21:32:47,427 [Worker-8: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is up-to-date. Trying to read.
2026-03-26 07:50:17,235 [Worker-7: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is out-of-date. Trying to update.
2026-03-26 10:32:24,614 [Worker-1: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is up-to-date. Trying to read.
2026-03-26 11:31:40,355 [Worker-8: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is up-to-date. Trying to read.
2026-03-26 16:50:11,098 [Worker-7: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is up-to-date. Trying to read.

View File

@@ -1,3 +1,3 @@
#Wed Mar 25 21:32:43 CET 2026
#Thu Mar 26 16:50:07 CET 2026
org.eclipse.core.runtime=2
org.eclipse.platform=4.39.0.v20260226-0420

View File

@@ -65,7 +65,9 @@ public class SecurityConfig {
.requestMatchers("/admin.html").authenticated()
.requestMatchers("/communityvotes.html").authenticated()
.requestMatchers("/keyholder.html").authenticated()
.requestMatchers("/keyholder-finden.html").authenticated()
.requestMatchers("/meine-locks.html").authenticated()
.requestMatchers("/entdecken-vorlagen.html").authenticated()
.requestMatchers("/unlock-history.html").authenticated()
.requestMatchers("/einladungen.html").authenticated()
.requestMatchers("/joinlock.html").authenticated()

View File

@@ -47,16 +47,14 @@ public enum CardEnum {
CUM {
@Override
public Card get() {
// TODO Auto-generated method stub
return null;
return new CumCard();
}
},
CUM_IN_CAGE {
@Override
public Card get() {
// TODO Auto-generated method stub
return null;
return new CumInCageCard();
}
};

View File

@@ -156,6 +156,9 @@ public class CardLockController {
return ResponseEntity.badRequest().build();
var lockee = lockeeOpt.get();
if (cardLockServiceFactory.hasActiveLock(req.lockeeUserId()))
return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));
LocalDateTime now = LocalDateTime.now();
CardLockEntity lock = new CardLockEntity();
lock.setName(req.name());
@@ -194,7 +197,7 @@ public class CardLockController {
}
// Self-lockee path (existing behavior)
if (cardlockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(myId))
if (cardLockServiceFactory.hasActiveLock(myId))
return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));
LockControllType controllType = req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE;
@@ -346,7 +349,7 @@ public class CardLockController {
if (!l.getLockee().equals(myId))
return ResponseEntity.status(403).build();
String code = cardLockServiceFactory.create(l).endHygieneOpening();
String code = cardLockServiceFactory.create(l).endTempOpening();
return ResponseEntity.ok(Map.of("newUnlockCode", code));
}
@@ -421,11 +424,11 @@ public class CardLockController {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty())
return ResponseEntity.status(401).build();
var locks = cardlockRepository.findByLockee(meOpt.get().getUserId());
var active = locks.stream().filter(l -> l.getUnlockTime() == null).findFirst();
if (active.isEmpty())
UUID myId = meOpt.get().getUserId();
var activeLockId = cardLockServiceFactory.findActiveLockId(myId);
if (activeLockId.isEmpty())
return ResponseEntity.noContent().build();
return ResponseEntity.ok(Map.of("lockId", active.get().getLockId().toString()));
return ResponseEntity.ok(Map.of("lockId", activeLockId.get().toString()));
}
@GetMapping("/cardlock/{lockId}")
@@ -477,6 +480,11 @@ public class CardLockController {
result.put("hygieneOpeningDue", hygieneOpeningDue);
result.put("hygieneSecondsRemaining", hygieneSecondsRemaining);
result.put("hygieneOpeningActive", l.getTempOpeningTime() != null && TempOpeningReason.HYGIENE == l.getTempOpeningReason());
boolean tempOpeningActive = l.getTempOpeningTime() != null && TempOpeningReason.HYGIENE != l.getTempOpeningReason();
result.put("tempOpeningActive", tempOpeningActive);
if (tempOpeningActive) {
result.put("tempOpeningUnlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : "");
}
result.put("hygieneOpeningStarted",
l.getTempOpeningTime() != null ? l.getTempOpeningTime().toString() : null);
result.put("hygieneDurationMinutes",

View File

@@ -11,7 +11,6 @@ import org.slf4j.LoggerFactory;
import de.oaa.xxx.games.chastity.common.BaseLockEntity;
import de.oaa.xxx.games.chastity.common.BaseLockService;
import de.oaa.xxx.games.chastity.common.CodeCreator;
import de.oaa.xxx.games.chastity.community.CommunityTaskVoteRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository;
@@ -99,11 +98,13 @@ public class CardLockService extends BaseLockService implements LockControlCallb
@Override
protected void applyHygieneOvertime(Long overtime) {
LOGGER.debug("Apply {} Minutes Overtime");
if (lock.getFrozenUntil() != null) {
lock.setFrozenUntil(lock.getFrozenUntil().plusMinutes(overtime * 4));
} else {
lock.setFrozenUntil(LocalDateTime.now().plusMinutes(overtime * 4));
}
LOGGER.debug("Frozen until {}", lock.getFrozenUntil());
}
// ── Card drawing ──────────────────────────────────────────────────────────
@@ -251,31 +252,6 @@ public class CardLockService extends BaseLockService implements LockControlCallb
return lock.getUnlockCode();
}
public String endCumming() {
Long overtime = calcOvertime();
if (overtime != null) {
if (lock.getKeyholder() == null) {
applyHygieneOvertime(overtime);
} else {
reportKeyholder(overtime);
}
}
lock.setTempOpeningDuration(null);
lock.setTempOpeningTime(null);
lock.setTempOpeningReason(null);
if (lockControl != null
&& lock.getControllType() != de.oaa.xxx.games.chastity.lockcontroll.LockControllType.UNLOCK_CODE) {
lockControl.lock();
cardLockRepository.save(lock);
return lock.getUnlockCode() != null ? lock.getUnlockCode() : "";
}
var code = CodeCreator.createNumeric(lock.getUnlockCodeLength() != null ? lock.getUnlockCodeLength() : 5);
lock.setUnlockCode(code);
cardLockRepository.save(lock);
return code;
}
// ── Assigned task penalty ─────────────────────────────────────────────────
public void applyAssignedTaskPenalty(AssignedTaskEntity task) {

View File

@@ -1,9 +1,14 @@
package de.oaa.xxx.games.chastity.cardlock;
import java.util.Optional;
import java.util.UUID;
import de.oaa.xxx.games.history.GameHistoryRepository;
import de.oaa.xxx.games.chastity.common.BaseLockService;
import de.oaa.xxx.games.chastity.community.CommunityTaskVoteRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository;
import de.oaa.xxx.games.chastity.timelock.TimeLockRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderVerificationRepository;
@@ -35,11 +40,14 @@ public class CardLockServiceFactory {
private final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository;
private final CommunityTaskVoteRepository communityTaskVoteRepository;
private final LockControlFactory lockControlFactory;
private final CardlockRepository cardlockRepository;
private final TimeLockRepository timeLockRepository;
public CardLockServiceFactory(
CommunityVerificationRepository communityVerificationRepository,
CommunityVerificationVoteRepository communityVerificationVoteRepository,
CardLockRepository cardLockRepository,
CardlockRepository cardlockRepository,
GameHistoryRepository gameHistoryRepository,
UserRepository userRepository,
KeyholderNotificationRepository keyholderNotificationRepository,
@@ -48,8 +56,10 @@ public class CardLockServiceFactory {
SystemMessageService systemMessageService,
KeyholderTaskChoiceRepository keyholderTaskChoiceRepository,
CommunityTaskVoteRepository communityTaskVoteRepository,
LockControlFactory lockControlFactory) {
LockControlFactory lockControlFactory,
TimeLockRepository timeLockRepository) {
this.cardLockRepository = cardLockRepository;
this.cardlockRepository = cardlockRepository;
this.communityVerificationRepository = communityVerificationRepository;
this.communityVerificationVoteRepository = communityVerificationVoteRepository;
this.gameHistoryRepository = gameHistoryRepository;
@@ -61,6 +71,19 @@ public class CardLockServiceFactory {
this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository;
this.communityTaskVoteRepository = communityTaskVoteRepository;
this.lockControlFactory = lockControlFactory;
this.timeLockRepository = timeLockRepository;
}
public boolean hasActiveLock(UUID lockeeId) {
return BaseLockService.hasActiveLock(lockeeId, cardlockRepository, timeLockRepository);
}
public Optional<UUID> findActiveLockId(UUID lockeeId) {
var cardLock = cardlockRepository.findByLockee(lockeeId).stream()
.filter(l -> l.getUnlockTime() == null).findFirst();
if (cardLock.isPresent()) return Optional.of(cardLock.get().getLockId());
return timeLockRepository.findFirstByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(lockeeId)
.map(l -> l.getLockId());
}
/**

View File

@@ -12,10 +12,12 @@ import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import de.oaa.xxx.games.chastity.cardlock.CardlockRepository;
import de.oaa.xxx.games.chastity.community.CommunityTaskVoteEntity;
import de.oaa.xxx.games.chastity.community.CommunityTaskVoteRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository;
import de.oaa.xxx.games.chastity.timelock.TimeLockRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationEntity;
import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceEntity;
@@ -91,6 +93,18 @@ public abstract class BaseLockService {
this.communityTaskVoteRepository = communityTaskVoteRepository;
}
// ── Lockee-Prüfung ────────────────────────────────────────────────────────
/**
* Prüft ob der Anwender bereits ein aktives Lock (CardLock oder TimeLock) als Lockee hat.
* Ein Lock gilt als aktiv wenn startTime gesetzt und unlockTime null ist.
*/
public static boolean hasActiveLock(UUID lockeeId, CardlockRepository cardlockRepo,
TimeLockRepository timelockRepo) {
return cardlockRepo.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(lockeeId)
|| timelockRepo.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(lockeeId);
}
// ── Gemeinsame Hilfsmethoden ──────────────────────────────────────────────
protected Long calcOvertime() {
@@ -192,10 +206,10 @@ public abstract class BaseLockService {
unlockCodeHistoryService.save(lock.getLockee(), lock.getLockId(), lock.getName(), lock.getUnlockCode(), reason.toString());
}
public String endHygieneOpening() {
BaseLockEntity lock = getLock();
LocalDateTime now = LocalDateTime.now();
Long overtime = calcOvertime();
public String endTempOpening() {
var lock = getLock();
var now = LocalDateTime.now();
var overtime = calcOvertime();
if (overtime != null) {
if (lock.getKeyholder() != null) {
reportKeyholder(overtime);
@@ -203,19 +217,19 @@ public abstract class BaseLockService {
applyHygieneOvertime(overtime);
}
afterHygieneClosing();
lock.setLastHygineOpening(now);
if (TempOpeningReason.HYGIENE == lock.getTempOpeningReason()) {
lock.setLastHygineOpening(now);
}
lock.setTempOpeningReason(null);
lock.setTempOpeningDuration(null);
lock.setTempOpeningTime(null);
if (lockControl != null
&& lock.getControllType() != de.oaa.xxx.games.chastity.lockcontroll.LockControllType.UNLOCK_CODE) {
// TTLock/Trust: lockControl.lock() setzt PIN am Gerät (oder tut nichts bei Trust).
// Kein Software-Code notwendig.
lockControl.lock();
lockControl.lock();
saveLock();
return lock.getUnlockCode() != null ? lock.getUnlockCode() : "";
}
// UNLOCK_CODE (oder kein lockControl): neuen numerischen Code generieren
String code = CodeCreator.createNumeric(lock.getUnlockCodeLength() != null ? lock.getUnlockCodeLength() : 5);
var code = CodeCreator.createNumeric(lock.getUnlockCodeLength() != null ? lock.getUnlockCodeLength() : 5);
lock.setUnlockCode(code);
saveLock();
return code;

View File

@@ -45,6 +45,8 @@ public class BaseLockTemplateController {
dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes());
dto.put("taskCount", t.getTasks() != null ? t.getTasks().size() : 0);
dto.put("requiresVerification", t.isRequiresVerification());
dto.put("published", t.isPublished());
dto.put("showAuthor", t.isShowAuthor());
return dto;
}).toList();

View File

@@ -47,7 +47,16 @@ public class BaseLockTemplateEntity {
private boolean requiresVerification;
@Column(nullable = false)
private TaskMode taskMode = TaskMode.RANDOM;
@Column(nullable = false)
private boolean published = false;
@Column(nullable = false)
private boolean showAuthor = false;
@Column(nullable = false)
private long subscriberCount = 0;
public TaskMode getTaskCardMode() {
return taskMode != null ? taskMode : TaskMode.RANDOM;
}

View File

@@ -7,7 +7,10 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BaseLockTemplateRepository extends JpaRepository<BaseLockTemplateEntity, UUID>{
public interface BaseLockTemplateRepository extends JpaRepository<BaseLockTemplateEntity, UUID> {
List<BaseLockTemplateEntity> findByOwner(UUID owner);
List<BaseLockTemplateEntity> findByOwnerAndPublishedTrue(UUID owner);
Page<BaseLockTemplateEntity> findByOwner(UUID owner, Pageable pageable);
Page<BaseLockTemplateEntity> findByPublishedTrue(Pageable pageable);
Page<BaseLockTemplateEntity> findByPublishedTrueAndNameContainingIgnoreCase(String name, Pageable pageable);
}

View File

@@ -0,0 +1,336 @@
package de.oaa.xxx.games.chastity.common;
import de.oaa.xxx.games.chastity.cardlock.CardlockTemplateEntity;
import de.oaa.xxx.games.chastity.cardlock.CardlockTemplateRepository;
import de.oaa.xxx.games.chastity.timelock.TimeLockTemplateEntity;
import de.oaa.xxx.games.chastity.timelock.TimeLockTemplateRepository;
import de.oaa.xxx.games.chastity.tasks.TaskMode;
import de.oaa.xxx.user.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/templates")
public class TemplateExploreController {
private final BaseLockTemplateRepository templateRepository;
private final TemplateSubscriptionRepository subscriptionRepository;
private final TimeLockTemplateRepository timeLockTemplateRepository;
private final CardlockTemplateRepository cardlockTemplateRepository;
private final UserRepository userRepository;
public TemplateExploreController(BaseLockTemplateRepository templateRepository,
TemplateSubscriptionRepository subscriptionRepository,
TimeLockTemplateRepository timeLockTemplateRepository,
CardlockTemplateRepository cardlockTemplateRepository,
UserRepository userRepository) {
this.templateRepository = templateRepository;
this.subscriptionRepository = subscriptionRepository;
this.timeLockTemplateRepository = timeLockTemplateRepository;
this.cardlockTemplateRepository = cardlockTemplateRepository;
this.userRepository = userRepository;
}
// ── Öffentliche Vorlagen entdecken ────────────────────────────────────────
@GetMapping("/public")
public ResponseEntity<Map<String, Object>> getPublicTemplates(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "") String q,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
size = Math.min(size, 20);
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "subscriberCount"));
Page<BaseLockTemplateEntity> pageResult = q.isBlank()
? templateRepository.findByPublishedTrue(pageable)
: templateRepository.findByPublishedTrueAndNameContainingIgnoreCase(q.trim(), pageable);
Set<UUID> subscribedIds = subscriptionRepository.findByUserId(myId)
.stream().map(TemplateSubscriptionEntity::getTemplateId).collect(Collectors.toSet());
List<Map<String, Object>> content = pageResult.getContent().stream()
.map(t -> toPublicDto(t, myId, subscribedIds))
.toList();
return ResponseEntity.ok(Map.of(
"content", content,
"page", pageResult.getNumber(),
"hasMore", !pageResult.isLast()
));
}
// ── Einzelne Vorlage per ID (für Detail-Dialog) ──────────────────────────
@GetMapping("/{id}/public")
public ResponseEntity<Map<String, Object>> getTemplate(
@PathVariable UUID id, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var tOpt = templateRepository.findById(id);
if (tOpt.isEmpty()) return ResponseEntity.notFound().build();
var t = tOpt.get();
// Sichtbar wenn: eigene Vorlage ODER veröffentlicht
if (!t.getOwner().equals(myId) && !t.isPublished()) return ResponseEntity.status(403).build();
Set<UUID> subscribedIds = subscriptionRepository.findByUserId(myId)
.stream().map(TemplateSubscriptionEntity::getTemplateId).collect(Collectors.toSet());
return ResponseEntity.ok(toPublicDto(t, myId, subscribedIds));
}
// ── Eigene Vorlagen (für Auswahl-Dropdown) ───────────────────────────────
@GetMapping("/mine")
public ResponseEntity<List<Map<String, Object>>> getMyTemplates(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<Map<String, Object>> result = templateRepository.findByOwner(myId).stream()
.map(t -> toPublicDto(t, myId, Set.of()))
.toList();
return ResponseEntity.ok(result);
}
// ── Abonnierte Vorlagen ───────────────────────────────────────────────────
@GetMapping("/subscribed")
public ResponseEntity<List<Map<String, Object>>> getSubscribedTemplates(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<TemplateSubscriptionEntity> subs = subscriptionRepository.findByUserId(myId);
Set<UUID> subscribedIds = subs.stream().map(TemplateSubscriptionEntity::getTemplateId).collect(Collectors.toSet());
List<Map<String, Object>> result = new ArrayList<>();
for (var sub : subs) {
templateRepository.findById(sub.getTemplateId()).ifPresent(t -> {
if (t.isPublished()) {
result.add(toPublicDto(t, myId, subscribedIds));
}
});
}
return ResponseEntity.ok(result);
}
// ── Abonnieren ────────────────────────────────────────────────────────────
@PostMapping("/{id}/subscribe")
@Transactional
public ResponseEntity<Void> subscribe(@PathVariable UUID id, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var tOpt = templateRepository.findById(id);
if (tOpt.isEmpty() || !tOpt.get().isPublished()) return ResponseEntity.notFound().build();
if (tOpt.get().getOwner().equals(myId)) return ResponseEntity.status(409).build();
if (subscriptionRepository.findByUserIdAndTemplateId(myId, id).isPresent())
return ResponseEntity.status(409).build();
var sub = new TemplateSubscriptionEntity();
sub.setUserId(myId);
sub.setTemplateId(id);
sub.setSubscribedAt(LocalDateTime.now());
subscriptionRepository.save(sub);
var t = tOpt.get();
t.setSubscriberCount(t.getSubscriberCount() + 1);
templateRepository.save(t);
return ResponseEntity.noContent().build();
}
// ── Abo kündigen ──────────────────────────────────────────────────────────
@DeleteMapping("/{id}/subscribe")
@Transactional
public ResponseEntity<Void> unsubscribe(@PathVariable UUID id, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var subOpt = subscriptionRepository.findByUserIdAndTemplateId(myId, id);
if (subOpt.isEmpty()) return ResponseEntity.noContent().build();
subscriptionRepository.delete(subOpt.get());
templateRepository.findById(id).ifPresent(t -> {
t.setSubscriberCount(Math.max(0, t.getSubscriberCount() - 1));
templateRepository.save(t);
});
return ResponseEntity.noContent().build();
}
// ── Kopie erstellen (Fork) ────────────────────────────────────────────────
@PostMapping("/{id}/fork")
@Transactional
public ResponseEntity<Map<String, Object>> fork(@PathVariable UUID id, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var tOpt = templateRepository.findById(id);
if (tOpt.isEmpty() || !tOpt.get().isPublished()) return ResponseEntity.notFound().build();
var source = tOpt.get();
String copyName = (source.getName() != null ? source.getName() : "Vorlage") + " (Kopie)";
UUID newId;
if (source instanceof TimeLockTemplateEntity tl) {
var copy = new TimeLockTemplateEntity();
copy.setOwner(myId);
copy.setName(copyName);
copy.setMinTimeInMinutes(tl.getMinTimeInMinutes());
copy.setMaxTimeInMinutes(tl.getMaxTimeInMinutes());
copy.setEndTimeVisible(tl.isEndTimeVisible());
copy.setHygineOpeningDurationMinutes(tl.getHygineOpeningDurationMinutes());
copy.setHygineOpeningEveryMinites(tl.getHygineOpeningEveryMinites());
copy.setTasks(tl.getTasks());
copy.setTaskEveryMinutes(tl.getTaskEveryMinutes());
copy.setMinTasksPerDay(tl.getMinTasksPerDay());
copy.setSpinningWheelEntries(tl.getSpinningWheelEntries());
copy.setSpinsEveryMinutes(tl.getSpinsEveryMinutes());
copy.setMinSpinsPerDay(tl.getMinSpinsPerDay());
copy.setRequiresVerification(tl.isRequiresVerification());
copy.setTaskMode(tl.getTaskMode() != null ? tl.getTaskMode() : TaskMode.RANDOM);
copy.setPenaltyType(tl.getPenaltyType());
copy.setPenaltyValue(tl.getPenaltyValue());
newId = timeLockTemplateRepository.save(copy).getTemplateId();
} else if (source instanceof CardlockTemplateEntity cl) {
var copy = new CardlockTemplateEntity();
copy.setOwner(myId);
copy.setName(copyName);
copy.setCardCountsMin(cl.getCardCountsMin());
copy.setCardCountsMax(cl.getCardCountsMax());
copy.setPickEveryMinute(cl.getPickEveryMinute());
copy.setAccumulatePicks(cl.isAccumulatePicks());
copy.setShowRemainingCards(cl.isShowRemainingCards());
copy.setHygineOpeningDurationMinutes(cl.getHygineOpeningDurationMinutes());
copy.setHygineOpeningEveryMinites(cl.getHygineOpeningEveryMinites());
copy.setTasks(cl.getTasks());
copy.setRequiresVerification(cl.isRequiresVerification());
copy.setTaskMode(cl.getTaskMode() != null ? cl.getTaskMode() : TaskMode.RANDOM);
newId = cardlockTemplateRepository.save(copy).getTemplateId();
} else {
return ResponseEntity.status(500).build();
}
return ResponseEntity.ok(Map.of("templateId", newId.toString()));
}
// ── Veröffentlichen ───────────────────────────────────────────────────────
@PatchMapping("/{id}/publish")
@Transactional
public ResponseEntity<Void> publish(@PathVariable UUID id, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var me = meOpt.get();
var tOpt = templateRepository.findById(id);
if (tOpt.isEmpty()) return ResponseEntity.notFound().build();
var t = tOpt.get();
if (!t.getOwner().equals(me.getUserId())) return ResponseEntity.status(403).build();
t.setPublished(true);
t.setShowAuthor(me.isProfilBeiVeroeffentlichungenSichtbar());
templateRepository.save(t);
return ResponseEntity.noContent().build();
}
// ── Veröffentlichung entfernen ────────────────────────────────────────────
@DeleteMapping("/{id}/publish")
@Transactional
public ResponseEntity<Void> unpublish(@PathVariable UUID id, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var tOpt = templateRepository.findById(id);
if (tOpt.isEmpty()) return ResponseEntity.notFound().build();
var t = tOpt.get();
if (!t.getOwner().equals(myId)) return ResponseEntity.status(403).build();
t.setPublished(false);
t.setShowAuthor(false);
t.setSubscriberCount(0);
templateRepository.save(t);
subscriptionRepository.deleteByTemplateId(id);
return ResponseEntity.noContent().build();
}
// ── DTO Helper ────────────────────────────────────────────────────────────
private Map<String, Object> toPublicDto(BaseLockTemplateEntity t, UUID myId, Set<UUID> subscribedIds) {
boolean isOwn = t.getOwner().equals(myId);
boolean isSubscribed = subscribedIds.contains(t.getTemplateId());
String authorName = null;
String authorProfilePicture = null;
if (t.isShowAuthor()) {
var authorOpt = userRepository.findById(t.getOwner());
authorName = authorOpt.map(u -> u.getName()).orElse(null);
authorProfilePicture = authorOpt.map(u -> u.getProfilePicture()).orElse(null);
}
Map<String, Object> dto = new LinkedHashMap<>();
dto.put("templateId", t.getTemplateId().toString());
dto.put("lockType", t instanceof TimeLockTemplateEntity ? "TIMELOCK" : "CARDLOCK");
dto.put("name", t.getName() != null ? t.getName() : "");
dto.put("subscriberCount", t.getSubscriberCount());
dto.put("authorName", authorName);
dto.put("authorProfilePicture", authorProfilePicture);
dto.put("isOwnTemplate", isOwn);
dto.put("isSubscribed", isSubscribed);
dto.put("taskCount", t.getTasks() != null ? t.getTasks().size() : 0);
dto.put("requiresVerification", t.isRequiresVerification());
dto.put("hygieneEnabled", t.getHygineOpeningEveryMinites() != null);
dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites());
dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes());
dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of());
dto.put("taskMode", t.getTaskMode() != null ? t.getTaskMode().name() : "RANDOM");
if (t instanceof TimeLockTemplateEntity tl) {
dto.put("minTimeInMinutes", tl.getMinTimeInMinutes());
dto.put("maxTimeInMinutes", tl.getMaxTimeInMinutes());
dto.put("endTimeVisible", tl.isEndTimeVisible());
dto.put("taskEveryMinutes", tl.getTaskEveryMinutes());
dto.put("minTasksPerDay", tl.getMinTasksPerDay());
dto.put("spinningWheelEntries", tl.getSpinningWheelEntries() != null ? tl.getSpinningWheelEntries() : List.of());
dto.put("spinsEveryMinutes", tl.getSpinsEveryMinutes());
dto.put("minSpinsPerDay", tl.getMinSpinsPerDay());
dto.put("penaltyType", tl.getPenaltyType() != null ? tl.getPenaltyType().name() : null);
dto.put("penaltyValue", tl.getPenaltyValue());
} else if (t instanceof CardlockTemplateEntity cl) {
dto.put("cardCountsMin", cl.getCardCountsMin() != null ? cl.getCardCountsMin() : Map.of());
dto.put("cardCountsMax", cl.getCardCountsMax() != null ? cl.getCardCountsMax() : Map.of());
dto.put("pickEveryMinute", cl.getPickEveryMinute());
dto.put("accumulatePicks", cl.isAccumulatePicks());
dto.put("showRemainingCards",cl.isShowRemainingCards());
}
return dto;
}
}

View File

@@ -0,0 +1,33 @@
package de.oaa.xxx.games.chastity.common;
import java.time.LocalDateTime;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "template_subscription")
public class TemplateSubscriptionEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private UUID templateId;
@Column(nullable = false)
private LocalDateTime subscribedAt;
}

View File

@@ -0,0 +1,18 @@
package de.oaa.xxx.games.chastity.common;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
public interface TemplateSubscriptionRepository extends JpaRepository<TemplateSubscriptionEntity, UUID> {
Optional<TemplateSubscriptionEntity> findByUserIdAndTemplateId(UUID userId, UUID templateId);
List<TemplateSubscriptionEntity> findByUserId(UUID userId);
long countByTemplateId(UUID templateId);
@Transactional
void deleteByTemplateId(UUID templateId);
@Transactional
void deleteByUserIdAndTemplateId(UUID userId, UUID templateId);
}

View File

@@ -0,0 +1,384 @@
package de.oaa.xxx.games.chastity.keyholder;
import de.oaa.xxx.games.chastity.cardlock.*;
import de.oaa.xxx.games.chastity.common.*;
import de.oaa.xxx.games.chastity.lockcontroll.LockControllType;
import de.oaa.xxx.games.chastity.tasks.TaskMode;
import de.oaa.xxx.games.chastity.timelock.*;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.Geschlecht;
import de.oaa.xxx.user.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
@RestController
@RequestMapping("/keyholder-offers")
public class KeyholderOfferController {
private final KeyholderOfferRepository offerRepository;
private final BaseLockTemplateRepository templateRepository;
private final TemplateSubscriptionRepository subscriptionRepository;
private final UserRepository userRepository;
private final CardlockRepository cardlockRepository;
private final TimeLockRepository timeLockRepository;
private final CardLockServiceFactory cardLockServiceFactory;
private final TimeLockServiceFactory timeLockServiceFactory;
private final KeyholderInvitationRepository invitationRepository;
private final SystemMessageService systemMessageService;
private final SubscriptionLimitService subscriptionLimitService;
public KeyholderOfferController(
KeyholderOfferRepository offerRepository,
BaseLockTemplateRepository templateRepository,
TemplateSubscriptionRepository subscriptionRepository,
UserRepository userRepository,
CardlockRepository cardlockRepository,
TimeLockRepository timeLockRepository,
CardLockServiceFactory cardLockServiceFactory,
TimeLockServiceFactory timeLockServiceFactory,
KeyholderInvitationRepository invitationRepository,
SystemMessageService systemMessageService,
SubscriptionLimitService subscriptionLimitService) {
this.offerRepository = offerRepository;
this.templateRepository = templateRepository;
this.subscriptionRepository = subscriptionRepository;
this.userRepository = userRepository;
this.cardlockRepository = cardlockRepository;
this.timeLockRepository = timeLockRepository;
this.cardLockServiceFactory = cardLockServiceFactory;
this.timeLockServiceFactory = timeLockServiceFactory;
this.invitationRepository = invitationRepository;
this.systemMessageService = systemMessageService;
this.subscriptionLimitService = subscriptionLimitService;
}
// ── Eigene Angebote ───────────────────────────────────────────────────────
@GetMapping("/mine")
public ResponseEntity<List<Map<String, Object>>> getMyOffers(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<Map<String, Object>> result = offerRepository.findByOffererId(myId).stream()
.map(this::toDto)
.toList();
return ResponseEntity.ok(result);
}
record CreateOfferRequest(UUID templateId, List<String> targetGenders, boolean directStart) {}
@PostMapping
@Transactional
public ResponseEntity<Map<String, Object>> createOffer(
@RequestBody CreateOfferRequest req, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var me = meOpt.get();
UUID myId = me.getUserId();
// Limit prüfen
long existing = offerRepository.countByOffererId(myId);
if (existing >= subscriptionLimitService.maxKeyholderOffers(myId)) {
return ResponseEntity.status(403).body(Map.of("error", "offer_limit_reached"));
}
// Template muss dem User gehören oder abonniert sein
var tOpt = templateRepository.findById(req.templateId());
if (tOpt.isEmpty()) return ResponseEntity.badRequest().build();
var t = tOpt.get();
boolean isOwn = t.getOwner().equals(myId);
boolean isSubscribed = subscriptionRepository.findByUserIdAndTemplateId(myId, req.templateId()).isPresent();
if (!isOwn && !isSubscribed) return ResponseEntity.status(403).build();
String genders = req.targetGenders() != null
? String.join(",", req.targetGenders())
: "";
String templateType = t instanceof TimeLockTemplateEntity ? "TIMELOCK" : "CARDLOCK";
KeyholderOfferEntity offer = new KeyholderOfferEntity();
offer.setOffererId(myId);
offer.setTemplateId(req.templateId());
offer.setTemplateName(t.getName() != null ? t.getName() : "Unbenannt");
offer.setTemplateType(templateType);
offer.setTargetGenders(genders);
offer.setDirectStart(req.directStart());
offer.setAcceptanceCount(0);
offer.setCreatedAt(LocalDateTime.now());
offerRepository.save(offer);
return ResponseEntity.ok(toDto(offer));
}
@PatchMapping("/{id}")
@Transactional
public ResponseEntity<Map<String, Object>> updateOffer(
@PathVariable UUID id,
@RequestBody CreateOfferRequest req,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var offerOpt = offerRepository.findById(id);
if (offerOpt.isEmpty()) return ResponseEntity.notFound().build();
var offer = offerOpt.get();
if (!offer.getOffererId().equals(myId)) return ResponseEntity.status(403).build();
// Template darf geändert werden muss dem User gehören oder abonniert sein
var tOpt = templateRepository.findById(req.templateId());
if (tOpt.isEmpty()) return ResponseEntity.badRequest().build();
var t = tOpt.get();
boolean isOwn = t.getOwner().equals(myId);
boolean isSubscribed = subscriptionRepository.findByUserIdAndTemplateId(myId, req.templateId()).isPresent();
if (!isOwn && !isSubscribed) return ResponseEntity.status(403).build();
String templateType = t instanceof TimeLockTemplateEntity ? "TIMELOCK" : "CARDLOCK";
String genders = req.targetGenders() != null ? String.join(",", req.targetGenders()) : "";
offer.setTemplateId(req.templateId());
offer.setTemplateName(t.getName() != null ? t.getName() : "Unbenannt");
offer.setTemplateType(templateType);
offer.setTargetGenders(genders);
offer.setDirectStart(req.directStart());
offerRepository.save(offer);
return ResponseEntity.ok(toDto(offer));
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<Void> deleteOffer(@PathVariable UUID id, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var offerOpt = offerRepository.findById(id);
if (offerOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!offerOpt.get().getOffererId().equals(myId)) return ResponseEntity.status(403).build();
offerRepository.delete(offerOpt.get());
return ResponseEntity.noContent().build();
}
// ── Angebote eines bestimmten Nutzers (für Profilseite) ──────────────────
@GetMapping("/user/{userId}")
public ResponseEntity<List<Map<String, Object>>> getOffersForUser(
@PathVariable UUID userId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<Map<String, Object>> result = offerRepository.findByOffererId(userId).stream()
.map(o -> toPublicDto(o, myId))
.toList();
return ResponseEntity.ok(result);
}
// ── Öffentliche Angebotsübersicht ─────────────────────────────────────────
@GetMapping("/public")
public ResponseEntity<List<Map<String, Object>>> getPublicOffers(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var me = meOpt.get();
String userGender = me.getGeschlecht() != null ? me.getGeschlecht().name() : null;
List<Map<String, Object>> result = offerRepository.findAllByOrderByAcceptanceCountDesc().stream()
.filter(o -> matchesGender(o, userGender))
.map(o -> toPublicDto(o, me.getUserId()))
.toList();
return ResponseEntity.ok(result);
}
private boolean matchesGender(KeyholderOfferEntity o, String userGender) {
String tg = o.getTargetGenders();
if (tg == null || tg.isBlank()) return true; // alle Geschlechter
if (userGender == null) return false; // User ohne Geschlecht: nur unrestricted
return Arrays.asList(tg.split(",")).contains(userGender);
}
// ── Angebot annehmen (Lock erstellen) ─────────────────────────────────────
record JoinOfferRequest(LockControllType controllType, Integer unlockCodeLength) {}
@PostMapping("/{id}/join")
@Transactional
public ResponseEntity<Map<String, Object>> joinOffer(
@PathVariable UUID id,
@RequestBody JoinOfferRequest req,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var me = meOpt.get();
UUID myId = me.getUserId();
var offerOpt = offerRepository.findById(id);
if (offerOpt.isEmpty()) return ResponseEntity.notFound().build();
var offer = offerOpt.get();
if (offer.getOffererId().equals(myId))
return ResponseEntity.status(409).body(Map.of("error", "own_offer"));
// Aktives Lock prüfen
if (cardLockServiceFactory.hasActiveLock(myId))
return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));
var tOpt = templateRepository.findById(offer.getTemplateId());
if (tOpt.isEmpty()) return ResponseEntity.status(410).body(Map.of("error", "template_gone"));
var template = tOpt.get();
var offererOpt = userRepository.findById(offer.getOffererId());
if (offererOpt.isEmpty()) return ResponseEntity.status(410).body(Map.of("error", "offerer_gone"));
var offerer = offererOpt.get();
LockControllType controllType = req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE;
int codeLen = (req.unlockCodeLength() != null && req.unlockCodeLength() >= 1) ? req.unlockCodeLength() : 5;
boolean directStart = offer.isDirectStart();
UUID keyholderIdIfDirect = directStart ? offer.getOffererId() : null;
UUID lockId;
String unlockCode = null;
if (template instanceof TimeLockTemplateEntity tl) {
TimeLockAdditionalSettings settings = new TimeLockAdditionalSettings(
controllType, myId, keyholderIdIfDirect, false, codeLen);
TimeLockEntity lock = new TimeLockEntity();
timeLockServiceFactory.create(lock).init(tl, settings);
timeLockRepository.save(lock);
lockId = lock.getLockId();
unlockCode = lock.getUnlockCode();
} else if (template instanceof CardlockTemplateEntity cl) {
List<CardEnum> cards = buildCardList(cl);
if (cards.isEmpty()) return ResponseEntity.badRequest().body(Map.of("error", "empty_deck"));
CardLockEntity lock = new CardLockEntity();
lock.setName(template.getName());
lock.setLockee(myId);
lock.setKeyholder(keyholderIdIfDirect);
lock.setInitialCards(cards);
lock.setPickEveryMinute(cl.getPickEveryMinute() != null ? cl.getPickEveryMinute() : 60);
lock.setAccumulatePicks(cl.isAccumulatePicks());
lock.setShowRemainingCards(cl.isShowRemainingCards());
lock.setHygineOpeningDurationMinutes(cl.getHygineOpeningDurationMinutes());
lock.setHygineOpeningEveryMinites(cl.getHygineOpeningEveryMinites());
lock.setTasks(cl.getTasks() != null ? cl.getTasks() : List.of());
lock.setRequiresVerification(cl.isRequiresVerification());
lock.setTestLock(false);
lock.setTaskMode(cl.getTaskMode() != null ? cl.getTaskMode() : TaskMode.RANDOM);
lock.setUnlockCodeLength(codeLen);
lock.setControllType(controllType);
LocalDateTime now = LocalDateTime.now();
lock.setStartTime(now);
lock.setAvailableCards(new ArrayList<>(cards));
lock.setOpenPicks(0);
lock.setNextCardIn(now.plusMinutes(lock.getPickEveryMinute()));
if (cl.getHygineOpeningEveryMinites() != null) {
lock.setLastHygineOpening(now);
}
cardlockRepository.save(lock);
CardLockService initService = cardLockServiceFactory.create(lock);
if (initService.getLockControl() != null) {
initService.getLockControl().lock();
} else {
lock.setUnlockCode(CodeCreator.createNumeric(codeLen));
cardlockRepository.save(lock);
}
lockId = lock.getLockId();
unlockCode = lock.getUnlockCode();
} else {
return ResponseEntity.status(500).build();
}
String lockName = template.getName() != null ? template.getName() : "Unbenanntes Lock";
boolean invitationSent = false;
if (!directStart) {
// Normaler Einladungsworkflow: Keyholder muss bestätigen
KeyholderInvitationEntity inv = new KeyholderInvitationEntity();
inv.setLockId(lockId);
inv.setKeyholderUserId(offer.getOffererId());
inv.setLockeeUserId(myId);
inv.setToken(UUID.randomUUID().toString().replace("-", ""));
inv.setCreatedAt(LocalDateTime.now());
invitationRepository.save(inv);
systemMessageService.send(myId, offerer.getUserId(),
me.getName() + " möchte dein Keyholder-Angebot annehmen und lädt dich als Keyholder für „"
+ lockName + "\" ein.",
"/einladungen.html", MessageCause.INVITATION);
invitationSent = true;
} else {
// Direktstart: Keyholder wird direkt gesetzt, aber trotzdem benachrichtigen
systemMessageService.send(myId, offerer.getUserId(),
me.getName() + " hat dein Keyholder-Angebot angenommen und das Lock „"
+ lockName + "\" gestartet.",
"/keyholder.html", MessageCause.INVITATION);
}
// Annahmezähler erhöhen
offer.setAcceptanceCount(offer.getAcceptanceCount() + 1);
offerRepository.save(offer);
Map<String, Object> response = new LinkedHashMap<>();
response.put("lockId", lockId.toString());
response.put("invitationSent", invitationSent);
if (unlockCode != null) response.put("unlockCode", unlockCode);
return ResponseEntity.ok(response);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private List<CardEnum> buildCardList(CardlockTemplateEntity cl) {
List<CardEnum> cards = new ArrayList<>();
Map<String, Integer> counts = cl.getCardCountsMax();
if (counts == null) return cards;
counts.forEach((type, count) -> {
if (count != null && count > 0) {
try {
CardEnum card = CardEnum.valueOf(type);
for (int i = 0; i < count; i++) cards.add(card);
} catch (IllegalArgumentException ignored) {}
}
});
return cards;
}
private Map<String, Object> toDto(KeyholderOfferEntity o) {
Map<String, Object> dto = new LinkedHashMap<>();
dto.put("id", o.getId().toString());
dto.put("templateId", o.getTemplateId().toString());
dto.put("templateName", o.getTemplateName());
dto.put("templateType", o.getTemplateType());
dto.put("targetGenders", o.getTargetGenders() != null ? Arrays.asList(o.getTargetGenders().split(",")).stream().filter(s -> !s.isBlank()).toList() : List.of());
dto.put("directStart", o.isDirectStart());
dto.put("acceptanceCount", o.getAcceptanceCount());
dto.put("createdAt", o.getCreatedAt().toString());
return dto;
}
private Map<String, Object> toPublicDto(KeyholderOfferEntity o, UUID myId) {
Map<String, Object> dto = toDto(o);
dto.put("isOwn", o.getOffererId().equals(myId));
userRepository.findById(o.getOffererId()).ifPresent(u -> {
dto.put("offererName", u.getName());
dto.put("offererProfilePic", u.getProfilePicture());
});
return dto;
}
}

View File

@@ -0,0 +1,48 @@
package de.oaa.xxx.games.chastity.keyholder;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "keyholder_offer")
public class KeyholderOfferEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column
private UUID id;
@Column(nullable = false)
private UUID offererId;
@Column(nullable = false)
private UUID templateId;
/** Denormalisiert für schnelle Anzeige */
@Column(length = 200)
private String templateName;
/** "CARDLOCK" oder "TIMELOCK" denormalisiert */
@Column(length = 20)
private String templateType;
/** Kommagetrennte Geschlechter: z.B. "WEIBLICH,DIVERS" leer = alle */
@Column(length = 100)
private String targetGenders;
/** true = Lock wird sofort gestartet; false = Keyholder muss erst bestätigen */
@Column(nullable = false)
private boolean directStart;
@Column(nullable = false)
private int acceptanceCount = 0;
@Column(nullable = false)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,12 @@
package de.oaa.xxx.games.chastity.keyholder;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface KeyholderOfferRepository extends JpaRepository<KeyholderOfferEntity, UUID> {
List<KeyholderOfferEntity> findByOffererId(UUID offererId);
long countByOffererId(UUID offererId);
List<KeyholderOfferEntity> findAllByOrderByAcceptanceCountDesc();
}

View File

@@ -257,7 +257,7 @@ public class LockeeInvitationController {
unlockCode = CodeCreator.createNumeric(codeLines);
int unlockMinutes = randomBetween(timeLock.getMinTimeInMinutes(), timeLock.getMaxTimeInMinutes());
timeLock.setStartTime(now);
timeLock.setUnlockTime(now.plusMinutes(unlockMinutes));
timeLock.setEstimatedUnlockTime(now.plusMinutes(unlockMinutes));
timeLock.setUnlockCode(unlockCode);
timeLock.setUnlockCodeLength(codeLines);
if (timeLock.getHygineOpeningEveryMinites() != null) {

View File

@@ -22,6 +22,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -120,6 +121,9 @@ public class TimeLockController {
if (lockeeOpt.isEmpty()) return ResponseEntity.badRequest().build();
var lockee = lockeeOpt.get();
if (timeLockServiceFactory.hasActiveLock(req.lockeeUserId()))
return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));
TimeLockEntity lock = buildBaseEntity(template, myId, req.lockeeUserId(), false);
lock.setStartTime(null);
timeLockRepository.save(lock);
@@ -144,7 +148,7 @@ public class TimeLockController {
"lockeeInvitationSent", true));
}
if (timeLockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(myId))
if (timeLockServiceFactory.hasActiveLock(myId))
return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));
LockControllType controllType = req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE;
@@ -223,7 +227,7 @@ public class TimeLockController {
timeLockRepository.save(l);
}
boolean timeUp = l.getUnlockTime() != null && l.getUnlockTime().isBefore(now);
boolean timeUp = l.getEstimatedUnlockTime() != null && l.getEstimatedUnlockTime().isBefore(now);
boolean isFrozen = l.getFrozenFrom() != null
&& (l.getFrozenUntil() == null || l.getFrozenUntil().isAfter(now));
@@ -319,7 +323,7 @@ public class TimeLockController {
// Only expose unlock time if end time is visible OR time is up
if (l.isEndTimeVisible() || timeUp) {
result.put("unlockTime", l.getUnlockTime() != null ? l.getUnlockTime().toString() : null);
result.put("unlockTime", l.getEstimatedUnlockTime() != null ? l.getEstimatedUnlockTime().toString() : null);
} else {
result.put("unlockTime", null);
}
@@ -360,11 +364,14 @@ public class TimeLockController {
});
}
result.put("controllType", l.getControllType() != null ? l.getControllType().name() : "UNLOCK_CODE");
result.put("keyholderRequestedUnlock", l.isKeyholderRequestedUnlock());
if (l.isKeyholderRequestedUnlock() || l.isTestLock() || timeUp) {
result.put("unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : "");
}
result.put("emergencyUnlockRequested", l.getEmergencyUnlockRequestedAt() != null);
result.put("emergencyAutoUnlocked", l.isEmergencyAutoUnlocked());
result.put("actualUnlockTime", l.getUnlockTime() != null ? l.getUnlockTime().toString() : null);
return ResponseEntity.ok(result);
}
@@ -382,7 +389,7 @@ public class TimeLockController {
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
if (l.getUnlockTime() == null) return ResponseEntity.status(409).build(); // not started
if (l.getEstimatedUnlockTime() == null) return ResponseEntity.status(409).build(); // not started
if (l.getSpinningWheelEntries() == null || l.getSpinningWheelEntries().isEmpty())
return ResponseEntity.status(409).build();
@@ -414,7 +421,7 @@ public class TimeLockController {
result.put("intVal", entry.getIntVal());
result.put("stringVal", entry.getStringVal());
// Include updated lock time fields
result.put("newUnlockTime", l.getUnlockTime() != null ? l.getUnlockTime().toString() : null);
result.put("newUnlockTime", l.getEstimatedUnlockTime() != null ? l.getEstimatedUnlockTime().toString() : null);
result.put("newFrozenUntil", l.getFrozenUntil() != null ? l.getFrozenUntil().toString() : null);
result.put("isFrozen", l.getFrozenFrom() != null);
result.put("currentTask", l.getCurrentTask());
@@ -505,11 +512,11 @@ public class TimeLockController {
return ResponseEntity.status(409).build();
TimeLockService service = timeLockServiceFactory.create(l);
String newCode = service.endHygieneOpening();
String newCode = service.endTempOpening();
return ResponseEntity.ok(Map.of(
"newUnlockCode", newCode,
"newUnlockTime", l.getUnlockTime() != null ? l.getUnlockTime().toString() : ""));
"newUnlockTime", l.getEstimatedUnlockTime() != null ? l.getEstimatedUnlockTime().toString() : ""));
}
// ── Verifikation starten ─────────────────────────────────────────────────────
@@ -576,7 +583,7 @@ public class TimeLockController {
if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
LocalDateTime now = LocalDateTime.now();
boolean timeUp = l.getUnlockTime() != null && l.getUnlockTime().isBefore(now);
boolean timeUp = l.getEstimatedUnlockTime() != null && l.getEstimatedUnlockTime().isBefore(now);
boolean isFrozen = l.getFrozenFrom() != null
&& (l.getFrozenUntil() == null || l.getFrozenUntil().isAfter(now));
@@ -604,6 +611,30 @@ public class TimeLockController {
return ResponseEntity.noContent().build();
}
// ── Tatsächliche Entsperrzeit setzen ─────────────────────────────────────────
@PatchMapping("/timelock/{lockId}/unlock-time")
@Transactional
public ResponseEntity<Void> setActualUnlockTime(@PathVariable UUID lockId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var lockOpt = timeLockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
LocalDateTime now = LocalDateTime.now();
boolean timeUp = l.getEstimatedUnlockTime() != null && l.getEstimatedUnlockTime().isBefore(now);
if (!timeUp && !l.isTestLock() && !l.isKeyholderRequestedUnlock())
return ResponseEntity.status(409).build();
l.setUnlockTime(now);
timeLockRepository.save(l);
return ResponseEntity.noContent().build();
}
// ── Keyholder-Ansicht ─────────────────────────────────────────────────────────
@GetMapping("/timelock/as-keyholder")
@@ -653,7 +684,7 @@ public class TimeLockController {
var lockee = lockeeOpt.get();
LocalDateTime now = LocalDateTime.now();
boolean timeUp = l.getUnlockTime() != null && l.getUnlockTime().isBefore(now);
boolean timeUp = l.getEstimatedUnlockTime() != null && l.getEstimatedUnlockTime().isBefore(now);
boolean isFrozen = l.getFrozenFrom() != null
&& (l.getFrozenUntil() == null || l.getFrozenUntil().isAfter(now));
@@ -689,7 +720,7 @@ public class TimeLockController {
result.put("lockeeProfilePic", lockee.getProfilePicture());
result.put("startTime", l.getStartTime() != null ? l.getStartTime().toString() : null);
result.put("unlockTime", (l.isEndTimeVisible() || timeUp)
? (l.getUnlockTime() != null ? l.getUnlockTime().toString() : null) : null);
? (l.getEstimatedUnlockTime() != null ? l.getEstimatedUnlockTime().toString() : null) : null);
result.put("timeUp", timeUp);
result.put("isFrozen", isFrozen);
result.put("frozenUntil", l.getFrozenUntil() != null ? l.getFrozenUntil().toString() : null);
@@ -721,6 +752,73 @@ public class TimeLockController {
return ResponseEntity.noContent().build();
}
// ── Einfrieren (Keyholder) ────────────────────────────────────────────────────
record FreezeRequest(String frozenUntil) {}
@PostMapping("/timelock/as-keyholder/{lockId}/freeze")
@Transactional
public ResponseEntity<?> freezeTimeLock(@PathVariable UUID lockId,
@RequestBody FreezeRequest req, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var me = meOpt.get();
UUID myId = me.getUserId();
var lockOpt = timeLockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!myId.equals(l.getKeyholder())) return ResponseEntity.status(403).build();
LocalDateTime until;
try {
until = LocalDateTime.parse(req.frozenUntil());
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", "Ungültiges Datumsformat."));
}
if (!until.isAfter(LocalDateTime.now()))
return ResponseEntity.badRequest().body(Map.of("error", "Zeitpunkt muss in der Zukunft liegen."));
l.setFrozenFrom(LocalDateTime.now());
l.setFrozenUntil(until);
timeLockRepository.save(l);
systemMessageService.send(myId, l.getLockee(),
me.getName() + " hat dein Lock bis "
+ until.toLocalDate().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")) + " "
+ until.toLocalTime().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"))
+ " Uhr eingefroren.",
"/activetimelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/timelock/as-keyholder/{lockId}/freeze")
@Transactional
public ResponseEntity<?> unfreezeTimeLock(@PathVariable UUID lockId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var me = meOpt.get();
UUID myId = me.getUserId();
var lockOpt = timeLockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!myId.equals(l.getKeyholder())) return ResponseEntity.status(403).build();
TimeLockService service = timeLockServiceFactory.create(l);
service.unfreeze();
l.setFrozenFrom(null);
l.setFrozenUntil(null);
timeLockRepository.save(l);
systemMessageService.send(myId, l.getLockee(),
me.getName() + " hat dein Lock entfroren.",
"/activetimelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE);
return ResponseEntity.noContent().build();
}
// ── Notfall-Entsperrung ───────────────────────────────────────────────────────
@PostMapping("/timelock/{lockId}/emergency-unlock")
@@ -800,4 +898,5 @@ public class TimeLockController {
ImageIO.write(scaled, "jpeg", out);
return out.toByteArray();
}
}

View File

@@ -46,7 +46,10 @@ public class TimeLockEntity extends BaseLockEntity {
@Column
private Integer penaltyValue;
@Column
@Column
private LocalDateTime estimatedUnlockTime;
@Column
private LocalDateTime frozenFrom;
@Column

View File

@@ -11,6 +11,8 @@ public interface TimeLockRepository extends JpaRepository<TimeLockEntity, UUID>
boolean existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(UUID lockee);
java.util.Optional<TimeLockEntity> findFirstByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(UUID lockee);
List<TimeLockEntity> findByKeyholderAndStartTimeIsNotNullAndUnlockTimeIsNull(UUID keyholder);
@Modifying(clearAutomatically = true)

View File

@@ -92,7 +92,9 @@ public class TimeLockService extends BaseLockService implements LockControlCallb
@Override
protected void applyHygieneOvertime(Long overtime) {
lock.setUnlockTime(lock.getUnlockTime().plusMinutes(overtime * 4));
LOGGER.debug("Apply {} Minutes Overtime");
lock.setEstimatedUnlockTime(lock.getEstimatedUnlockTime().plusMinutes(overtime * 4));
LOGGER.debug("New estimated endtime {}", lock.getEstimatedUnlockTime());
}
// ── Hook overrides ────────────────────────────────────────────────────────
@@ -135,7 +137,7 @@ public class TimeLockService extends BaseLockService implements LockControlCallb
int unlockTimeMinutes = (minMinutes != null && minMinutes < maxMinutes)
? minMinutes + new Random().nextInt(maxMinutes - minMinutes)
: maxMinutes;
lock.setUnlockTime(now.plusMinutes(unlockTimeMinutes));
lock.setEstimatedUnlockTime(now.plusMinutes(unlockTimeMinutes));
lock.setEndTimeVisible(template.isEndTimeVisible());
lock.setTasks(template.getTasks());
@@ -178,12 +180,12 @@ public class TimeLockService extends BaseLockService implements LockControlCallb
public void addTime(Integer intVal) {
LOGGER.debug("Lock addTime: %s minutes", intVal);
lock.setUnlockTime(lock.getUnlockTime().plusMinutes(intVal));
lock.setEstimatedUnlockTime(lock.getEstimatedUnlockTime().plusMinutes(intVal));
}
public void removeTime(Integer intVal) {
LOGGER.debug("Lock removeTime: %s minutes", intVal);
lock.setUnlockTime(lock.getUnlockTime().minusMinutes(intVal));
lock.setEstimatedUnlockTime(lock.getEstimatedUnlockTime().minusMinutes(intVal));
}
public void freeze(Integer intVal) {
@@ -202,7 +204,7 @@ public class TimeLockService extends BaseLockService implements LockControlCallb
var unfreeTime = lock.getFrozenUntil() != null ? lock.getFrozenUntil() : LocalDateTime.now();
var diff = ChronoUnit.MINUTES.between(lock.getFrozenFrom(), unfreeTime);
LOGGER.debug("Lock unfrozen - adding %s minutes to the lock", diff);
lock.setUnlockTime(lock.getUnlockTime().plusMinutes(diff));
lock.setEstimatedUnlockTime(lock.getEstimatedUnlockTime().plusMinutes(diff));
} else {
LOGGER.debug("Lock not frozen - ignore Call");
}
@@ -358,7 +360,6 @@ public class TimeLockService extends BaseLockService implements LockControlCallb
// ── Hygiene opening ───────────────────────────────────────────────────────
public void startHygieneOpening() {
lockControl.unlock();
startTempOpening(TempOpeningReason.HYGIENE, lock.getHygineOpeningDurationMinutes());
}

View File

@@ -1,7 +1,11 @@
package de.oaa.xxx.games.chastity.timelock;
import java.util.UUID;
import org.springframework.stereotype.Service;
import de.oaa.xxx.games.chastity.cardlock.CardlockRepository;
import de.oaa.xxx.games.chastity.common.BaseLockService;
import de.oaa.xxx.games.chastity.community.CommunityPilloryRepository;
import de.oaa.xxx.games.chastity.community.CommunityTaskVoteRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationRepository;
@@ -32,6 +36,7 @@ public class TimeLockServiceFactory {
private final SystemMessageService systemMessageService;
private CommunityVerificationVoteRepository communityVerificationVoteRepository;
private final LockControlFactory lockControlFactory;
private final CardlockRepository cardlockRepository;
public TimeLockServiceFactory(CommunityVerificationRepository verificationRepository,
CommunityVerificationVoteRepository verificationVoteRepository, TimeLockRepository timeLockRepository,
@@ -41,7 +46,7 @@ public class TimeLockServiceFactory {
KeyholderVerificationRepository keyholderVerificationRepository,
CommunityTaskVoteRepository communityTaskVoteRepository, CommunityPilloryRepository pilloryRepository,
UnlockCodeHistoryService unlockCodeHistoryService, SystemMessageService systemMessageService,
LockControlFactory lockControlFactory) {
LockControlFactory lockControlFactory, CardlockRepository cardlockRepository) {
this.communityVerificationVoteRepository = verificationVoteRepository;
this.timeLockRepository = timeLockRepository;
this.communityVerificationRepository = verificationRepository;
@@ -55,6 +60,11 @@ public class TimeLockServiceFactory {
this.communityTaskVoteRepository = communityTaskVoteRepository;
this.keyholderVerificationRepository = keyholderVerificationRepository;
this.lockControlFactory = lockControlFactory;
this.cardlockRepository = cardlockRepository;
}
public boolean hasActiveLock(UUID lockeeId) {
return BaseLockService.hasActiveLock(lockeeId, cardlockRepository, timeLockRepository);
}
/**

View File

@@ -365,7 +365,8 @@ public class SocialController {
user.getSichtbarkeitFeed(),
user.getSichtbarkeitPinnwand(),
user.getSichtbarkeitXp(),
user.getSichtbarkeitLockhistorie());
user.getSichtbarkeitLockhistorie(),
user.isProfilBeiVeroeffentlichungenSichtbar());
}
private MessageDto toMessageDto(MessageEntity m) {

View File

@@ -30,12 +30,13 @@ public record UserProfile(
Sichtbarkeit sichtbarkeitFeed,
Sichtbarkeit sichtbarkeitPinnwand,
Sichtbarkeit sichtbarkeitXp,
Sichtbarkeit sichtbarkeitLockhistorie
Sichtbarkeit sichtbarkeitLockhistorie,
boolean profilBeiVeroeffentlichungenSichtbar
) {
/** Compact constructor for contexts where profile details are not needed (friend list etc.) */
public UserProfile(UUID userId, String name, String profilePicture, String profilePictureHq, String friendStatus) {
this(userId, name, profilePicture, profilePictureHq, friendStatus,
null, null, null, null, null, null, null, 0, 0, 0,
null, null, null, null, null, null, null);
null, null, null, null, null, null, null, false);
}
}

View File

@@ -14,10 +14,11 @@ import java.util.UUID;
public class SubscriptionLimitService {
// ── Limits for STANDARD (no active subscription) ──
public static final int STANDARD_MAX_LOCK_TEMPLATES = 6;
public static final int STANDARD_MAX_TASK_GROUPS = 6;
public static final int STANDARD_MAX_TASKS_PER_GROUP = 50;
public static final int STANDARD_MAX_TOYS = 10;
public static final int STANDARD_MAX_LOCK_TEMPLATES = 6;
public static final int STANDARD_MAX_TASK_GROUPS = 6;
public static final int STANDARD_MAX_TASKS_PER_GROUP = 50;
public static final int STANDARD_MAX_TOYS = 10;
public static final int STANDARD_MAX_KEYHOLDER_OFFERS = 5;
private final UserSubscriptionRepository subscriptionRepository;
@@ -61,4 +62,10 @@ public class SubscriptionLimitService {
if (hasActivePaidSubscription(userId)) return Integer.MAX_VALUE;
return STANDARD_MAX_TOYS;
}
/** Max keyholder offers the user may create. */
public int maxKeyholderOffers(UUID userId) {
if (hasActivePaidSubscription(userId)) return Integer.MAX_VALUE;
return STANDARD_MAX_KEYHOLDER_OFFERS;
}
}

View File

@@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.games.bdsm.entity.BdsmDefaultsEntity;
import de.oaa.xxx.games.bdsm.repository.BdsmDefaultsRepository;
import de.oaa.xxx.games.chastity.common.BaseLockRepository;
import de.oaa.xxx.games.chastity.common.BaseLockTemplateRepository;
import de.oaa.xxx.games.chastity.common.CodeCreator;
import de.oaa.xxx.games.chastity.ttlock.TTAuthService;
import de.oaa.xxx.games.chastity.ttlock.TTLockConfigRepository;
@@ -57,6 +58,7 @@ public class UserController {
private final TTAuthService ttAuthService;
private final TTLockService ttLockService;
private final BaseLockRepository baseLockRepository;
private final BaseLockTemplateRepository baseLockTemplateRepository;
public UserController(UserRepository userRepository,
RegistrationRepository registrationRepository,
@@ -67,7 +69,8 @@ public class UserController {
TTLockConfigRepository ttLockConfigRepository,
TTAuthService ttAuthService,
TTLockService ttLockService,
BaseLockRepository baseLockRepository) {
BaseLockRepository baseLockRepository,
BaseLockTemplateRepository baseLockTemplateRepository) {
this.userRepository = userRepository;
this.registrationRepository = registrationRepository;
this.notificationPreferenceRepository = notificationPreferenceRepository;
@@ -78,6 +81,7 @@ public class UserController {
this.ttAuthService = ttAuthService;
this.ttLockService = ttLockService;
this.baseLockRepository = baseLockRepository;
this.baseLockTemplateRepository = baseLockTemplateRepository;
}
record ProfilePictureRequest(String picture, String pictureHq) {}
@@ -94,7 +98,8 @@ public class UserController {
Sichtbarkeit sichtbarkeitFeed,
Sichtbarkeit sichtbarkeitPinnwand,
Sichtbarkeit sichtbarkeitXp,
Sichtbarkeit sichtbarkeitLockhistorie) {}
Sichtbarkeit sichtbarkeitLockhistorie,
Boolean profilBeiVeroeffentlichungenSichtbar) {}
@PutMapping("/me/picture")
public ResponseEntity<Void> updateProfilePicture(@RequestBody ProfilePictureRequest request, Principal principal) {
@@ -138,6 +143,16 @@ public class UserController {
if (request.sichtbarkeitPinnwand() != null) user.setSichtbarkeitPinnwand(request.sichtbarkeitPinnwand());
if (request.sichtbarkeitXp() != null) user.setSichtbarkeitXp(request.sichtbarkeitXp());
if (request.sichtbarkeitLockhistorie()!= null) user.setSichtbarkeitLockhistorie(request.sichtbarkeitLockhistorie());
if (request.profilBeiVeroeffentlichungenSichtbar() != null) {
boolean showAuthor = request.profilBeiVeroeffentlichungenSichtbar();
user.setProfilBeiVeroeffentlichungenSichtbar(showAuthor);
// Alle veröffentlichten Templates synchronisieren
var templates = baseLockTemplateRepository.findByOwnerAndPublishedTrue(user.getUserId());
for (var t : templates) {
t.setShowAuthor(showAuthor);
}
baseLockTemplateRepository.saveAll(templates);
}
userRepository.save(user);
LOGGER.info("User {} hat Datenschutz-Einstellungen aktualisiert", user.getUserId());
return ResponseEntity.ok().build();

View File

@@ -92,6 +92,9 @@ public class UserEntity {
@Column(length = 20, nullable = false, columnDefinition = "VARCHAR(20) DEFAULT 'ALLE'")
private Sichtbarkeit sichtbarkeitLockhistorie = Sichtbarkeit.ALLE;
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private boolean profilBeiVeroeffentlichungenSichtbar = false;
public Integer getAlter() {
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;
}

View File

@@ -678,6 +678,29 @@
</div>
</div>
<!-- Temp-Öffnung-Modal (CARD / TASK / etc.) -->
<div class="hygiene-modal-backdrop" id="tempOpeningModal">
<div class="hygiene-modal-box">
<div class="hygiene-modal-icon">🔓</div>
<h3>Lock geöffnet</h3>
<!-- Phase 1: Code anzeigen -->
<div id="tempPhase1" style="width:100%;display:flex;flex-direction:column;align-items:center;gap:0.75rem;">
<p style="margin:0;font-size:0.88rem;color:var(--color-muted);">Dein aktueller Entsperrcode:</p>
<div class="hygiene-unlock-code" id="tempOpeningCode"></div>
<button class="btn-draw-ok" style="margin-top:0.25rem;" onclick="endTempOpeningFlow()">🔒 Öffnung beenden</button>
</div>
<!-- Phase 2: Neuer Code (nur UNLOCK_CODE) -->
<div id="tempPhase2" style="display:none;width:100%;flex-direction:column;align-items:center;gap:0.75rem;">
<p id="tempPhase2Hint" style="margin:0;font-size:0.88rem;color:var(--color-muted);">Dein neuer Code zum Abschließen:</p>
<div class="hygiene-unlock-code" id="tempNewCode"></div>
<div id="tempScrambleCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);font-family:monospace;"></div>
<button class="btn-draw-ok" id="tempPhase2Btn" onclick="startTempScramble()">OK</button>
</div>
</div>
</div>
<!-- Karte-Ziehen-Modal -->
<div class="draw-modal-backdrop" id="drawModal">
<div class="draw-modal-box">
@@ -811,6 +834,7 @@
renderNextCardPanel(lock);
renderHygienePanel(lock);
renderVerificationPanel(lock);
renderTempOpeningPanel(lock);
renderCardsPanel(lock);
if (lock.keyholderRequestedUnlock) {
@@ -1508,6 +1532,111 @@
}, 1000);
}
// ── Temp-Öffnung (CARD / TASK / etc.) ──
function renderTempOpeningPanel(lock) {
if (lock.tempOpeningActive) {
document.getElementById('tempOpeningCode').textContent = lock.tempOpeningUnlockCode || '';
document.getElementById('tempPhase1').style.display = '';
document.getElementById('tempPhase2').style.display = 'none';
document.querySelector('#tempOpeningModal h3').textContent = 'Lock geöffnet';
document.getElementById('tempOpeningModal').classList.add('open');
} else {
document.getElementById('tempOpeningModal').classList.remove('open');
}
}
async function endTempOpeningFlow() {
const isTtlock = _currentLock && _currentLock.controllType === 'TTLOCK';
const isTrust = _currentLock && _currentLock.controllType === 'TRUST';
if (isTtlock) {
document.getElementById('tempOpeningModal').classList.remove('open');
document.getElementById('ttlLoadingModal').classList.add('open');
}
const res = await fetch('/keyholder/cardlock/' + lockId + '/hygiene/end', { method: 'POST' });
if (isTtlock) {
document.getElementById('ttlLoadingModal').classList.remove('open');
if (!res.ok) { alert('Fehler beim Beenden der Öffnung.'); return; }
loadLock();
return;
}
if (!res.ok) { alert('Fehler beim Beenden der Öffnung.'); return; }
const data = await res.json();
if (isTrust || !data.newUnlockCode) {
document.getElementById('tempOpeningModal').classList.remove('open');
loadLock();
return;
}
// UNLOCK_CODE: neuen Code anzeigen
document.getElementById('tempPhase1').style.display = 'none';
document.getElementById('tempPhase2').style.display = 'flex';
document.getElementById('tempNewCode').textContent = data.newUnlockCode;
document.getElementById('tempPhase2Hint').style.display = '';
document.getElementById('tempPhase2Btn').textContent = 'OK';
document.getElementById('tempPhase2Btn').onclick = startTempScramble;
document.getElementById('tempScrambleCountdown').style.display = 'none';
document.querySelector('#tempOpeningModal h3').textContent = 'Lock geöffnet';
}
function closeTempOpeningModal() {
document.getElementById('tempOpeningModal').classList.remove('open');
if (tempScrambleTimer) { clearInterval(tempScrambleTimer); tempScrambleTimer = null; }
if (tempScrambleCd) { clearInterval(tempScrambleCd); tempScrambleCd = null; }
loadLock();
}
let tempScrambleTimer = null;
let tempScrambleCd = null;
function startTempScramble() {
const codeEl = document.getElementById('tempNewCode');
const hintEl = document.getElementById('tempPhase2Hint');
const cdEl = document.getElementById('tempScrambleCountdown');
const btnEl = document.getElementById('tempPhase2Btn');
const realCode = codeEl.textContent;
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(tempScrambleTimer); tempScrambleTimer = null;
clearInterval(tempScrambleCd); tempScrambleCd = null;
closeTempOpeningModal();
}
hintEl.style.display = 'none';
cdEl.style.display = '';
document.querySelector('#tempOpeningModal h3').textContent = 'Nun vergessen wir den Code…';
btnEl.textContent = 'Abbrechen';
btnEl.onclick = finish;
function updateCd() {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
cdEl.textContent = `${m}:${String(s).padStart(2,'0')}`;
}
updateCd();
tempScrambleTimer = setInterval(() => { if (!stopped) codeEl.textContent = randomCode(); }, 1000);
tempScrambleCd = setInterval(() => {
if (stopped) return;
remaining--;
updateCd();
if (remaining <= 0) finish();
}, 1000);
}
// ── Lock beenden ──
function lockBeendenFragen() {
document.getElementById('warnModalUnlockCode').textContent = _currentLock ? (_currentLock.unlockCode || '') : '';

View File

@@ -183,21 +183,14 @@
}
#wheelCanvas { border-radius: 50%; display: block; }
/* ── Spin-Result-Modal ── */
.spin-modal-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 500;
align-items: center; justify-content: center;
}
.spin-modal-backdrop.open { display: flex; }
.spin-modal-box {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 14px; padding: 2rem 1.75rem 1.5rem; max-width: 340px; width: 90%;
/* ── Glücksrad-Ergebnis (unterhalb des Rads) ── */
.wheel-result {
margin-top: 1.25rem; width: 290px;
display: flex; flex-direction: column; align-items: center;
gap: 1rem; text-align: center;
gap: 0.6rem; text-align: center;
}
.spin-result-icon { font-size: 3rem; }
.spin-result-title { font-size: 1.1rem; font-weight: 700; margin: 0; }
.spin-result-title { font-size: 1.1rem; font-weight: 700; margin: 0; color: var(--color-text); }
.spin-result-desc { font-size: 0.9rem; color: var(--color-muted); line-height: 1.5; margin: 0; }
/* ── Hygiene-Modal ── */
@@ -268,6 +261,17 @@
border-radius: 8px; cursor: pointer; width: auto; transition: background 0.15s;
}
.btn-lock-unlock:hover { background: rgba(46,204,113,0.28); }
.btn-confirm-unlock {
background: rgba(52,152,219,0.15); border: 1px solid rgba(52,152,219,0.45);
color: #3498db; font-size: 0.88rem; font-weight: 600; padding: 0.55rem 1.25rem;
border-radius: 8px; cursor: pointer; width: auto; transition: background 0.15s;
}
.btn-confirm-unlock:hover { background: rgba(52,152,219,0.28); }
.unlock-confirmed-badge {
font-size: 0.82rem; color: #2ecc71;
border: 1px solid rgba(46,204,113,0.35); border-radius: 7px;
padding: 0.35rem 0.75rem; display: inline-block;
}
.btn-lock-beenden {
background: transparent; border: 1px solid rgba(200,50,50,0.45);
color: rgba(200,50,50,0.7); font-size: 0.82rem; padding: 0.5rem 1rem;
@@ -368,16 +372,12 @@
<div class="wheel-canvas-wrap">
<div class="wheel-pointer-top"></div>
<canvas id="wheelCanvas" width="290" height="290"></canvas>
</div>
</div>
<!-- Spin-Result-Modal -->
<div class="spin-modal-backdrop" id="spinModal">
<div class="spin-modal-box">
<div class="spin-result-icon" id="spinResultIcon"></div>
<h3 class="spin-result-title" id="spinResultTitle"></h3>
<p class="spin-result-desc" id="spinResultDesc"></p>
<button onclick="closeSpinModal()" style="width:100%;margin-top:0.25rem;">OK</button>
<div class="wheel-result" id="wheelResult" style="display:none;">
<div class="spin-result-icon" id="spinResultIcon"></div>
<h3 class="spin-result-title" id="spinResultTitle"></h3>
<p class="spin-result-desc" id="spinResultDesc"></p>
<button onclick="closeSpinModal()" style="width:100%;margin-top:0.25rem;">OK</button>
</div>
</div>
</div>
@@ -403,6 +403,12 @@
<div id="hygieneScrambleCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);font-family:monospace;"></div>
<button id="hygienePhase2Btn" onclick="startHygieneScramble()" style="background:var(--color-primary);color:#fff;border:none;padding:0.55rem 1.5rem;border-radius:8px;cursor:pointer;font-weight:600;">OK</button>
</div>
<!-- Phase 3: TTLock-Kommunikation -->
<div id="hygienePhase3" style="display:none;width:100%;flex-direction:column;align-items:center;gap:0.5rem;padding:0.5rem 0;">
<div style="font-size:1.5rem;">🔗</div>
<div style="font-size:0.9rem;color:var(--color-muted);">Kommuniziere mit TTLock-Server…</div>
<div style="font-size:0.8rem;color:var(--color-muted);font-family:monospace;">Bitte warten</div>
</div>
</div>
</div>
@@ -430,6 +436,19 @@
</div>
</div>
<!-- Unlock-Result-Modal -->
<div class="warn-modal-backdrop" id="unlockResultModal">
<div class="warn-modal-box" style="align-items:center;text-align:center;">
<div style="font-size:2.5rem;line-height:1;">🔓</div>
<h3 style="margin:0;">Lock entsperrt</h3>
<div id="unlockResultBody" style="width:100%;"></div>
<div class="warn-modal-actions" style="justify-content:center;flex-wrap:wrap;margin-top:0.5rem;">
<button class="btn-cancel" onclick="document.getElementById('unlockResultModal').classList.remove('open')">Schließen</button>
<button style="background:#27ae60;border:none;color:#fff;padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;font-weight:600;width:auto;" onclick="lockLoeschen()">Lock beenden</button>
</div>
</div>
</div>
<!-- Warn-Modal (Lock beenden) -->
<div class="warn-modal-backdrop" id="warnModal">
<div class="warn-modal-box">
@@ -637,6 +656,7 @@
};
function startWheelSpin() {
document.getElementById('wheelResult').style.display = 'none';
document.getElementById('wheelAnimModal').classList.add('open');
wheelAngle = Math.random() * 2 * Math.PI;
wheelResult = null;
@@ -673,9 +693,7 @@
wheelAnimState = 'done';
drawWheelFrame(canvas, wheelAngle);
setTimeout(() => {
document.getElementById('wheelAnimModal').classList.remove('open');
showSpinResult(wheelResult);
loadLock();
}, 750);
return;
}
@@ -777,7 +795,7 @@
panel.style.display = '';
if (lock.spinDue) {
cdEl.textContent = 'Jetzt fällig';
cdEl.textContent = 'Bereit';
cdEl.style.color = '#2ecc71';
btn.disabled = lock.isFrozen || lock.hygieneOpeningActive || false;
return;
@@ -792,7 +810,7 @@
function tick() {
const diff = target - Date.now();
if (diff <= 0) {
cdEl.textContent = 'Jetzt fällig';
cdEl.textContent = 'Bereit';
cdEl.style.color = '#2ecc71';
btn.disabled = lock.isFrozen || false;
clearInterval(spinTickInterval); spinTickInterval = null;
@@ -857,11 +875,13 @@
document.getElementById('spinResultIcon').textContent = info.icon;
document.getElementById('spinResultTitle').textContent = info.title;
document.getElementById('spinResultDesc').textContent = info.descFn(result);
document.getElementById('spinModal').classList.add('open');
document.getElementById('wheelResult').style.display = '';
}
function closeSpinModal() {
document.getElementById('spinModal').classList.remove('open');
document.getElementById('wheelAnimModal').classList.remove('open');
document.getElementById('wheelResult').style.display = 'none';
loadLock();
}
// ── Aufgaben-Panel ─────────────────────────────────────────────────────────
@@ -992,10 +1012,25 @@
async function endHygieneOpening() {
if (hygieneTickInterval) { clearInterval(hygieneTickInterval); hygieneTickInterval = null; }
if (_currentLock && _currentLock.controllType === 'TTLOCK') {
document.getElementById('hygienePhase1').style.display = 'none';
document.getElementById('hygienePhase3').style.display = 'flex';
}
const res = await fetch('/keyholder/timelock/' + lockId + '/hygiene/end', { method: 'POST' });
if (!res.ok) { alert('Fehler beim Beenden der Hygiene-Öffnung.'); return; }
if (!res.ok) {
document.getElementById('hygienePhase3').style.display = 'none';
document.getElementById('hygienePhase1').style.display = 'flex';
alert('Fehler beim Beenden der Hygiene-Öffnung.');
return;
}
const data = await res.json();
if (_currentLock && _currentLock.controllType === 'TTLOCK') {
closeHygieneModal();
return;
}
document.getElementById('hygienePhase1').style.display = 'none';
const phase2 = document.getElementById('hygienePhase2');
phase2.style.display = 'flex';
@@ -1169,9 +1204,32 @@
if (!area) return;
if (lock.timeUp) {
// Unlock-Button anzeigen
if (!lock.actualUnlockTime) {
area.innerHTML = `<button class="btn-lock-unlock" onclick="confirmUnlock()">🔓 Entsperren</button>`;
} else {
area.innerHTML = `<button class="btn-lock-unlock" onclick="openUnlockResultModal()">🔓 Lock abschließen</button>`;
}
} else if (lock.keyholderRequestedUnlock) {
const code = lock.unlockCode || '';
const isEmergency = lock.emergencyAutoUnlocked;
const title = isEmergency
? '🆘 Lock automatisch freigegeben'
: '🔓 Dein Keyholder hat das Lock freigegeben!';
const note = isEmergency
? `<div style="font-size:0.8rem;color:var(--color-muted);margin-top:0.4rem;">
Da dein Keyholder nicht innerhalb einer Stunde reagiert hat, wurde das Lock automatisch geöffnet. Es werden keine XP vergeben und kein Eintrag in die Historie gespeichert.
</div>`
: '';
area.innerHTML = `
<button class="btn-lock-unlock" onclick="lockOeffnen()">🔓 Lock öffnen</button>`;
<div style="background:rgba(46,204,113,0.06);border:1px solid rgba(46,204,113,0.3);border-radius:10px;padding:1rem 1.1rem;">
<div style="font-weight:700;font-size:0.95rem;color:#2ecc71;margin-bottom:0.6rem;">${title}</div>
<div style="font-size:0.72rem;font-weight:700;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.07em;margin-bottom:0.3rem;">Dein Entsperrcode</div>
<div style="font-size:2rem;font-weight:700;font-family:monospace;letter-spacing:0.2em;
background:rgba(233,69,96,0.08);border:1px solid rgba(233,69,96,0.25);
border-radius:8px;padding:0.5rem 1rem;color:var(--color-primary);text-align:center;">${code}</div>
${note}
<button class="btn-lock-beenden" style="margin-top:0.75rem;" onclick="lockLoeschen()">🔓 Lock beenden</button>
</div>`;
} else if (lock.testLock) {
area.innerHTML = `<button class="btn-lock-beenden" onclick="lockBeendenFragen()">🔓 Lock beenden</button>`;
} else if (lock.emergencyUnlockRequested) {
@@ -1188,11 +1246,40 @@
}
}
function lockOeffnen() {
const code = _currentLock ? (_currentLock.unlockCode || '') : '';
document.getElementById('warnModalUnlockCode').textContent = code || '(wird nach Bestätigung angezeigt)';
document.getElementById('warnModal').classList.add('open');
document.querySelector('#warnModal h3').textContent = '🔓 Lock öffnen?';
async function confirmUnlock() {
const btn = document.querySelector('#lockActionArea .btn-lock-unlock');
if (btn) btn.disabled = true;
try {
const res = await fetch('/keyholder/timelock/' + lockId + '/unlock-time', { method: 'PATCH' });
if (res.ok || res.status === 204) {
const r2 = await fetch('/keyholder/timelock/' + lockId);
if (r2.ok) _currentLock = await r2.json();
renderLockActionArea(_currentLock);
openUnlockResultModal();
} else {
if (btn) btn.disabled = false;
}
} catch(_) {
if (btn) btn.disabled = false;
}
}
function openUnlockResultModal() {
const lock = _currentLock;
const body = document.getElementById('unlockResultBody');
if (lock && lock.controllType === 'TRUST') {
body.innerHTML = `<p style="margin:0.5rem 0 0;color:#2ecc71;font-weight:600;font-size:1rem;">✓ Du kannst dich jetzt befreien.</p>`;
} else {
const code = (lock && lock.unlockCode) || '';
body.innerHTML = `
<div style="margin-top:0.75rem;">
<div style="font-size:0.75rem;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.07em;margin-bottom:0.4rem;">Entsperrcode</div>
<div style="font-size:2.2rem;font-weight:700;font-family:monospace;letter-spacing:0.22em;
background:rgba(233,69,96,0.08);border:1px solid rgba(233,69,96,0.25);
border-radius:8px;padding:0.6rem 1.4rem;color:var(--color-primary);">${code}</div>
</div>`;
}
document.getElementById('unlockResultModal').classList.add('open');
}
function lockBeendenFragen() {
@@ -1207,6 +1294,17 @@
async function lockLoeschen() {
closeWarnModal();
if (_currentLock && _currentLock.controllType === 'TTLOCK') {
document.getElementById('unlockResultBody').innerHTML = `
<div style="margin-top:0.75rem;display:flex;flex-direction:column;align-items:center;gap:0.5rem;">
<div style="font-size:1.5rem;">🔗</div>
<div style="font-size:0.9rem;color:var(--color-muted);">Kommuniziere mit TTLock-Server…</div>
<div style="font-size:0.8rem;color:var(--color-muted);font-family:monospace;">Bitte warten</div>
</div>`;
// Buttons im Modal ausblenden während kommuniziert wird
document.querySelector('#unlockResultModal .warn-modal-actions').style.display = 'none';
document.getElementById('unlockResultModal').classList.add('open');
}
try {
await fetch('/keyholder/timelock/' + lockId, { method: 'DELETE' });
} catch(_) { /* ignorieren */ }
@@ -1273,6 +1371,7 @@
closeWarnModal();
closeEmergencyModal();
closeSpinModal();
document.getElementById('unlockResultModal').classList.remove('open');
}
});

View File

@@ -304,6 +304,34 @@
flex-wrap: wrap;
}
/* ── Keyholder-Angebote Tab ── */
.kh-offer-card {
background:var(--color-card); border:1px solid var(--color-secondary);
border-radius:10px; padding:0.75rem 1rem; margin-bottom:0.6rem;
display:flex; align-items:center; gap:0.85rem;
}
.kh-offer-type-icon {
position:relative; width:2.2rem; height:2.2rem; flex-shrink:0;
display:flex; align-items:center; justify-content:center;
}
.kh-offer-type-icon .icon-base { font-size:1.8rem; line-height:1; }
.kh-offer-type-icon img.icon-base { width:1.8rem; height:1.8rem; object-fit:contain; }
.kh-offer-type-icon .icon-lock {
position:absolute; bottom:-2px; right:-4px;
font-size:1.5rem; line-height:1;
filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));
}
.kh-offer-body { flex:1; min-width:0; }
.kh-offer-name { font-weight:700; font-size:0.95rem; margin-bottom:0.2rem;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.kh-offer-meta { font-size:0.78rem; color:var(--color-muted); display:flex; flex-wrap:wrap; gap:0.4rem; }
.kh-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);
}
.kh-offer-badge.direct { background:rgba(46,204,113,0.12); border-color:rgba(46,204,113,0.3); color:#2ecc71; }
.kh-offer-badge.confirm { background:rgba(230,126,34,0.12); border-color:rgba(230,126,34,0.3); color:#e67e22; }
/* ── Comments (section container) ── */
.comments-section {
margin-top: 0.65rem;
@@ -406,11 +434,12 @@
</div>
</div>
<!-- Tabs: Feed | Pinnwand | Spielhistorie -->
<!-- Tabs: Feed | Pinnwand | Spielhistorie | Keyholder-Angebote -->
<div class="profil-tabs" style="margin-top:1.25rem;">
<button class="profil-tab-btn active" id="tabBtnPosts" onclick="switchProfilTab('posts', this)">Feed</button>
<button class="profil-tab-btn" id="tabBtnPinnwand" onclick="switchProfilTab('pinnwand', this)">Pinnwand</button>
<button class="profil-tab-btn" id="tabBtnGameHistory" onclick="switchProfilTab('gamehistory', this)">Spielhistorie</button>
<button class="profil-tab-btn" id="tabBtnKhOffers" onclick="switchProfilTab('khoffers', this)">Keyholder-Angebote</button>
</div>
<!-- Feed Tab (vorausgewählt) -->
@@ -435,6 +464,12 @@
<p id="gameHistoryEmpty" style="color:var(--color-muted);font-size:0.9rem;display:none;">Keine abgeschlossenen Locks vorhanden.</p>
</div>
<!-- Keyholder-Angebote Tab -->
<div class="profil-tab-panel" id="tab-khoffers">
<div id="khOffersList" style="margin-top:0.75rem;"></div>
<p id="khOffersEmpty" style="color:var(--color-muted);font-size:0.9rem;display:none;">Keine Keyholder-Angebote vorhanden.</p>
</div>
</div>
</div>
</div>
@@ -682,6 +717,7 @@
// ── Tab switching ──
let _gameHistoryLoaded = false;
let _khOffersLoaded = false;
function switchProfilTab(name, btn) {
document.querySelectorAll('.profil-tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
@@ -691,6 +727,10 @@
_gameHistoryLoaded = true;
loadGameHistory();
}
if (name === 'khoffers' && !_khOffersLoaded) {
_khOffersLoaded = true;
loadKhOffers();
}
// URL-QueryParam aktualisieren, damit der Tab nach F5 erhalten bleibt
const url = new URL(window.location.href);
if (name === 'posts') {
@@ -946,6 +986,49 @@
} catch(e) { list.innerHTML = ''; }
}
// ── Keyholder-Angebote ──
const KH_GENDER_LABELS = { WEIBLICH: 'Weiblich', MAENNLICH: 'Männlich', DIVERS: 'Divers' };
async function loadKhOffers() {
const list = document.getElementById('khOffersList');
const empty = document.getElementById('khOffersEmpty');
list.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;">Lädt…</p>';
try {
const res = await fetch('/keyholder-offers/user/' + targetUserId);
if (!res.ok) { list.innerHTML = ''; return; }
const offers = await res.json();
list.innerHTML = '';
if (offers.length === 0) { empty.style.display = ''; return; }
offers.forEach(o => list.appendChild(buildKhOfferCard(o)));
} catch(e) { list.innerHTML = ''; }
}
function buildKhOfferCard(o) {
const esc = s => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
const genderTags = (o.targetGenders && o.targetGenders.length > 0)
? o.targetGenders.map(g => `<span class="kh-offer-badge">${esc(KH_GENDER_LABELS[g] || g)}</span>`).join('')
: '<span class="kh-offer-badge">Alle</span>';
const modeBadge = o.directStart
? '<span class="kh-offer-badge direct">Direktstart</span>'
: '<span class="kh-offer-badge confirm">Mit Bestätigung</span>';
const typeIcon = o.templateType === 'TIMELOCK'
? `<div class="kh-offer-type-icon"><span class="icon-base">🕐</span><span class="icon-lock">🔒</span></div>`
: `<div class="kh-offer-type-icon"><img src="img/card.png" class="icon-base" alt="Karten-Lock"><span class="icon-lock">🔒</span></div>`;
const div = document.createElement('div');
div.className = 'kh-offer-card';
div.innerHTML = `
${typeIcon}
<div class="kh-offer-body">
<div class="kh-offer-name">${esc(o.templateName || 'Unbenannt')}</div>
<div class="kh-offer-meta">
${modeBadge} ${genderTags}
<span class="kh-offer-badge">✓ ${o.acceptanceCount}× angenommen</span>
</div>
</div>`;
return div;
}
async function postPinnwand() {
const ta = document.getElementById('pinnwandText');
const text = ta.value.trim();

View File

@@ -566,6 +566,18 @@
</select>
</div>
<!-- Profil bei Veröffentlichungen -->
<div class="settings-row">
<div class="settings-row-info">
<div class="settings-row-label">Profil bei Veröffentlichungen sichtbar</div>
<div class="settings-row-desc">Dein Name wird bei veröffentlichten Lock-Vorlagen angezeigt</div>
</div>
<select id="sv-veroeffentlichungen" onchange="doSave()">
<option value="true">Ja</option>
<option value="false">Nein</option>
</select>
</div>
<hr class="settings-separator">
<!-- Vorschau -->
@@ -955,13 +967,14 @@
if (!profRes.ok) return;
const profile = await profRes.json();
setValue('sv-grunddaten', profile.sichtbarkeitGrunddaten || 'ALLE');
setValue('sv-galerie', profile.sichtbarkeitGalerie || 'ALLE');
setValue('sv-freunde', profile.sichtbarkeitFreunde || 'ALLE');
setValue('sv-feed', profile.sichtbarkeitFeed || 'ALLE');
setValue('sv-pinnwand', profile.sichtbarkeitPinnwand || 'ALLE');
setValue('sv-xp', profile.sichtbarkeitXp || 'ALLE');
setValue('sv-lockhistorie', profile.sichtbarkeitLockhistorie || 'ALLE');
setValue('sv-grunddaten', profile.sichtbarkeitGrunddaten || 'ALLE');
setValue('sv-galerie', profile.sichtbarkeitGalerie || 'ALLE');
setValue('sv-freunde', profile.sichtbarkeitFreunde || 'ALLE');
setValue('sv-feed', profile.sichtbarkeitFeed || 'ALLE');
setValue('sv-pinnwand', profile.sichtbarkeitPinnwand || 'ALLE');
setValue('sv-xp', profile.sichtbarkeitXp || 'ALLE');
setValue('sv-lockhistorie', profile.sichtbarkeitLockhistorie || 'ALLE');
setValue('sv-veroeffentlichungen', profile.profilBeiVeroeffentlichungenSichtbar ? 'true' : 'false');
}
function setValue(id, value) {
@@ -971,13 +984,14 @@
async function doSave() {
const body = {
sichtbarkeitGrunddaten: document.getElementById('sv-grunddaten').value,
sichtbarkeitGalerie: document.getElementById('sv-galerie').value,
sichtbarkeitFreunde: document.getElementById('sv-freunde').value,
sichtbarkeitFeed: document.getElementById('sv-feed').value,
sichtbarkeitPinnwand: document.getElementById('sv-pinnwand').value,
sichtbarkeitXp: document.getElementById('sv-xp').value,
sichtbarkeitLockhistorie: document.getElementById('sv-lockhistorie').value,
sichtbarkeitGrunddaten: document.getElementById('sv-grunddaten').value,
sichtbarkeitGalerie: document.getElementById('sv-galerie').value,
sichtbarkeitFreunde: document.getElementById('sv-freunde').value,
sichtbarkeitFeed: document.getElementById('sv-feed').value,
sichtbarkeitPinnwand: document.getElementById('sv-pinnwand').value,
sichtbarkeitXp: document.getElementById('sv-xp').value,
sichtbarkeitLockhistorie: document.getElementById('sv-lockhistorie').value,
profilBeiVeroeffentlichungenSichtbar: document.getElementById('sv-veroeffentlichungen').value === 'true',
};
const res = await fetch('/user/me/privacy', {
method: 'PUT',

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

@@ -66,7 +66,7 @@ const CARD_DEFS = [
id: 'CUM',
img: '/img/card_cum.png',
name: 'Cum',
desc: 'Spezielle Karte.',
desc: 'Du wirst entsperrt, nutze diese Entsperrung um zu kommen. Je länger du brauchst, desto schlimmer.',
defMin: 0,
defMax: 0,
},
@@ -74,7 +74,7 @@ const CARD_DEFS = [
id: 'CUM_IN_CAGE',
img: '/img/card_cum_caged.png',
name: 'Cum in Cage',
desc: 'Spezielle Karte.',
desc: 'Komme in deinem Keuschheitsgürtel, wie du es anstellst ist deine Sache.',
defMin: 0,
defMax: 0,
},

View File

@@ -29,7 +29,9 @@
{ href: '/neulock.html', icon: I('NEW_LOCK'), label: 'Neues Lock', id: 'navChastityNeu' },
{ href: '#', icon: I('ACTIVE_LOCK'), label: 'Aktives Lock', id: 'navChastityAktiv' },
{ href: '/communityvotes.html', icon: I('VOTES'), label: 'Community Votes' },
{ href: '/meine-locks.html', icon: I('LOCK'), label: 'Meine Locks' },
{ href: '/meine-locks.html', icon: I('LOCK'), label: 'Meine Vorlagen' },
{ href: '/entdecken-vorlagen.html', icon: I('DISCOVER'), label: 'Entdecken' },
{ href: '/keyholder-finden.html', icon: I('FRIENDS'), label: 'Keyholder finden' },
{ href: '/keyholder.html', icon: I('KEY'), label: 'Keyholder' },
{ href: '/unlock-history.html', icon: I('HISTORY'), label: 'Code-Historie' },
]

View File

@@ -277,24 +277,25 @@
if (!res.ok) { body.innerHTML = '<div class="topbar-panel-hint">Keine Benachrichtigungen.</div>'; return; }
const notifs = await res.json();
if (!notifs.length) { body.innerHTML = '<div class="topbar-panel-hint">Keine neuen Benachrichtigungen.</div>'; return; }
body.innerHTML = `<div style="padding:0.3rem 1rem;text-align:right;">
<button onclick="window.__topbarMarkAllRead()" class="topbar-mark-all-btn">Alle gelesen</button>
</div>`;
body.innerHTML = '';
notifs.forEach(n => {
const el = document.createElement('div');
const tag = n.targetUrl ? 'a' : 'div';
const href = n.targetUrl ? `href="${esc(n.targetUrl)}"` : '';
const unread = !n.read;
el.innerHTML = `<${tag} ${href} class="topbar-panel-item topbar-notif-item${unread ? ' topbar-notif-item--unread' : ''}"
onclick="window.__topbarMarkNotifRead('${esc(n.id)}')">
${unread ? '<span class="topbar-notif-dot"></span>' : '<span style="width:7px;flex-shrink:0;"></span>'}
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">
${av}
<div class="topbar-panel-item-body">
<div style="font-size:0.85rem;line-height:1.4;${unread ? 'font-weight:600;' : ''}">${esc(n.text)}</div>
<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>'; }
}

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 ? '/activetimelock.html' : '/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

@@ -101,16 +101,154 @@
.tp-seg .tp-label { font-size:0.65rem; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.04em; }
.tp-colon { font-size:1.1rem; font-weight:700; color:var(--color-muted); margin-bottom:1rem; }
/* ── Tab-Navigation ── */
.kh-tabs { display:flex; gap:0; border-bottom:2px solid var(--color-secondary); margin-bottom:1.25rem; }
.kh-tab {
padding:0.55rem 1.1rem; font-size:0.9rem; font-weight:600; cursor:pointer;
border:none; background:none; color:var(--color-muted);
border-bottom:2px solid transparent; margin-bottom:-2px;
transition:color 0.15s, border-color 0.15s;
}
.kh-tab.active { color:var(--color-text); border-bottom-color:var(--color-primary); }
/* ── Vorlagen-Typ-Icon (wie in meine-locks.html) ── */
.template-type-icon {
position:relative; width:2.2rem; height:2.2rem; flex-shrink:0;
display:flex; align-items:center; justify-content:center;
}
.template-type-icon .icon-base { font-size:1.8rem; line-height:1; }
.template-type-icon img.icon-base { width:1.8rem; height:1.8rem; object-fit:contain; }
.template-type-icon .icon-lock {
position:absolute; bottom:-2px; right:-4px;
font-size:1.5rem; line-height:1;
filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));
}
/* ── Angebote-Tab ── */
.offer-card {
background:var(--color-card); border:1px solid var(--color-secondary);
border-radius:10px; padding:0.85rem 1rem; margin-bottom:0.6rem;
display:flex; align-items:center; gap:0.85rem;
}
.offer-card-body { flex:1; min-width:0; }
.offer-card-name { font-weight:700; font-size:0.95rem; margin-bottom:0.2rem;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.offer-card-meta { font-size:0.78rem; color:var(--color-muted); display:flex; flex-wrap:wrap; gap:0.4rem; }
.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-offer-del {
background:rgba(231,76,60,0.1); border:1px solid rgba(231,76,60,0.3); color:#e74c3c;
border-radius:7px; padding:0.3rem 0.65rem; font-size:0.8rem; cursor:pointer; flex-shrink:0; width:auto;
}
/* Combobox (Vorlage-Auswahl im Angebot-Modal) */
.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: 600;
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 { background: var(--color-secondary); }
.combo-empty { padding: 0.55rem 0.85rem; font-size: 0.85rem; color: var(--color-muted); font-style: italic; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom:1.25rem;">Keyholder</h1>
<h1 style="margin-bottom:1rem;">Keyholder</h1>
<!-- Meine Lockees -->
<div class="lock-list" id="locksGrid"></div>
<p class="empty-hint" id="locksEmpty" style="display:none;">Du bist aktuell bei keinem Lock als Keyholder eingetragen.</p>
<!-- Tab-Navigation -->
<div class="kh-tabs">
<button class="kh-tab active" onclick="switchTab('lockees')">Meine Lockees</button>
<button class="kh-tab" onclick="switchTab('offers')">Keyholder-Angebote</button>
</div>
<!-- Tab: Meine Lockees -->
<div id="tabLockees">
<div class="lock-list" id="locksGrid"></div>
<p class="empty-hint" id="locksEmpty" style="display:none;">Du bist aktuell bei keinem Lock als Keyholder eingetragen.</p>
</div>
<!-- Tab: Keyholder-Angebote -->
<div id="tabOffers" style="display:none;">
<div style="display:flex;justify-content:flex-end;margin-bottom:1rem;">
<button style="width:auto;padding:0.5rem 1.1rem;" onclick="openCreateOfferModal()">+ Angebot erstellen</button>
</div>
<div id="offersList"></div>
<p class="empty-hint" id="offersEmpty" style="display:none;">Du hast noch keine Keyholder-Angebote erstellt.</p>
<p id="offersLimitHint" style="display:none;font-size:0.82rem;color:var(--color-muted);margin-top:0.5rem;"></p>
</div>
</div>
</div>
<!-- Angebot-Erstellen-Modal -->
<div id="createOfferModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.65);z-index:500;align-items:center;justify-content:center;">
<div style="background:var(--color-card);border:1px solid var(--color-secondary);border-radius:14px;padding:1.5rem;max-width:440px;width:92%;max-height:88vh;overflow-y:auto;display:flex;flex-direction:column;gap:1rem;position:relative;">
<button onclick="closeCreateOfferModal()" 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 id="offerModalTitle" style="margin:0;font-size:1.05rem;">🔑 Keyholder-Angebot erstellen</h3>
<!-- Template-Auswahl -->
<div>
<div style="font-size:0.72rem;font-weight:700;color:var(--color-primary);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.4rem;">Vorlage</div>
<div class="combo-wrap" id="offerTemplateCombo">
<input type="text" id="offerTemplateInput" placeholder="Vorlage suchen…" autocomplete="off"
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;">
<div class="combo-dropdown" id="offerTemplateDropdown"></div>
<input type="hidden" id="offerTemplateValue">
</div>
</div>
<!-- Ziel-Geschlechter -->
<div>
<div style="font-size:0.72rem;font-weight:700;color:var(--color-primary);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.5rem;">Richtet sich an</div>
<div style="display:flex;flex-direction:column;gap:0.35rem;">
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.88rem;cursor:pointer;">
<input type="checkbox" id="offerGenderAll" onchange="toggleAllGenders(this)" style="width:auto;"> Alle Geschlechter
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.88rem;cursor:pointer;">
<input type="checkbox" name="offerGender" value="WEIBLICH" style="width:auto;"> Weiblich
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.88rem;cursor:pointer;">
<input type="checkbox" name="offerGender" value="MAENNLICH" style="width:auto;"> Männlich
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.88rem;cursor:pointer;">
<input type="checkbox" name="offerGender" value="DIVERS" style="width:auto;"> Divers
</label>
</div>
</div>
<!-- Startmodus -->
<div>
<div style="font-size:0.72rem;font-weight:700;color:var(--color-primary);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.5rem;">Startmodus</div>
<div style="display:flex;flex-direction:column;gap:0.35rem;">
<label style="display:flex;align-items:flex-start;gap:0.5rem;font-size:0.88rem;cursor:pointer;line-height:1.4;">
<input type="radio" name="offerStartMode" value="direct" checked style="width:auto;margin-top:2px;">
<span><strong>Direktstart</strong> Lock startet sofort, du wirst als Keyholder eingetragen</span>
</label>
<label style="display:flex;align-items:flex-start;gap:0.5rem;font-size:0.88rem;cursor:pointer;line-height:1.4;">
<input type="radio" name="offerStartMode" value="confirm" style="width:auto;margin-top:2px;">
<span><strong>Mit Bestätigung</strong> du erhältst eine Einladung und kannst annehmen oder ablehnen</span>
</label>
</div>
</div>
<div id="createOfferError" 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="closeCreateOfferModal()" 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="createOfferSubmitBtn" onclick="submitCreateOffer()" style="padding:0.5rem 1.25rem;border-radius:7px;font-size:0.88rem;font-weight:600;width:auto;" id="createOfferSubmitBtn">Erstellen</button>
</div>
</div>
</div>
@@ -409,6 +547,17 @@
body.dataset.loaded = '1';
attachDetailListeners(body, lockId);
}
// Listenkarte line3 aktualisieren
const line3 = card ? card.querySelector('.lock-card-line3') : null;
if (line3) {
const startDate = d.startTime ? new Date(d.startTime).toLocaleDateString('de-DE') : '';
const frozenBadge = (d.isFrozenByKeyholder || d.isFrozen) ? ' · ❄️ Eingefroren' : '';
if (d.lockType === 'TIMELOCK') {
line3.textContent = `⏱ TimeLock · seit ${startDate}${frozenBadge}`;
} else {
line3.textContent = `🃏 ${d.totalCards} Karten · seit ${startDate}${frozenBadge}`;
}
}
} catch(e) { if (body) body.textContent = 'Fehler beim Laden.'; }
}
@@ -463,6 +612,24 @@
html += `</div>`;
}
// Einfrieren
html += `<div class="detail-section"><div class="detail-section-title">Einfrieren</div>`;
if (d.isFrozen) {
const fu = d.frozenUntil ? new Date(d.frozenUntil).toLocaleString('de-DE') : 'unbegrenzt';
html += `<div class="detail-row">
<span class="detail-label">❄️ Eingefroren bis</span>
<span class="detail-value danger">${fu}</span>
</div>
<div style="margin-top:0.5rem;">
<button onclick="openUnfreezeModal('${d.lockId}')" style="background:rgba(52,152,219,0.15);border:1px solid rgba(52,152,219,0.4);color:#3498db;padding:0.4rem 0.9rem;border-radius:7px;cursor:pointer;font-size:0.82rem;font-weight:600;width:auto;">❄️ Entfrieren</button>
</div>`;
} else {
html += `<div style="margin-top:0.25rem;">
<button onclick="openFreezeModal('${d.lockId}')" style="background:rgba(52,152,219,0.15);border:1px solid rgba(52,152,219,0.4);color:#3498db;padding:0.4rem 0.9rem;border-radius:7px;cursor:pointer;font-size:0.82rem;font-weight:600;width:auto;">❄️ Einfrieren</button>
</div>`;
}
html += `</div>`;
// Gestartet am
if (d.startTime) {
html += `<div style="font-size:0.78rem;color:var(--color-muted);margin-top:0.5rem;">
@@ -804,18 +971,7 @@
});
if (res.ok || res.status === 202) {
closeVerificationModal();
const cardEl = document.querySelector(`[data-lock-id="${lockId}"]`);
if (cardEl) {
const body = cardEl.querySelector('.lock-detail-body');
const detailRes = await fetch('/keyholder/as-keyholder/' + lockId);
if (detailRes.ok) {
const updated = await detailRes.json();
lockDetailCache[lockId] = updated;
body.innerHTML = buildDetailHtml(updated);
body.dataset.loaded = '1';
attachDetailListeners(body, lockId);
}
}
await reloadLockDetail(lockId);
} else {
btnUp.disabled = btnDown.disabled = false;
}
@@ -903,19 +1059,7 @@
});
if (res.ok || res.status === 204) {
closeCardModal();
// Detail neu laden
const cardEl = document.querySelector(`[data-lock-id="${lockId}"]`);
if (cardEl) {
const body = cardEl.querySelector('.lock-detail-body');
const detailRes = await fetch('/keyholder/as-keyholder/' + lockId);
if (detailRes.ok) {
const d = await detailRes.json();
lockDetailCache[lockId] = d;
body.innerHTML = buildDetailHtml(d);
body.dataset.loaded = '1';
attachDetailListeners(body, lockId);
}
}
await reloadLockDetail(lockId);
} else {
const data = await res.json().catch(() => ({}));
const errEl = document.getElementById('cardEditError');
@@ -1270,8 +1414,11 @@
}
errEl.style.display = 'none';
const frozenUntil = new Date(Date.now() + minutes * 60000).toISOString().slice(0, 19);
const freezeEndpoint = lockTypeMap[lockId] === 'TIMELOCK'
? `/keyholder/timelock/as-keyholder/${lockId}/freeze`
: `/keyholder/as-keyholder/${lockId}/freeze`;
try {
const res = await fetch(`/keyholder/as-keyholder/${lockId}/freeze`, {
const res = await fetch(freezeEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frozenUntil })
@@ -1302,8 +1449,11 @@
async function submitUnfreeze() {
const lockId = unfreezeTargetLockId;
const unfreezeEndpoint = lockTypeMap[lockId] === 'TIMELOCK'
? `/keyholder/timelock/as-keyholder/${lockId}/freeze`
: `/keyholder/as-keyholder/${lockId}/freeze`;
try {
const res = await fetch(`/keyholder/as-keyholder/${lockId}/freeze`, { method: 'DELETE' });
const res = await fetch(unfreezeEndpoint, { method: 'DELETE' });
if (res.ok || res.status === 204) {
closeUnfreezeModal();
await reloadLockDetail(lockId);
@@ -1311,26 +1461,6 @@
} catch(e) { console.error(e); }
}
async function reloadLockDetail(lockId) {
const cardEl = document.querySelector(`[data-lock-id="${lockId}"]`);
if (!cardEl) return;
const body = cardEl.querySelector('.lock-detail-body');
const detailRes = await fetch('/keyholder/as-keyholder/' + lockId);
if (detailRes.ok) {
const updated = await detailRes.json();
lockDetailCache[lockId] = updated;
body.innerHTML = buildDetailHtml(updated);
body.dataset.loaded = '1';
attachDetailListeners(body, lockId);
// Listenkarte line3 aktualisieren
const line3 = cardEl.querySelector('.lock-card-line3');
if (line3) {
const startDate = updated.startTime ? new Date(updated.startTime).toLocaleDateString('de-DE') : '';
const frozenBadge = updated.isFrozenByKeyholder ? ' · ❄️ Eingefroren' : '';
line3.textContent = `🃏 ${updated.totalCards} Karten · seit ${startDate}${frozenBadge}`;
}
}
}
async function chooseTaskForLock(choiceId, taskIndex, lockId) {
try {
@@ -1454,8 +1584,9 @@
} catch(e) { console.error(e); }
}
// Initial laden, dann ggf. per URL-Parameter ein Lock vorauswählen
// Initial laden, dann ggf. per URL-Parameter ein Lock vorauswählen / Tab wiederherstellen
loadLocks().then(() => {
restoreTabFromUrl();
const params = new URLSearchParams(window.location.search);
const preselect = params.get('lockId');
if (preselect) {
@@ -1474,6 +1605,247 @@
if (lockId) reloadLockDetail(lockId);
});
}, 60000);
// ── Tab-Switching ──────────────────────────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.kh-tab').forEach(b => b.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('tabLockees').style.display = tab === 'lockees' ? '' : 'none';
document.getElementById('tabOffers').style.display = tab === 'offers' ? '' : 'none';
if (tab === 'offers') loadOffers();
const url = new URL(window.location.href);
if (tab === 'lockees') url.searchParams.delete('tab');
else url.searchParams.set('tab', tab);
history.replaceState(null, '', url.toString());
}
function restoreTabFromUrl() {
const tab = new URLSearchParams(window.location.search).get('tab');
if (tab === 'offers') {
const btn = document.querySelector('.kh-tab:nth-child(2)');
if (btn) { btn.click(); }
}
}
// ── Keyholder-Angebote ────────────────────────────────────────────────────
let _offerTemplates = [];
let _editOfferId = null;
function esc2(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
const GENDER_LABELS = { WEIBLICH: 'Weiblich', MAENNLICH: 'Männlich', DIVERS: 'Divers' };
async function loadOffers() {
const res = await fetch('/keyholder-offers/mine');
if (!res.ok) return;
const offers = await res.json();
const list = document.getElementById('offersList');
const empty = document.getElementById('offersEmpty');
list.innerHTML = '';
if (offers.length === 0) { empty.style.display = ''; return; }
empty.style.display = 'none';
offers.forEach(o => list.appendChild(buildOfferCard(o)));
}
function buildOfferCard(o) {
const genderTags = (o.targetGenders && o.targetGenders.length > 0)
? o.targetGenders.map(g => `<span class="offer-badge">${esc2(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 typeIcon = o.templateType === 'TIMELOCK'
? `<div class="template-type-icon"><span class="icon-base">🕐</span><span class="icon-lock">🔒</span></div>`
: `<div class="template-type-icon"><img src="img/card.png" class="icon-base" alt="Karten-Lock"><span class="icon-lock">🔒</span></div>`;
const div = document.createElement('div');
div.className = 'offer-card';
div.style.cursor = 'pointer';
div.innerHTML = `
${typeIcon}
<div class="offer-card-body" style="flex:1;min-width:0;">
<div class="offer-card-name">${esc2(o.templateName)}</div>
<div class="offer-card-meta">
${modeBadge} ${genderTags}
<span class="offer-badge">✓ ${o.acceptanceCount}× angenommen</span>
</div>
</div>
<button class="btn-offer-del" onclick="event.stopPropagation();deleteOffer('${o.id}')">Entfernen</button>`;
div.addEventListener('click', () => openEditOfferModal(o));
return div;
}
async function deleteOffer(id) {
if (!confirm('Angebot wirklich entfernen?')) return;
const res = await fetch(`/keyholder-offers/${id}`, { method: 'DELETE' });
if (res.ok || res.status === 204) loadOffers();
else alert('Fehler beim Entfernen.');
}
// ── Angebot erstellen / bearbeiten ────────────────────────────────────────
async function _openOfferModal() {
document.getElementById('createOfferError').style.display = 'none';
document.getElementById('createOfferSubmitBtn').disabled = false;
// Templates laden (eigene + abonnierte)
const [ownRes, subRes] = await Promise.all([
fetch('/templates/mine'),
fetch('/templates/subscribed')
]);
const own = ownRes.ok ? await ownRes.json() : [];
const sub = subRes.ok ? await subRes.json() : [];
const ownIds = new Set(own.map(t => t.templateId));
_offerTemplates = [...own, ...sub.filter(t => !ownIds.has(t.templateId))];
setupOfferTemplateCombo();
}
async function openCreateOfferModal() {
_editOfferId = null;
document.getElementById('offerModalTitle').textContent = '🔑 Keyholder-Angebot erstellen';
document.getElementById('createOfferSubmitBtn').textContent = 'Erstellen';
await _openOfferModal();
document.getElementById('offerTemplateInput').value = '';
document.getElementById('offerTemplateValue').value = '';
document.getElementById('offerGenderAll').checked = false;
document.querySelectorAll('input[name="offerGender"]').forEach(cb => { cb.checked = false; cb.disabled = false; });
document.querySelector('input[name="offerStartMode"][value="direct"]').checked = true;
document.getElementById('createOfferModal').style.display = 'flex';
}
async function openEditOfferModal(o) {
_editOfferId = o.id;
document.getElementById('offerModalTitle').textContent = '✏️ Keyholder-Angebot bearbeiten';
document.getElementById('createOfferSubmitBtn').textContent = 'Speichern';
await _openOfferModal();
// Vorlage vorauswählen
const tpl = _offerTemplates.find(t => t.templateId === o.templateId);
if (tpl) {
const badge = tpl.lockType === 'TIMELOCK' ? '⏱' : '🃏';
document.getElementById('offerTemplateInput').value = badge + ' ' + (tpl.name || 'Unbenannte Vorlage');
document.getElementById('offerTemplateValue').value = tpl.templateId;
} else {
// Template nicht mehr in eigenen/abonnierten → Fallback: Name anzeigen
document.getElementById('offerTemplateInput').value = o.templateName || '';
document.getElementById('offerTemplateValue').value = o.templateId;
}
// Geschlechter vorauswählen
const genders = o.targetGenders || [];
const allSelected = genders.length === 0;
document.getElementById('offerGenderAll').checked = allSelected;
document.querySelectorAll('input[name="offerGender"]').forEach(cb => {
cb.checked = !allSelected && genders.includes(cb.value);
cb.disabled = allSelected;
});
// Startmodus
const modeVal = o.directStart ? 'direct' : 'confirm';
document.querySelector(`input[name="offerStartMode"][value="${modeVal}"]`).checked = true;
document.getElementById('createOfferModal').style.display = 'flex';
}
function setupOfferTemplateCombo() {
const input = document.getElementById('offerTemplateInput');
const dropdown = document.getElementById('offerTemplateDropdown');
const hidden = document.getElementById('offerTemplateValue');
// Vorherige Listener entfernen durch Klonen
const newInput = input.cloneNode(true);
input.parentNode.replaceChild(newInput, input);
function renderDropdown(query) {
const q = query.toLowerCase().trim();
const filtered = q
? _offerTemplates.filter(t => (t.name || '').toLowerCase().includes(q))
: _offerTemplates;
dropdown.innerHTML = '';
if (filtered.length === 0) {
dropdown.innerHTML = '<div class="combo-empty">Keine Vorlagen gefunden.</div>';
} else {
filtered.forEach(t => {
const badge = t.lockType === 'TIMELOCK' ? '⏱' : '🃏';
const label = t.name || 'Unbenannte Vorlage';
const div = document.createElement('div');
div.className = 'combo-option';
div.innerHTML = `${badge} ${label}`;
div.addEventListener('mousedown', e => {
e.preventDefault();
hidden.value = t.templateId;
newInput.value = badge + ' ' + label;
dropdown.classList.remove('open');
});
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
newInput.addEventListener('input', () => { hidden.value = ''; renderDropdown(newInput.value); });
newInput.addEventListener('focus', () => renderDropdown(newInput.value));
newInput.addEventListener('blur', () => {
setTimeout(() => {
dropdown.classList.remove('open');
if (!hidden.value) newInput.value = '';
}, 150);
});
}
function closeCreateOfferModal() {
document.getElementById('createOfferModal').style.display = 'none';
_editOfferId = null;
}
function toggleAllGenders(allCb) {
document.querySelectorAll('input[name="offerGender"]').forEach(cb => { cb.checked = false; cb.disabled = allCb.checked; });
}
async function submitCreateOffer() {
const templateId = document.getElementById('offerTemplateValue').value;
if (!templateId) { showOfferError('Bitte eine Vorlage auswählen.'); return; }
const allGenders = document.getElementById('offerGenderAll').checked;
const targetGenders = allGenders
? []
: Array.from(document.querySelectorAll('input[name="offerGender"]:checked')).map(cb => cb.value);
const directStart = document.querySelector('input[name="offerStartMode"]:checked').value === 'direct';
const btn = document.getElementById('createOfferSubmitBtn');
btn.disabled = true;
const url = _editOfferId ? `/keyholder-offers/${_editOfferId}` : '/keyholder-offers';
const method = _editOfferId ? 'PATCH' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templateId, targetGenders, directStart })
});
if (res.ok) {
closeCreateOfferModal();
loadOffers();
} else if (res.status === 403) {
const d = await res.json().catch(() => ({}));
showOfferError(d.error === 'offer_limit_reached'
? 'Limit erreicht upgrade auf Premium für unbegrenzte Angebote.'
: 'Keine Berechtigung.');
btn.disabled = false;
} else {
showOfferError(_editOfferId ? 'Fehler beim Speichern.' : 'Fehler beim Erstellen.');
btn.disabled = false;
}
}
function showOfferError(msg) {
const el = document.getElementById('createOfferError');
el.textContent = msg;
el.style.display = '';
}
</script>
<!-- Aufgaben-Auswahl-Popup -->

View File

@@ -4,7 +4,7 @@
<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>Meine Locks xXx Sphere</title>
<title>Meine Vorlagen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
@@ -37,7 +37,7 @@
.modal-backdrop {
display:none; position:fixed; inset:0;
background:rgba(0,0,0,0.65); z-index:400;
align-items:flex-start; overflow-y:auto; padding:2rem 0;
align-items:flex-start; justify-content:center; overflow-y:auto; padding:2rem 1rem;
}
.modal-backdrop.open { display:flex; }
.modal-box {
@@ -234,13 +234,36 @@
<div class="main">
<div class="content">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;gap:1rem;flex-wrap:wrap;">
<h1 style="margin:0;">Meine Locks</h1>
<h1 style="margin:0;">Meine Vorlagen</h1>
<button onclick="openModal()" style="width:auto;padding:0.55rem 1.2rem;">+ Vorlage erstellen</button>
</div>
<div class="template-list" id="templateList"></div>
<p class="empty-hint" id="listEmpty" style="display:none;">Noch keine Vorlagen vorhanden.</p>
<div id="scrollSentinel" style="height:1px;"></div>
<h2 style="margin:2rem 0 1rem;">Abonnierte Vorlagen</h2>
<div class="template-list" id="subscribedList"></div>
<p id="subscribedEmpty" style="display:none;color:var(--color-muted);font-size:0.9rem;">Keine abonnierten Vorlagen vorhanden.</p>
<div id="subscribedSentinel" style="height:1px;"></div>
</div>
</div>
<!-- Veröffentlichen-Modal -->
<div class="modal-backdrop" id="publishModal" onclick="closePublishModal()">
<div class="modal-box" style="max-width:380px;max-height:none;" onclick="event.stopPropagation()">
<h2 style="margin:0 0 1rem;">Vorlage veröffentlichen</h2>
<p style="font-size:0.88rem;color:var(--color-muted);margin:0 0 1rem;line-height:1.5;">
Die Vorlage wird öffentlich sichtbar und kann von anderen Nutzern abonniert werden.
Wenn du die Veröffentlichung entfernst, werden alle Abonnements gelöscht angefertigte Kopien bleiben erhalten.
</p>
<p style="font-size:0.82rem;color:var(--color-muted);margin:0 0 1.25rem;line-height:1.5;">
Ob dein Name angezeigt wird, hängt von deiner Datenschutzeinstellung
<em>„Profil bei Veröffentlichungen sichtbar"</em> ab.
</p>
<div style="display:flex;gap:0.6rem;justify-content:flex-end;">
<button onclick="closePublishModal()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);width:auto;padding:0.5rem 1.1rem;">Abbrechen</button>
<button id="publishConfirmBtn" onclick="confirmPublish()" style="width:auto;padding:0.5rem 1.25rem;">Veröffentlichen</button>
</div>
</div>
</div>
@@ -804,8 +827,7 @@
const rect = document.querySelector('.content')?.getBoundingClientRect();
if (!rect) return;
const box = document.querySelector('.modal-box');
box.style.width = rect.width + 'px';
box.style.marginLeft = rect.left + 'px';
box.style.width = Math.min(rect.width, 720) + 'px';
}
function openModal(template) {
@@ -1036,6 +1058,12 @@
firstError = firstError || document.getElementById('modalError');
}
// Validierung: Unbegrenztes FREEZE ohne UNFREEZE
if (wheelEntries.some(e => e.type === 'FREEZE') && !wheelEntries.some(e => e.type === 'UNFREEZE')) {
showModalError('Das Glücksrad enthält ein unbegrenztes Einfrieren (FREEZE), aber keinen Auftau-Eintrag (UNFREEZE). Bitte einen UNFREEZE-Eintrag hinzufügen.');
firstError = firstError || document.getElementById('modalError');
}
// Validierung: Mindestaufgaben pro Tag (Zeitkollision)
const taskMode = document.querySelector('input[name="modalTaskMode"]:checked')?.value || 'RANDOM';
if (taskEvery && minTasksPerDay) {
@@ -1118,6 +1146,12 @@
? `alle ${fmtMinutes(t.hygineOpeningEveryMinites)}, ${fmtMinutes(t.hygineOpeningDurationMinutes)} offen`
: 'Keine';
const metaLine = `Hygiene: ${hygText} · Verif.: ${t.requiresVerification ? 'Ja' : 'Nein'}${t.taskCount ? ' · ' + t.taskCount + ' Aufgabe(n)' : ''}`;
const publishedBadge = t.published
? `<span style="font-size:0.7rem;background:rgba(46,204,113,0.15);border:1px solid rgba(46,204,113,0.4);color:#2ecc71;border-radius:5px;padding:0.15rem 0.5rem;margin-left:0.4rem;">🌐 Veröffentlicht</span>`
: '';
const publishBtn = t.published
? `<button onclick="event.stopPropagation();unpublishTemplate('${t.templateId}','${esc(t.name||'')}')" style="background:rgba(46,204,113,0.1);border:1px solid rgba(46,204,113,0.35);color:#2ecc71;width:auto;padding:0.35rem 0.75rem;font-size:0.8rem;">🌐 Entfernen</button>`
: `<button onclick="event.stopPropagation();openPublishModal('${t.templateId}')" style="background:rgba(52,152,219,0.1);border:1px solid rgba(52,152,219,0.35);color:#3498db;width:auto;padding:0.35rem 0.75rem;font-size:0.8rem;">🌐 Veröffentlichen</button>`;
const card = document.createElement('div');
card.className = 'template-card';
@@ -1129,10 +1163,11 @@
<span class="icon-lock">🔒</span>
</div>
<div style="flex:1; min-width:0;">
<div class="template-name">${esc(t.name || 'Ohne Namen')}</div>
<div class="template-name">${esc(t.name || 'Ohne Namen')}${publishedBadge}</div>
<div class="template-meta">${metaLine}</div>
</div>
<div class="template-actions">
${publishBtn}
<button onclick="event.stopPropagation();deleteTemplate('${t.lockType}','${t.templateId}','${esc(t.name||'')}')" style="background:rgba(231,76,60,0.12);border:1px solid rgba(231,76,60,0.35);color:#e74c3c;">✕ Löschen</button>
</div>
</div>`;
@@ -1164,6 +1199,7 @@
document.getElementById('templateList').innerHTML = '';
document.getElementById('listEmpty').style.display = 'none';
loadNextPage();
loadSubscribedTemplates();
}
async function editTemplate(id) {
@@ -1172,6 +1208,94 @@
openModal(await res.json());
}
// ── Abonnierte Vorlagen ──
async function loadSubscribedTemplates() {
try {
const res = await fetch('/templates/subscribed');
if (!res.ok) return;
const list = await res.json();
const el = document.getElementById('subscribedList');
el.innerHTML = '';
if (!list.length) {
document.getElementById('subscribedEmpty').style.display = '';
return;
}
document.getElementById('subscribedEmpty').style.display = 'none';
list.forEach(t => appendSubscribedCard(t));
} catch(e) { console.error(e); }
}
function appendSubscribedCard(t) {
const list = document.getElementById('subscribedList');
const isCard = t.lockType === 'CARDLOCK';
const typeIcon = isCard
? `<img src="img/card.png" class="icon-base" alt="Karten-Lock">`
: `<span class="icon-base">🕐</span>`;
const authorText = t.authorName ? ` · von ${esc(t.authorName)}` : '';
const subsText = `${t.subscriberCount} Abonnent(en)`;
const card = document.createElement('div');
card.className = 'template-card';
card.innerHTML = `
<div class="template-card-header">
<div class="template-type-icon">
${typeIcon}
<span class="icon-lock">🔒</span>
</div>
<div style="flex:1; min-width:0;">
<div class="template-name">${esc(t.name || 'Ohne Namen')}</div>
<div class="template-meta">${isCard ? '🃏 Karten-Lock' : '⏱ Zeit-Lock'}${authorText} · ${subsText}</div>
</div>
<div class="template-actions">
<button onclick="forkTemplate('${t.templateId}')" style="background:rgba(52,152,219,0.1);border:1px solid rgba(52,152,219,0.35);color:#3498db;width:auto;padding:0.35rem 0.75rem;font-size:0.8rem;">📋 Kopie</button>
<button onclick="cancelSubscription('${t.templateId}','${esc(t.name||'')}')" style="background:rgba(231,76,60,0.1);border:1px solid rgba(231,76,60,0.35);color:#e74c3c;width:auto;padding:0.35rem 0.75rem;font-size:0.8rem;">✕ Abo</button>
</div>
</div>`;
list.appendChild(card);
}
async function forkTemplate(id) {
const btn = event.target;
btn.disabled = true;
try {
const res = await fetch(`/templates/${id}/fork`, { method: 'POST' });
if (res.ok) { resetList(); }
else { alert('Kopie konnte nicht erstellt werden.'); btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
async function cancelSubscription(id, name) {
if (!confirm(`Abonnement von „${name}" wirklich kündigen?`)) return;
const res = await fetch(`/templates/${id}/subscribe`, { method: 'DELETE' });
if (res.ok || res.status === 204) loadSubscribedTemplates();
}
// ── Veröffentlichen ──
let _publishTemplateId = null;
function openPublishModal(id) {
_publishTemplateId = id;
document.getElementById('publishModal').style.display = 'flex';
}
function closePublishModal() {
document.getElementById('publishModal').style.display = 'none';
_publishTemplateId = null;
}
async function confirmPublish() {
if (!_publishTemplateId) return;
const btn = document.getElementById('publishConfirmBtn');
btn.disabled = true;
try {
const res = await fetch(`/templates/${_publishTemplateId}/publish`, { method: 'PATCH' });
if (res.ok || res.status === 204) { closePublishModal(); resetList(); }
else { alert('Fehler beim Veröffentlichen.'); btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
async function unpublishTemplate(id, name) {
if (!confirm(`Veröffentlichung von „${name}" entfernen? Alle Abonnements werden gelöscht.`)) return;
const res = await fetch(`/templates/${id}/publish`, { method: 'DELETE' });
if (res.ok || res.status === 204) resetList();
}
// ── IntersectionObserver für Infinite Scroll ──
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadNextPage();

View File

@@ -360,17 +360,19 @@
myUserId = user.userId;
myUserName = user.name;
// Subscription + Templates + TTLock-Config parallel laden
// Subscription + Templates (eigene + abonnierte) + TTLock-Config parallel laden
try {
const [cardTpls, timeTpls, subData, ttlCfg] = await Promise.all([
fetch('/cardlock/templates').then(r => r.ok ? r.json() : []),
fetch('/timelock/templates').then(r => r.ok ? r.json() : []),
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 = [
...cardTpls.map(t => ({ ...t, _type: 'cardlock' })),
...timeTpls.map(t => ({ ...t, _type: 'timelock' }))
...ownTpls.map(toEntry),
...subTpls.filter(t => !ownIds.has(t.templateId)).map(toEntry)
];
hasPaidSubscription = !!(subData && subData.subscriptionType === 'PREMIUM');
ttlockReady = !!(ttlCfg && ttlCfg.testSuccessful);
@@ -717,6 +719,11 @@
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;
}