feat: Einzelne Nachrichten als Link teilen
Backend:
- SharedMessageRecord Model + shared_messages DB-Tabelle
- SharedEndpoint: POST /api/messages/{id}/share, GET /api/shared/{token},
GET /api/shared (Liste), DELETE /api/shared/{token}
- Auth-Ausnahme für GET /api/shared/{token} (öffentlich ohne Login)
- DB-Methoden: CreateSharedMessage, GetSharedMessage, GetChainMessagesUpTo
Frontend:
- Share-Button (fa-share-alt) an jeder Nachricht
- Kontextmenü: Einzelnachricht oder mit Thread-Kontext teilen
- Link wird automatisch in Zwischenablage kopiert
- Öffentliche Shared-Page (#shared/{token}) ohne Login
- Markdown-Rendering in der geteilten Ansicht
- CSS für Share-Button, Share-Menü und Shared-Page
This commit is contained in:
1 parent
4e915f76af
commit
be29984b25
12 files changed
+591
-8
No files matched your search
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Gott.Extensions;
|
||||
using Gott.Models;
|
||||
using Gott.Services;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Gott.Endpoints;
|
||||
|
||||
public static class SharedEndpoint
|
||||
{
|
||||
public static void MapSharedEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// Authentifizierte Endpunkte
|
||||
app.MapPost("/api/messages/{messageId}/share", HandleShareMessage);
|
||||
app.MapGet("/api/shared", HandleListShares);
|
||||
app.MapDelete("/api/shared/{token}", HandleDeleteShare);
|
||||
|
||||
// Öffentlicher Endpunkt (Auth-Ausnahme in Middleware)
|
||||
app.MapGet("/api/shared/{token}", HandleGetShared);
|
||||
}
|
||||
|
||||
private static async Task HandleShareMessage(HttpContext ctx, DatabaseService db)
|
||||
{
|
||||
var userId = ctx.GetUserId();
|
||||
var messageIdStr = ctx.Request.RouteValues["messageId"]?.ToString();
|
||||
if (!long.TryParse(messageIdStr, out var messageId))
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Invalid messageId" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Body lesen (optional: includeContext)
|
||||
bool includeContext = false;
|
||||
try
|
||||
{
|
||||
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
||||
if (body.TryGetProperty("includeContext", out var ic)) includeContext = ic.GetBoolean();
|
||||
}
|
||||
catch { /* kein Body oder ungültig – defaults verwenden */ }
|
||||
|
||||
// Nachricht existiert und gehört dem User?
|
||||
var message = await db.GetMessage(messageId);
|
||||
if (message == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Message not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Chain holen → Session prüfen
|
||||
var chain = await db.GetChain(message.ChainId);
|
||||
if (chain == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Chain not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
var session = await db.GetSession(chain.SessionId);
|
||||
if (session == null || session.UserId != userId)
|
||||
{
|
||||
ctx.Response.StatusCode = 403;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Not authorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
var shared = await db.CreateSharedMessage(messageId, userId, includeContext);
|
||||
await ctx.Response.WriteAsJsonAsync(new { token = shared.ShareToken });
|
||||
}
|
||||
|
||||
private static async Task HandleGetShared(HttpContext ctx, DatabaseService db)
|
||||
{
|
||||
var token = ctx.Request.RouteValues["token"]?.ToString();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
return;
|
||||
}
|
||||
|
||||
var shared = await db.GetSharedMessage(token);
|
||||
if (shared == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Share not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Ablaufdatum prüfen
|
||||
if (!string.IsNullOrEmpty(shared.ExpiresAt) &&
|
||||
DateTime.TryParse(shared.ExpiresAt, out var expires) &&
|
||||
expires < DateTime.UtcNow)
|
||||
{
|
||||
ctx.Response.StatusCode = 410;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Share expired" });
|
||||
return;
|
||||
}
|
||||
|
||||
var message = await db.GetMessage(shared.MessageId);
|
||||
if (message == null)
|
||||
{
|
||||
ctx.Response.StatusCode = 404;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Message not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Chain-Info holen
|
||||
var chain = await db.GetChain(message.ChainId);
|
||||
|
||||
if (shared.IncludeContext)
|
||||
{
|
||||
// Alle Messages der Chain bis zu dieser Nachricht laden
|
||||
var contextMessages = await db.GetChainMessagesUpTo(message.ChainId, shared.MessageId);
|
||||
|
||||
// Nur text-Messages zurückgeben (keine tool_use/tool_result)
|
||||
var textMessages = contextMessages
|
||||
.Where(m => m.Type == "text" && m.DeletedOn == null)
|
||||
.Select(m => new
|
||||
{
|
||||
m.Id,
|
||||
m.Role,
|
||||
m.Content,
|
||||
m.CreatedAt
|
||||
})
|
||||
.ToList();
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
mode = "context",
|
||||
messages = textMessages,
|
||||
model = chain?.Model,
|
||||
createdAt = shared.CreatedAt
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// Einzelne Nachricht
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
mode = "single",
|
||||
message = new
|
||||
{
|
||||
message.Id,
|
||||
message.Role,
|
||||
message.Content,
|
||||
message.CreatedAt
|
||||
},
|
||||
model = chain?.Model,
|
||||
createdAt = shared.CreatedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleListShares(HttpContext ctx, DatabaseService db)
|
||||
{
|
||||
var userId = ctx.GetUserId();
|
||||
var shares = await db.GetSharedMessagesByUser(userId);
|
||||
await ctx.Response.WriteAsJsonAsync(shares);
|
||||
}
|
||||
|
||||
private static async Task HandleDeleteShare(HttpContext ctx, DatabaseService db)
|
||||
{
|
||||
var userId = ctx.GetUserId();
|
||||
var token = ctx.Request.RouteValues["token"]?.ToString();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
return;
|
||||
}
|
||||
|
||||
await db.DeleteSharedMessage(token, userId);
|
||||
ctx.Response.StatusCode = 204;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public class AuthMiddleware
|
||||
|
||||
// Static files, login and telegram webhook don't require auth
|
||||
var isTelegramWebhook = path.StartsWith("/api/telegram/webhook/") && ctx.Request.Method == "POST";
|
||||
if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook)
|
||||
var isSharedGet = path.StartsWith("/api/shared/") && ctx.Request.Method == "GET";
|
||||
if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook || isSharedGet)
|
||||
{
|
||||
await _next(ctx);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Gott.Models;
|
||||
|
||||
public class SharedMessageRecord
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string ShareToken { get; set; }
|
||||
public long MessageId { get; set; }
|
||||
public string UserId { get; set; }
|
||||
public bool IncludeContext { get; set; }
|
||||
public string ExpiresAt { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
}
|
||||
@@ -80,5 +80,6 @@ app.MapSettingsEndpoints();
|
||||
app.MapTelegramEndpoints();
|
||||
app.MapCronjobEndpoints();
|
||||
app.MapKreaEndpoints();
|
||||
app.MapSharedEndpoints();
|
||||
|
||||
app.Run("http://0.0.0.0:19000");
|
||||
@@ -228,6 +228,20 @@ public class DatabaseService
|
||||
CREATE INDEX IF NOT EXISTS idx_cronjobs_user ON cronjobs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cronjobs_next_run ON cronjobs(next_run_at);
|
||||
");
|
||||
|
||||
conn.Execute(@"
|
||||
CREATE TABLE IF NOT EXISTS shared_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_token TEXT NOT NULL UNIQUE,
|
||||
message_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
include_context INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_shared_messages_token ON shared_messages(share_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_shared_messages_message ON shared_messages(message_id);
|
||||
");
|
||||
}
|
||||
|
||||
private void TryAddColumn(SqliteConnection conn, string table, string column, string type)
|
||||
@@ -236,6 +250,65 @@ public class DatabaseService
|
||||
catch { /* column already exists */ }
|
||||
}
|
||||
|
||||
// ── Shared Messages ──
|
||||
|
||||
public async Task<SharedMessageRecord> CreateSharedMessage(long messageId, string userId, bool includeContext, string expiresAt = null)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var record = new SharedMessageRecord
|
||||
{
|
||||
ShareToken = Guid.NewGuid().ToString("N"),
|
||||
MessageId = messageId,
|
||||
UserId = userId,
|
||||
IncludeContext = includeContext,
|
||||
ExpiresAt = expiresAt,
|
||||
CreatedAt = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT INTO shared_messages (share_token, message_id, user_id, include_context, expires_at, created_at)
|
||||
VALUES (@ShareToken, @MessageId, @UserId, @IncludeContext, @ExpiresAt, @CreatedAt)
|
||||
", record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public async Task<SharedMessageRecord> GetSharedMessage(string shareToken)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<SharedMessageRecord>(
|
||||
"SELECT * FROM shared_messages WHERE share_token = @shareToken", new { shareToken });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SharedMessageRecord>> GetSharedMessagesByUser(string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QueryAsync<SharedMessageRecord>(
|
||||
"SELECT * FROM shared_messages WHERE user_id = @userId ORDER BY created_at DESC", new { userId });
|
||||
}
|
||||
|
||||
public async Task DeleteSharedMessage(string shareToken, string userId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"DELETE FROM shared_messages WHERE share_token = @shareToken AND user_id = @userId",
|
||||
new { shareToken, userId });
|
||||
}
|
||||
|
||||
public async Task<MessageRecord> GetMessage(long messageId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<MessageRecord>(
|
||||
"SELECT * FROM messages WHERE id = @messageId", new { messageId });
|
||||
}
|
||||
|
||||
public async Task<List<MessageRecord>> GetChainMessagesUpTo(string chainId, long messageId)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var result = await conn.QueryAsync<MessageRecord>(
|
||||
"SELECT * FROM messages WHERE chain_id = @chainId AND id <= @messageId ORDER BY id ASC",
|
||||
new { chainId, messageId });
|
||||
return result.AsList();
|
||||
}
|
||||
|
||||
// ── Agents ──
|
||||
|
||||
public async Task<AgentRecord> CreateAgent(AgentRecord agent)
|
||||
|
||||
+119
-4
@@ -414,7 +414,8 @@
|
||||
|
||||
.msg-reply-btn,
|
||||
.msg-delete-btn,
|
||||
.msg-mark-btn {
|
||||
.msg-mark-btn,
|
||||
.msg-share-btn {
|
||||
position: absolute;
|
||||
top: var(--badge-spacing);
|
||||
opacity: 0;
|
||||
@@ -428,20 +429,25 @@
|
||||
}
|
||||
|
||||
.msg-reply-btn {
|
||||
right: calc(var(--badge-spacing) + 4em + var(--badge-spacing));
|
||||
right: calc(var(--badge-spacing) + 6em + var(--badge-spacing) * 1.5);
|
||||
}
|
||||
|
||||
.msg-delete-btn {
|
||||
right: calc(var(--badge-spacing) + 2em + var(--badge-spacing) / 2);
|
||||
right: calc(var(--badge-spacing) + 4em + var(--badge-spacing));
|
||||
}
|
||||
|
||||
.msg-mark-btn {
|
||||
right: calc(var(--badge-spacing) + 2em + var(--badge-spacing) / 2);
|
||||
}
|
||||
|
||||
.msg-share-btn {
|
||||
right: var(--badge-spacing);
|
||||
}
|
||||
|
||||
.msg:hover .msg-reply-btn,
|
||||
.msg:hover .msg-delete-btn,
|
||||
.msg:hover .msg-mark-btn {
|
||||
.msg:hover .msg-mark-btn,
|
||||
.msg:hover .msg-share-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -858,3 +864,112 @@
|
||||
object-fit: contain;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
/* Share Menu */
|
||||
|
||||
.share-menu {
|
||||
background: var(--f2-bg);
|
||||
border: var(--border-width) solid var(--f2-border);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 0.25);
|
||||
min-width: 12em;
|
||||
}
|
||||
|
||||
.share-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--badge-spacing);
|
||||
padding: var(--menu-item-padding);
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
.share-menu-item:hover {
|
||||
background: var(--f3-bg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Shared Page (öffentliche Ansicht) */
|
||||
|
||||
.shared-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
max-width: var(--page-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.shared-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--box-spacing);
|
||||
padding: var(--page-padding);
|
||||
border-bottom: var(--border-width) solid var(--f2-border);
|
||||
}
|
||||
|
||||
.shared-logo {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
.shared-label {
|
||||
opacity: var(--semi-transparent);
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
.shared-content {
|
||||
flex: 1;
|
||||
padding: var(--page-padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--badge-spacing);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.shared-content .msg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.shared-loading,
|
||||
.shared-error {
|
||||
text-align: center;
|
||||
padding: 3em 1em;
|
||||
opacity: var(--semi-transparent);
|
||||
}
|
||||
|
||||
.shared-error {
|
||||
color: var(--dangerous);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.shared-meta {
|
||||
text-align: center;
|
||||
padding: var(--box-padding);
|
||||
opacity: var(--semi-transparent);
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
.shared-footer {
|
||||
padding: var(--page-padding);
|
||||
text-align: center;
|
||||
border-top: var(--border-width) solid var(--f2-border);
|
||||
}
|
||||
|
||||
.shared-cta {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.shared-cta:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -415,6 +415,19 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-shared">
|
||||
<div class="shared-page">
|
||||
<div class="shared-header">
|
||||
<span class="shared-logo">// gott</span>
|
||||
<span class="shared-label">Geteilte Nachricht</span>
|
||||
</div>
|
||||
<div class="shared-content" id="sharedContent">
|
||||
<div class="shared-loading"><i class="fa fa-spinner fa-spin"></i> Lade...</div>
|
||||
</div>
|
||||
<div class="shared-footer"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="js/lib/marked.min.js"></script>
|
||||
<script src="js/lib/codemirror.min.js"></script>
|
||||
<script src="js/lib/cm-mode-xml.min.js"></script>
|
||||
@@ -446,6 +459,7 @@
|
||||
<script src="js/pages/login-page.js"></script>
|
||||
<script src="js/pages/settings-page.js"></script>
|
||||
<script src="js/pages/profile-page.js"></script>
|
||||
<script src="js/pages/shared-page.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -374,6 +374,34 @@ async function apiRunCronjob(id) {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── Shared Message API ──
|
||||
|
||||
async function apiShareMessage(messageId, includeContext = false) {
|
||||
const resp = await fetch(`/api/messages/${messageId}/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ includeContext })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Teilen fehlgeschlagen');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiGetShared(token) {
|
||||
const resp = await fetch(`/api/shared/${token}`);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Geteilte Nachricht nicht gefunden');
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiDeleteShare(token) {
|
||||
return fetch(`/api/shared/${token}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── Backup API ──
|
||||
|
||||
async function apiBackupDb() {
|
||||
|
||||
+10
-3
@@ -28,6 +28,14 @@ function applySettings() {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Auth-Check: Eingeloggt?
|
||||
const { page, id } = parseHash();
|
||||
|
||||
// Shared-Page braucht kein Login
|
||||
if (page === 'shared') {
|
||||
await navigateTo('shared', { id }, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await apiMe();
|
||||
if (!user) {
|
||||
await navigateTo('login', {}, { replace: true });
|
||||
@@ -39,7 +47,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
applySettings();
|
||||
loadClaudeStatus();
|
||||
|
||||
const { page, id } = parseHash();
|
||||
var target = page === 'login' ? 'dashboard' : page;
|
||||
await navigateTo(target, { id }, { replace: true });
|
||||
});
|
||||
@@ -84,8 +91,8 @@ window.addEventListener('popstate', async (e) => {
|
||||
id = parsed.id;
|
||||
}
|
||||
|
||||
// Login-Seite immer erlauben, alles andere nur wenn eingeloggt
|
||||
if (page !== 'login') {
|
||||
// Login- und Shared-Seite immer erlauben, alles andere nur wenn eingeloggt
|
||||
if (page !== 'login' && page !== 'shared') {
|
||||
var user = await apiMe();
|
||||
if (!user) {
|
||||
await navigateTo('login', {}, { replace: true });
|
||||
|
||||
@@ -189,6 +189,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
|
||||
_addReplyButton(div);
|
||||
_addDeleteButton(div, msg.id, msg.deletedOn);
|
||||
_addMarkButton(div, msg.id, msg.isMarked);
|
||||
_addShareButton(div, msg.id);
|
||||
fragment.appendChild(div);
|
||||
if (msg.attachmentCount > 0) {
|
||||
// Prüfe ob Bilder dabei sind (Content enthält [Bild: ...])
|
||||
@@ -217,6 +218,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) {
|
||||
_addReplyButton(div);
|
||||
_addDeleteButton(div, msg.id, msg.deletedOn);
|
||||
_addMarkButton(div, msg.id, msg.isMarked);
|
||||
_addShareButton(div, msg.id);
|
||||
fragment.appendChild(div);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// ── Shared Message Page (öffentlich, kein Login nötig) ──
|
||||
|
||||
registerPage('shared', {
|
||||
init: async (params) => {
|
||||
const token = params.id;
|
||||
const container = document.getElementById('sharedContent');
|
||||
if (!token || !container) {
|
||||
container.innerHTML = '<div class="shared-error"><i class="fa fa-times-circle"></i> Ungültiger Link</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiGetShared(token);
|
||||
container.innerHTML = '';
|
||||
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
// Mehrere Nachrichten (Thread mit Kontext)
|
||||
data.messages.forEach(msg => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + (msg.role === 'user' ? 'user' : 'assistant') + ' markdown';
|
||||
if (msg.role === 'user') {
|
||||
renderUserContent(div, msg.content);
|
||||
} else {
|
||||
div.innerHTML = renderMarkdown(msg.content);
|
||||
parseFluentEmojis(div);
|
||||
wrapKreaGalleries(div);
|
||||
}
|
||||
container.appendChild(div);
|
||||
});
|
||||
} else if (data.message) {
|
||||
// Einzelne Nachricht
|
||||
const msg = data.message;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + (msg.role === 'user' ? 'user' : 'assistant') + ' markdown';
|
||||
if (msg.role === 'user') {
|
||||
renderUserContent(div, msg.content);
|
||||
} else {
|
||||
div.innerHTML = renderMarkdown(msg.content);
|
||||
parseFluentEmojis(div);
|
||||
wrapKreaGalleries(div);
|
||||
}
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
// Timestamp anzeigen
|
||||
if (data.createdAt) {
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'shared-meta';
|
||||
const d = new Date(data.createdAt);
|
||||
meta.innerHTML = '<i class="fa fa-clock-o"></i> Geteilt am ' + d.toLocaleDateString('de-DE') + ' um ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
container.appendChild(meta);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
container.innerHTML = '<div class="shared-error"><i class="fa fa-times-circle"></i> ' + esc(err.message) + '</div>';
|
||||
}
|
||||
},
|
||||
destroy: () => {}
|
||||
});
|
||||
@@ -218,6 +218,95 @@ function _addMarkButton(div, messageId, isMarked) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Share Button ──
|
||||
|
||||
function _addShareButton(div, messageId) {
|
||||
if (!messageId) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'msg-share-btn icon-btn';
|
||||
btn.innerHTML = '<i class="fa fa-share-alt"></i>';
|
||||
btn.title = 'Nachricht teilen';
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
// Kontextmenü: Einzelnachricht oder mit Kontext?
|
||||
const mode = await _showShareMenu(btn);
|
||||
if (!mode) return; // abgebrochen
|
||||
|
||||
const result = await apiShareMessage(messageId, mode === 'context');
|
||||
const url = location.origin + '/#shared/' + result.token;
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast('Link kopiert!', 'success');
|
||||
} catch (err) {
|
||||
showToast('Fehler: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
// Nach dem Mark-Button einfügen
|
||||
const markBtn = div.querySelector('.msg-mark-btn');
|
||||
if (markBtn) {
|
||||
markBtn.after(btn);
|
||||
} else {
|
||||
const deleteBtn = div.querySelector('.msg-delete-btn');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.after(btn);
|
||||
} else {
|
||||
div.prepend(btn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _showShareMenu(anchorBtn) {
|
||||
return new Promise((resolve) => {
|
||||
// Wenn schon ein Menü offen, schließen
|
||||
const existing = document.querySelector('.share-menu');
|
||||
if (existing) { existing.remove(); resolve(null); return; }
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'share-menu';
|
||||
menu.innerHTML = `
|
||||
<button class="share-menu-item" data-mode="single">
|
||||
<i class="fa fa-comment-o"></i> Einzelne Nachricht
|
||||
</button>
|
||||
<button class="share-menu-item" data-mode="context">
|
||||
<i class="fa fa-comments-o"></i> Mit Kontext (Thread)
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Positionierung
|
||||
const rect = anchorBtn.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.top = (rect.bottom + 4) + 'px';
|
||||
menu.style.left = rect.left + 'px';
|
||||
menu.style.zIndex = '10000';
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
function cleanup() {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', outsideClick);
|
||||
}
|
||||
|
||||
function outsideClick(e) {
|
||||
if (!menu.contains(e.target)) {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
menu.querySelectorAll('.share-menu-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const mode = item.dataset.mode;
|
||||
cleanup();
|
||||
resolve(mode);
|
||||
});
|
||||
});
|
||||
|
||||
// Beim nächsten Klick außerhalb schließen
|
||||
setTimeout(() => document.addEventListener('click', outsideClick), 10);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Reply Button ──
|
||||
|
||||
function _addReplyButton(div) {
|
||||
@@ -354,6 +443,7 @@ function addUserBubble(text, streamed = false, timestamp = '', messageId = null,
|
||||
_addReplyButton(div);
|
||||
_addDeleteButton(div, messageId, deletedOn);
|
||||
_addMarkButton(div, messageId, isMarked);
|
||||
_addShareButton(div, messageId);
|
||||
chatEl().appendChild(div);
|
||||
scrollDown();
|
||||
}
|
||||
@@ -371,6 +461,7 @@ function addAssistantBubble(text, timestamp = '', messageId = null, isMarked = f
|
||||
_addReplyButton(div);
|
||||
_addDeleteButton(div, messageId, deletedOn);
|
||||
_addMarkButton(div, messageId, isMarked);
|
||||
_addShareButton(div, messageId);
|
||||
chatEl().appendChild(div);
|
||||
scrollDown();
|
||||
return div;
|
||||
|
||||
Reference in new issue
Block a user