bleeter/html/script.js

1962 lines
90 KiB
JavaScript
Raw Permalink Normal View History

Soziales Netz im Spiel: Feed, Werbung, Markt, Kalender und Gewerbe Jeder Spieler waehlt sich ein Handle mit @, Unternehmen und Behoerden bekommen eigene Profile, fuer die sie Mitarbeiter freigeben. Verwaltet wird ueber dieselbe Anmeldung wie das Webhosting: wer am IC-Computer als Anbieter angemeldet ist, richtet Unternehmensprofile ein. Damit gibt es genau eine Stelle, an der Verwaltungsrechte haengen - ein zweites Rechtesystem daneben waere eine zweite Stelle, an der man jemanden zu entziehen vergisst. Bewusst getrennt: wer fuer ein Unternehmen schreiben darf, kann sich nicht selbst zum Verwalter machen. Behoben gegenueber dem Ausgangsstand: - Die Resource startete nicht. Eine harte Abhaengigkeit zeigte auf eine Resource, die es nicht gibt - das verhindert den Start vollstaendig. - Das Schema war gegenueber dem Code stehengeblieben: eine Tabelle fehlte ganz, sechs Spalten fehlten. Jede Registrierung eines Handles scheiterte deshalb mit einem SQL-Fehler. Zwei Migrationen ziehen das nach. - Die Identitaet kommt jetzt von ESX statt von einem Charaktersystem, das hier nicht laeuft. Ohne das findet das Mailsystem die Postfaecher nicht. - Das Fenster hatte keinen Schliessknopf, nur ESC. Im Computerfenster ist diese Taste aber schon vergeben. - Bilder laden mit referrerpolicy="no-referrer": Bilderdienste sperren Hotlinks anhand der Herkunft, und die eines NUI kennen sie nicht. - Der Schluessel fuer den Bilder-Upload steht nicht mehr im Code, sondern in der server.cfg (set bleeter_imgbb_key). Er gehoert nicht in ein oeffentlich einsehbares Repository. Neu: Unternehmensprofile und Mitarbeiterfreigabe, ueber Netzwerkereignisse und Konsolenbefehle. Dazu eine Oberflaeche im IC-Computer, die dieselbe Gestaltung benutzt - die Stildatei wird dafuer mechanisch gekapselt, statt sie nachzubauen. Enthaelt README.md mit Einrichtung, Rechten und den Fallstricken.
2026-08-09 12:00:48 +00:00
const resourceName = typeof GetParentResourceName === 'function' ? GetParentResourceName() : 'bleeter';
const state = {
visible: false,
mode: 'phone', // 'phone' | 'desktop'
page: 'feed',
data: null,
fallback: true,
likedPosts: new Set([201]),
commentsOpen: new Set(),
searchQuery: '',
selectedCalendarDay: 3,
marketQuery: '',
marketSort: 'newest',
likeDeltas: {},
postComments: {},
likedComments: new Set(),
commentLikeDeltas: {},
editingProfile: false,
pendingUploadTarget: null,
composeMedia: {},
viewedProfileHandle: null
};
const fallbackData = {
account: {
displayName: 'Zoey Reed',
mailAddress: 'zoeyreed@mail.ls'
},
navigation: [],
profiles: [
{
id: 1,
profile_type: 'private',
handle: 'zoeyreed',
display_name: 'Zoey Reed',
avatar_url: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=256&h=256&fit=crop',
banner_url: 'https://images.unsplash.com/photo-1519608487953-e999c86e7455?w=1200&h=320&fit=crop',
bio: 'Los Santos, Kaffee, Geschichten und viel zu viele Benachrichtigungen.',
is_verified: false
},
{
id: 2,
profile_type: 'small_business',
handle: 'reed.events',
display_name: 'Reed Events',
avatar_url: 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?w=256&h=256&fit=crop',
banner_url: 'https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=1200&h=320&fit=crop',
bio: 'Kleine Events, grosse Abende.',
is_verified: true
}
],
activeProfileId: 1,
profileDirectory: [],
posts: [
{
id: 101,
feed_type: 'home',
author: {
handle: 'lasky',
display_name: 'A.J. Lasky III',
avatar_url: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=256&h=256&fit=crop',
is_verified: false
},
body: '"Er ist ein stiller Waechter .... ein wachsamer Beschuetzer. Ein dunkler Ritter" - Jim Gordon',
media_url: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1100&h=680&fit=crop',
created_at: 'Heute, 22:15',
likes: 12,
comments: 3
},
{
id: 102,
feed_type: 'home',
author: {
handle: 'm4ttr1x',
display_name: '@m4ttr1x',
avatar_url: 'https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=256&h=256&fit=crop',
is_verified: false
},
body: 'Wir haben zugesehen. ShitInvaders Sicherheit? Laecherlich. Ihr System ist genauso kaputt wie ihre Versprechen.\n\nDas hier ist euer Weckruf.',
created_at: '28.12.2024, 22:21:53',
likes: 1,
comments: 0
},
{
id: 201,
feed_type: 'advertising',
author: {
handle: 'lsmd',
display_name: 'Los Santos Medical Department',
avatar_url: 'https://images.unsplash.com/photo-1584515933487-779824d29309?w=256&h=256&fit=crop',
is_verified: true
},
body: 'Heute Blutspendeaktion am Pillbox Medical Center. Kommt vorbei, Los Santos.',
created_at: 'Heute, 19:40',
likes: 44,
comments: 8
}
],
businesses: [
{
handle: 'dmv',
display_name: 'Department of Motorvehicles',
avatar_url: 'https://images.unsplash.com/photo-1511919884226-fd3cad34687c?w=256&h=256&fit=crop',
banner_url: 'https://images.unsplash.com/photo-1503376780353-7e6692767b70?w=1000&h=260&fit=crop',
status: 'open',
is_verified: true
},
{
handle: 'doj',
display_name: 'Department of Justice',
avatar_url: 'https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=256&h=256&fit=crop',
banner_url: 'https://images.unsplash.com/photo-1453945619913-79ec89a82c51?w=1000&h=260&fit=crop',
status: 'open',
is_verified: true
},
{
handle: 'gruppe6',
display_name: 'Gruppe 6',
avatar_url: 'https://images.unsplash.com/photo-1517048676732-d65bc937f952?w=256&h=256&fit=crop',
banner_url: 'https://images.unsplash.com/photo-1521791136064-7986c2920216?w=1000&h=260&fit=crop',
status: 'closed',
is_verified: false
},
{
handle: 'herrkutz',
display_name: 'Herr Kutz',
avatar_url: 'https://images.unsplash.com/photo-1503951914875-452162b0f3f1?w=256&h=256&fit=crop',
banner_url: 'https://images.unsplash.com/photo-1585747860715-2ba37e788b70?w=1000&h=260&fit=crop',
status: 'closed',
is_verified: true
}
],
suggestions: [
{ handle: 'lsmd', display_name: 'Los Santos Medical Department' },
{ handle: 'dynasty8', display_name: 'Dynasty 8' },
{ handle: 'usms', display_name: 'US Marshal Service' }
],
trends: [
{ tag: '#gruppe6', posts: 77 },
{ tag: '#lalintera', posts: 43 },
{ tag: '#JoinTheTeam', posts: 39 },
{ tag: '#LSFD', posts: 39 }
],
users: [
{
handle: 'zoeyreed',
display_name: 'Zoey Reed',
avatar_url: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=256&h=256&fit=crop',
profile_type: 'private',
can_be_blocked: true
},
{
handle: 'zoey.reed',
display_name: 'Zoey Reed',
avatar_url: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=256&h=256&fit=crop',
profile_type: 'private',
can_be_blocked: true
},
{
handle: 'z_reed',
display_name: 'Z. Reed',
avatar_url: 'https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?w=256&h=256&fit=crop',
profile_type: 'private',
can_be_blocked: true
},
{
handle: 'lasky',
display_name: 'A.J. Lasky III',
avatar_url: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=256&h=256&fit=crop',
profile_type: 'private',
can_be_blocked: true
}
],
social: {
following: [
{ handle: 'lasky', display_name: 'A.J. Lasky III', avatar_url: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=256&h=256&fit=crop', profile_type: 'private', can_be_blocked: true }
],
followers: [],
blocked: []
},
marketplace: [
{ id: 1, title: 'BMX in gutem Zustand', body: 'Schwarzes BMX, kaum gefahren. Abholung in Vespucci.', price: 450, price_label: '$450', created: 4, can_delete: true, author: { handle: 'zoeyreed', display_name: 'Zoey Reed', avatar_url: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=256&h=256&fit=crop', email: 'zoeyreed@mail.ls', is_verified: false } },
{ id: 2, title: 'Catering fuer kleine Feiern', body: 'Fingerfood und Getraenke fuer Events bis 25 Personen.', price: 1200, price_label: '$1200', created: 2, can_delete: false, author: { handle: 'reed.events', display_name: 'Reed Events', avatar_url: 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?w=256&h=256&fit=crop', email: 'reed.events@mail.ls', is_verified: true } },
{ id: 3, title: 'Reinigung nach Clubabend', body: 'Diskret, schnell, mit eigenem Equipment.', price: 800, price_label: '$800', created: 1, can_delete: false, author: { handle: 'cleanls', display_name: 'Clean LS', avatar_url: '', email: 'cleanls@mail.ls', is_verified: false } }
],
calendarDays: [
{ offset: 0, date: '2026-05-19', title: 'Dienstag 19.05.2026', events: [{ id: 1, time: '18:00', title: 'LSPD Coffe with Cops', location: '', author: 'CoolBeans', can_delete: false }] },
{ offset: 1, date: '2026-05-20', title: 'Mittwoch 20.05.2026', events: [] },
{ offset: 2, date: '2026-05-21', title: 'Donnerstag 21.05.2026', events: [] },
{ offset: 3, date: '2026-05-22', title: 'Freitag 22.05.2026', events: [{ id: 2, time: '20:00', title: 'Happy Hour', location: 'Craftbar', author: 'Craftbar', can_delete: false }, { id: 3, time: '21:00', title: 'All you can eat', location: '', author: 'Burgershot', can_delete: false }] },
{ offset: 4, date: '2026-05-23', title: 'Samstag 23.05.2026', events: [{ id: 4, time: '21:00', title: 'Geocaching Legionsquar', location: '', author: 'Lifeinvader', can_delete: false }] },
{ offset: 5, date: '2026-05-24', title: 'Sonntag 24.05.2026', events: [] },
{ offset: 6, date: '2026-05-25', title: 'Montag 25.05.2026', events: [] },
{ offset: 7, date: '2026-05-26', title: 'Dienstag 26.05.2026', events: [] },
{ offset: 8, date: '2026-05-27', title: 'Mittwoch 27.05.2026', events: [] },
{ offset: 9, date: '2026-05-28', title: 'Donnerstag 28.05.2026', events: [] },
{ offset: 10, date: '2026-05-29', title: 'Freitag 29.05.2026', events: [] },
{ offset: 11, date: '2026-05-30', title: 'Samstag 30.05.2026', events: [] },
{ offset: 12, date: '2026-05-31', title: 'Sonntag 31.05.2026', events: [] },
{ offset: 13, date: '2026-06-01', title: 'Montag 01.06.2026', events: [] },
{ offset: 14, date: '2026-06-02', title: 'Dienstag 02.06.2026', events: [] },
{ offset: 15, date: '2026-06-03', title: 'Mittwoch 03.06.2026', events: [] },
{ offset: 16, date: '2026-06-04', title: 'Donnerstag 04.06.2026', events: [] },
{ offset: 17, date: '2026-06-05', title: 'Freitag 05.06.2026', events: [] },
{ offset: 18, date: '2026-06-06', title: 'Samstag 06.06.2026', events: [] },
{ offset: 19, date: '2026-06-07', title: 'Sonntag 07.06.2026', events: [] },
{ offset: 20, date: '2026-06-08', title: 'Montag 08.06.2026', events: [] },
{ offset: 21, date: '2026-06-09', title: 'Dienstag 09.06.2026', events: [] },
{ offset: 22, date: '2026-06-10', title: 'Mittwoch 10.06.2026', events: [] },
{ offset: 23, date: '2026-06-11', title: 'Donnerstag 11.06.2026', events: [] },
{ offset: 24, date: '2026-06-12', title: 'Freitag 12.06.2026', events: [] },
{ offset: 25, date: '2026-06-13', title: 'Samstag 13.06.2026', events: [] },
{ offset: 26, date: '2026-06-14', title: 'Sonntag 14.06.2026', events: [] },
{ offset: 27, date: '2026-06-15', title: 'Montag 15.06.2026', events: [] }
],
config: {
navigation: [
{ key: 'feed', label: 'Feed', icon: 'H' },
{ key: 'ads', label: 'Werbefeed', icon: 'W' },
{ key: 'followers', label: 'Follower', icon: 'F' },
{ key: 'market', label: 'Marktplatz', icon: 'M' },
{ key: 'calendar', label: 'Kalender', icon: 'K' },
{ key: 'business', label: 'Gewerbe', icon: 'G' },
{ key: 'profile', label: 'Profil', icon: 'P' },
{ key: 'legal', label: 'Rechtliches', icon: 'R' }
]
}
};
const root = document.getElementById('bleeter-root');
const nav = document.getElementById('side-nav');
const content = document.getElementById('content');
const context = document.getElementById('context');
const topProfileAvatar = document.getElementById('top-profile-avatar');
const globalSearch = document.getElementById('global-search');
const searchResults = document.getElementById('search-results');
const mediaFileInput = document.getElementById('media-file-input');
function post(name, payload = {}) {
return fetch(`https://${resourceName}/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(payload)
}).catch(() => {});
}
function data() {
return state.data || fallbackData;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function activeProfile() {
const current = data();
return (current.profiles || []).find(profile => profile.id === current.activeProfileId) || current.profiles?.[0] || {};
}
function allKnownProfiles() {
const current = data();
const byHandle = new Map();
const addProfile = item => {
if (!item?.handle) return;
byHandle.set(item.handle, { ...(byHandle.get(item.handle) || {}), ...item });
};
(current.profileDirectory || []).forEach(addProfile);
(current.profiles || []).forEach(addProfile);
(current.users || []).forEach(addProfile);
(current.businesses || []).forEach(addProfile);
(current.social?.following || []).forEach(addProfile);
(current.social?.followers || []).forEach(addProfile);
(current.social?.blocked || []).forEach(addProfile);
(current.posts || []).forEach(postItem => addProfile(postItem.author));
(current.marketplace || []).forEach(item => addProfile(item.author));
return [...byHandle.values()];
}
function findKnownProfile(handle) {
const cleanHandle = String(handle || '').replace(/^@/, '');
if (!cleanHandle) return null;
return allKnownProfiles().find(profile => profile.handle === cleanHandle) || null;
}
function viewedProfile() {
if (!state.viewedProfileHandle) return activeProfile();
return findKnownProfile(state.viewedProfileHandle) || activeProfile();
}
function isOwnProfile(profile) {
const active = activeProfile();
return Number(profile?.id) === Number(active.id) || profile?.handle === active.handle;
}
function openProfile(handle) {
const cleanHandle = String(handle || '').replace(/^@/, '');
state.viewedProfileHandle = cleanHandle || null;
state.editingProfile = false;
state.page = 'profile';
state.searchQuery = '';
render();
}
function isServerBacked() {
return state.data !== null;
}
function lifeinvaderPermissions() {
const permissions = data().permissions?.lifeinvader;
return typeof permissions === 'object' && permissions !== null ? permissions : {};
}
function hydrateInteractionState() {
state.likedPosts = new Set();
state.likedComments = new Set();
state.postComments = {};
state.likeDeltas = {};
state.commentLikeDeltas = {};
for (const postItem of state.data?.posts || []) {
if (postItem.liked_by_viewer) {
state.likedPosts.add(postItem.id);
}
if (Array.isArray(postItem.comments_list)) {
state.postComments[postItem.id] = postItem.comments_list;
for (const comment of postItem.comments_list) {
if (comment.liked_by_viewer) {
state.likedComments.add(String(comment.id));
}
}
}
}
}
function verified(profile) {
return profile?.is_verified ? `<span class="verified" title="Verifiziert">${patchCheckIcon()}</span>` : '';
}
function lifeinvaderStaffBadge(profile) {
return profile?.is_lifeinvader_staff ? `<span class="staff-badge" title="Lifeinvader Mitarbeiter">${awardIcon()}</span>` : '';
}
function profileBadges(profile) {
return `${verified(profile)}${lifeinvaderStaffBadge(profile)}`;
}
function patchCheckIcon() {
return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01zm.287 5.984-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708.708"/>
</svg>
`;
}
function awardIcon() {
return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="m8 0 1.669.864 1.858.282.842 1.68 1.337 1.32L13.4 6l.306 1.854-1.337 1.32-.842 1.68-1.858.282L8 12l-1.669-.864-1.858-.282-.842-1.68-1.337-1.32L2.6 6l-.306-1.854 1.337-1.32.842-1.68L6.331.864z"/>
<path d="M4 11.794V16l4-1 4 1v-4.206l-2.018.306L8 13.126 6.018 12.1z"/>
</svg>
`;
}
function slashCircleIcon() {
return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="M11.354 4.646a.5.5 0 0 0-.708 0l-6 6a.5.5 0 0 0 .708.708l6-6a.5.5 0 0 0 0-.708"/>
</svg>
`;
}
function renderLifeinvaderProfileActions(profile) {
const permissions = lifeinvaderPermissions();
const buttons = [];
const handle = escapeHtml(profile.handle || '');
if (permissions.can_verify) {
buttons.push(`
<button class="moderation-icon-button ${profile.is_verified ? 'active' : ''}" type="button" data-moderate-profile="verify" data-moderate-profile-handle="${handle}" aria-label="${profile.is_verified ? 'Verifizierung entfernen' : 'Profil verifizieren'}" title="${profile.is_verified ? 'Verifizierung entfernen' : 'Profil verifizieren'}">
${patchCheckIcon()}
</button>
`);
}
if (permissions.can_staff) {
buttons.push(`
<button class="moderation-icon-button ${profile.is_lifeinvader_staff ? 'active' : ''}" type="button" data-moderate-profile="staff" data-moderate-profile-handle="${handle}" aria-label="${profile.is_lifeinvader_staff ? 'Lifeinvader Mitarbeiter entfernen' : 'Lifeinvader Mitarbeiter markieren'}" title="${profile.is_lifeinvader_staff ? 'Lifeinvader Mitarbeiter entfernen' : 'Lifeinvader Mitarbeiter markieren'}">
${awardIcon()}
</button>
`);
}
if (permissions.can_lock) {
buttons.push(`
<button class="moderation-icon-button danger ${profile.is_locked ? 'active' : ''}" type="button" data-moderate-profile="lock" data-moderate-profile-handle="${handle}" aria-label="${profile.is_locked ? 'Profil entsperren' : 'Profil sperren'}" title="${profile.is_locked ? 'Profil entsperren' : 'Profil sperren'}">
${slashCircleIcon()}
</button>
`);
}
return buttons.length ? `<div class="moderation-actions">${buttons.join('')}</div>` : '';
}
function trashIcon() {
return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5"/>
</svg>
`;
}
function mailIcon() {
return `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M2 2A2 2 0 0 0 .05 3.555L8 8.414l7.95-4.859A2 2 0 0 0 14 2zm-2 9.8V4.698l5.803 3.546zm6.761-2.97-6.57 4.026A2 2 0 0 0 2 14h6.256A4.5 4.5 0 0 1 8 12.5a4.49 4.49 0 0 1 1.606-3.446l-.367-.225L8 9.586zM16 9.671V4.697l-5.803 3.546.338.208A4.5 4.5 0 0 1 12.5 8c1.414 0 2.675.652 3.5 1.671"/>
<path d="M15.834 12.244c0 1.168-.577 2.025-1.587 2.025-.503 0-1.002-.228-1.12-.648h-.043c-.118.416-.543.643-1.015.643-.77 0-1.259-.542-1.259-1.434v-.529c0-.844.481-1.4 1.26-1.4.585 0 .87.333.953.63h.03v-.568h.905v2.19c0 .272.18.42.411.42.315 0 .639-.415.639-1.39v-.118c0-1.277-.95-2.326-2.484-2.326h-.04c-1.582 0-2.64 1.067-2.64 2.724v.157c0 1.867 1.237 2.654 2.57 2.654h.045c.507 0 .935-.07 1.18-.18v.731c-.219.1-.643.175-1.237.175h-.044C10.438 16 9 14.82 9 12.646v-.214C9 10.36 10.421 9 12.485 9h.035c2.12 0 3.314 1.43 3.314 3.034zm-4.04.21v.227c0 .586.227.8.581.8.31 0 .564-.17.564-.743v-.367c0-.516-.275-.708-.572-.708-.346 0-.573.245-.573.791"/>
</svg>
`;
}
function profileStatIcon(type) {
const icons = {
posts: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2zm6 2.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 1 0m3.5.878c1.482-1.42 4.795 1.392 0 4.622-4.795-3.23-1.482-6.043 0-4.622M2 5.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5"/>
</svg>
`,
followers: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M11.5 1.246c.832-.855 2.913.642 0 2.566-2.913-1.924-.832-3.421 0-2.566M9 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0m-9 8c0 1 1 1 1 1h10s1 0 1-1-1-4-6-4-6 3-6 4m13.5-8.09c1.387-1.425 4.855 1.07 0 4.277-4.854-3.207-1.387-5.702 0-4.276ZM15 2.165c.555-.57 1.942.428 0 1.711-1.942-1.283-.555-2.281 0-1.71Z"/>
</svg>
`,
following: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M9 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0m-9 8c0 1 1 1 1 1h10s1 0 1-1-1-4-6-4-6 3-6 4m13.5-8.09c1.387-1.425 4.855 1.07 0 4.277-4.854-3.207-1.387-5.702 0-4.276Z"/>
</svg>
`,
market: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M2.97 1.35A1 1 0 0 1 3.73 1h8.54a1 1 0 0 1 .76.35l2.609 3.044A1.5 1.5 0 0 1 16 5.37v.255a2.375 2.375 0 0 1-4.25 1.458A2.37 2.37 0 0 1 9.875 8 2.37 2.37 0 0 1 8 7.083 2.37 2.37 0 0 1 6.125 8a2.37 2.37 0 0 1-1.875-.917A2.375 2.375 0 0 1 0 5.625V5.37a1.5 1.5 0 0 1 .361-.976zm1.78 4.275a1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0 1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0 1.375 1.375 0 1 0 2.75 0V5.37a.5.5 0 0 0-.12-.325L12.27 2H3.73L1.12 5.045A.5.5 0 0 0 1 5.37v.255a1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0M1.5 8.5A.5.5 0 0 1 2 9v6h12V9a.5.5 0 0 1 1 0v6h.5a.5.5 0 0 1 0 1H.5a.5.5 0 0 1 0-1H1V9a.5.5 0 0 1 .5-.5m2 .5a.5.5 0 0 1 .5.5V13h8V9.5a.5.5 0 0 1 1 0V13a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9.5a.5.5 0 0 1 .5-.5"/>
</svg>
`,
calendar: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M10.854 7.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 9.793l2.646-2.647a.5.5 0 0 1 .708 0"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
</svg>
`
};
return icons[type] || '';
}
function navIconGlyph(icon, label) {
return {
home: 'F',
megaphone: 'W',
users: 'Fo',
basket: 'M',
calendar: 'K',
briefcase: 'G',
user: 'P',
book: 'R'
}[icon] || String(label || '?').slice(0, 2);
}
function navIconMarkup(item) {
const icons = {
feed: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 6.982C9.664 5.309 13.825 8.236 8 12 2.175 8.236 6.336 5.309 8 6.982"/>
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.707L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.646a.5.5 0 0 0 .708-.707L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293zM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5z"/>
</svg>
`,
ads: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="m3.7 11 .47-1.542h2.004L6.644 11h1.261L5.901 5.001H4.513L2.5 11zm1.503-4.852.734 2.426H4.416l.734-2.426zm4.759.128c-1.059 0-1.753.765-1.753 2.043v.695c0 1.279.685 2.043 1.74 2.043.677 0 1.222-.33 1.367-.804h.057V11h1.138V4.685h-1.16v2.36h-.053c-.18-.475-.68-.77-1.336-.77zm.387.923c.58 0 1.002.44 1.002 1.138v.602c0 .76-.396 1.2-.984 1.2-.598 0-.972-.449-.972-1.248v-.453c0-.795.37-1.24.954-1.24z"/>
<path d="M14 3a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
</svg>
`,
followers: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5"/>
</svg>
`,
market: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M2.97 1.35A1 1 0 0 1 3.73 1h8.54a1 1 0 0 1 .76.35l2.609 3.044A1.5 1.5 0 0 1 16 5.37v.255a2.375 2.375 0 0 1-4.25 1.458A2.37 2.37 0 0 1 9.875 8 2.37 2.37 0 0 1 8 7.083 2.37 2.37 0 0 1 6.125 8a2.37 2.37 0 0 1-1.875-.917A2.375 2.375 0 0 1 0 5.625V5.37a1.5 1.5 0 0 1 .361-.976zm1.78 4.275a1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0 1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0 1.375 1.375 0 1 0 2.75 0V5.37a.5.5 0 0 0-.12-.325L12.27 2H3.73L1.12 5.045A.5.5 0 0 0 1 5.37v.255a1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0M1.5 8.5A.5.5 0 0 1 2 9v6h1v-5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v5h6V9a.5.5 0 0 1 1 0v6h.5a.5.5 0 0 1 0 1H.5a.5.5 0 0 1 0-1H1V9a.5.5 0 0 1 .5-.5M4 15h3v-5H4zm5-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1zm3 0h-2v3h2z"/>
</svg>
`,
calendar: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M2 2a1 1 0 0 0-1 1v1h14V3a1 1 0 0 0-1-1zm13 3H1v9a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1z"/>
<path d="M11 7.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm-3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm-2 3a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm-3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5z"/>
</svg>
`,
business: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M14.763.075A.5.5 0 0 1 15 .5v15a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5V14h-1v1.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V10a.5.5 0 0 1 .342-.474L6 7.64V4.5a.5.5 0 0 1 .276-.447l8-4a.5.5 0 0 1 .487.022M6 8.694 1 10.36V15h5zM7 15h2v-1.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5V15h2V1.309l-7 3.5z"/>
<path d="M2 11h1v1H2zm2 0h1v1H4zm-2 2h1v1H2zm2 0h1v1H4zm4-4h1v1H8zm2 0h1v1h-1zm-2 2h1v1H8zm2 0h1v1h-1zm2-2h1v1h-1zm0 2h1v1h-1zM8 7h1v1H8zm2 0h1v1h-1zm2 0h1v1h-1zM8 5h1v1H8zm2 0h1v1h-1zm2 0h1v1h-1zm0-2h1v1h-1z"/>
</svg>
`,
profile: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1"/>
</svg>
`,
legal: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M6 1h6v7a.5.5 0 0 1-.757.429L9 7.083 6.757 8.43A.5.5 0 0 1 6 8z"/>
<path d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2"/>
<path d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1z"/>
</svg>
`
};
return icons[item.key] || escapeHtml(navIconGlyph(item.icon, item.label));
}
function show(open, mode) {
state.visible = open;
if (open && mode) state.mode = mode;
root.classList.toggle('hidden', !open);
root.classList.toggle('mode-desktop', open && state.mode === 'desktop');
root.classList.toggle('mode-phone', open && state.mode !== 'desktop');
if (open) {
post('requestBootstrap');
}
render();
}
function renderNav() {
const items = data().config?.navigation || fallbackData.config.navigation;
nav.innerHTML = items.map(item => `
<button class="nav-button ${state.page === item.key ? 'active' : ''}" data-page="${escapeHtml(item.key)}" aria-label="${escapeHtml(item.label)}">
<span class="nav-icon">${navIconMarkup(item)}</span>
<span class="nav-label">${escapeHtml(item.label)}</span>
</button>
`).join('');
}
function renderTopbar() {
const profile = activeProfile();
topProfileAvatar.src = profile.avatar_url || '';
if (globalSearch.value !== state.searchQuery) {
globalSearch.value = state.searchQuery;
}
renderSearchResults();
}
function renderSearchResults() {
const query = state.searchQuery.trim().toLowerCase().replace(/^@/, '');
if (!query) {
searchResults.classList.add('hidden');
searchResults.innerHTML = '';
return;
}
const matches = (data().users || fallbackData.users).filter(user => user.handle.startsWith(query)).slice(0, 5);
searchResults.classList.toggle('hidden', matches.length === 0);
searchResults.innerHTML = matches.map(user => `
<button class="search-result" type="button" data-found-profile="${escapeHtml(user.handle)}">
<img src="${escapeHtml(user.avatar_url)}" alt="">
<span>
<strong>${escapeHtml(user.display_name)}</strong><br>
<span class="handle">@${escapeHtml(user.handle)}</span>
</span>
</button>
`).join('');
}
function renderCompose(feedType) {
const profile = activeProfile();
const canPost = (profile.profile_type === 'private' && feedType === 'home')
|| (['small_business', 'company'].includes(profile.profile_type) && feedType === 'advertising')
|| (profile.profile_type === 'authority');
if (!canPost) {
return `
<div class="context-card">
<h3>Nur lesen</h3>
<p>Dein aktives Profil darf hier liken und kommentieren, aber nicht posten.</p>
</div>
`;
}
return `
<form class="compose">
<img class="avatar" referrerpolicy="no-referrer" src="${escapeHtml(profile.avatar_url)}" alt="">
<div>
<textarea placeholder="Was gibt es Neues in Los Santos?"></textarea>
<div class="compose-actions">
<div class="upload-row">
<label for="media-file-input" class="upload-button" onclick="setComposeUploadTarget(this)">Datei hochladen</label>
<input class="media-url-input" data-compose-media="${escapeHtml(feedType)}" value="${escapeHtml(state.composeMedia[feedType] || '')}" placeholder="oder Bild-URL einfuegen">
<span class="hint">JPG/PNG, max. 2 MB</span>
</div>
<button class="primary-button" type="button" data-create-post="${escapeHtml(feedType)}">Posten</button>
</div>
</div>
</form>
`;
}
function renderPost(post) {
const author = post.author || {};
const liked = state.likedPosts.has(post.id);
const commentsOpen = state.commentsOpen.has(post.id);
const likeCount = Number(post.likes || 0) + Number(state.likeDeltas[post.id] || 0);
const comments = commentsForPost(post.id);
const commentCount = comments.length;
return `
<article class="post-card">
<header class="post-head">
<button class="author-link" type="button" data-open-profile="${escapeHtml(author.handle)}">
<img class="avatar" referrerpolicy="no-referrer" src="${escapeHtml(author.avatar_url)}" alt="">
</button>
<div>
<button class="author-link" type="button" data-open-profile="${escapeHtml(author.handle)}">
<span class="author-name">${escapeHtml(author.display_name)} ${profileBadges(author)}</span>
</button>
<div class="handle">@${escapeHtml(author.handle)}</div>
</div>
<div class="post-time">${escapeHtml(post.created_at)}</div>
</header>
${post.body ? `<div class="post-body">${escapeHtml(post.body)}</div>` : ''}
${post.media_url ? `<img class="post-image" referrerpolicy="no-referrer" src="${escapeHtml(post.media_url)}" alt="">` : ''}
<footer class="post-actions">
<button class="action-link" type="button" data-like-post="${post.id}">${liked ? '&#x1f90d;' : '&#9825;'} ${likeCount}</button>
<button class="action-link" type="button" data-comments-post="${post.id}">Kommentare ${commentCount}</button>
${post.can_delete ? `<button class="icon-action danger-icon" type="button" data-delete-post="${post.id}" aria-label="Post loeschen" title="Post loeschen">${trashIcon()}</button>` : ''}
</footer>
${commentsOpen ? renderComments(post, comments) : ''}
</article>
`;
}
function commentsForPost(postId) {
if (!state.postComments[postId]) {
const postItem = (data().posts || []).find(item => Number(item.id) === Number(postId));
state.postComments[postId] = Array.isArray(postItem?.comments_list)
? postItem.comments_list
: [
{ id: `${postId}-1`, author: 'zoeyreed', body: 'Guter Hinweis, danke fuer die Info.', likes: 0 },
{ id: `${postId}-2`, author: 'lasky', body: 'Bin dabei.', likes: 0 }
];
}
return state.postComments[postId];
}
function renderComments(post, comments) {
return `
<div class="comments-panel">
<div class="comment-form">
<input type="text" data-comment-input="${post.id}" placeholder="Kommentar schreiben">
<button class="primary-button" type="button" data-send-comment="${post.id}">Senden</button>
</div>
${comments.map(comment => renderComment(comment)).join('')}
</div>
`;
}
function renderComment(comment) {
const commentId = String(comment.id);
const liked = state.likedComments.has(commentId);
const likeCount = Number(comment.likes || 0) + Number(state.commentLikeDeltas[comment.id] || 0);
return `
<div class="comment-item">
<div>
<button class="comment-author-link" type="button" data-open-profile="${escapeHtml(comment.author)}">@${escapeHtml(comment.author)}</button>
<span>${escapeHtml(comment.body)}</span>
</div>
<div class="comment-actions">
<button class="action-link" type="button" data-like-comment="${escapeHtml(commentId)}">${liked ? '&#x1f90d;' : '&#9825;'} ${likeCount}</button>
${comment.can_delete ? `<button class="icon-action danger-icon" type="button" data-delete-comment="${escapeHtml(commentId)}" aria-label="Kommentar loeschen" title="Kommentar loeschen">${trashIcon()}</button>` : ''}
</div>
</div>
`;
}
function renderFeed(feedType) {
const title = feedType === 'home' ? 'Feed' : 'Werbefeed';
const posts = (data().posts || []).filter(post => post.feed_type === feedType);
content.innerHTML = `
<div class="section-title">
<div>
<h1>${title}</h1>
</div>
</div>
${renderCompose(feedType)}
<div class="feed-list">${posts.map(renderPost).join('')}</div>
`;
}
function renderContextFeed() {
const current = data();
context.innerHTML = `
<h2 class="context-title">Wem folgen?</h2>
<div class="context-stack">
${(current.suggestions || []).map(item => `
<div class="context-card suggestion">
<div class="suggestion-avatar">${escapeHtml(item.display_name.slice(0, 1))}</div>
<div>
<h3>${escapeHtml(item.display_name)}</h3>
<p>@${escapeHtml(item.handle)}</p>
</div>
<button class="mini-button" type="button">Folgen</button>
</div>
`).join('')}
</div>
<h2 class="context-title" style="margin-top:24px">Trends</h2>
<div class="context-stack">
${(current.trends || []).map(item => `
<div class="context-card">
<h3>${escapeHtml(item.tag)}</h3>
<p>${Number(item.posts || 0)} Posts</p>
</div>
`).join('')}
</div>
`;
}
function renderBusiness() {
const current = data();
const open = (current.businesses || []).filter(item => item.status === 'open');
const closed = (current.businesses || []).filter(item => item.status !== 'open');
const card = item => `
<button class="business-card ${item.status === 'open' ? '' : 'closed'}" type="button" data-open-profile="${escapeHtml(item.handle)}">
<img class="business-banner" src="${escapeHtml(item.banner_url)}" alt="">
<div class="business-overlay">
<img class="business-avatar" src="${escapeHtml(item.avatar_url)}" alt="">
<div class="business-label">
<strong>${escapeHtml(item.display_name)} ${profileBadges(item)}</strong>
<div class="handle">@${escapeHtml(item.handle)}</div>
</div>
</div>
</button>
`;
content.innerHTML = `
<div class="section-title">
<div>
<h1>Gewerbe</h1>
</div>
</div>
<div class="status-strip"></div>
<div class="page-grid">${open.map(card).join('')}</div>
<div class="status-strip closed" style="margin-top:24px"></div>
<div class="page-grid">${closed.map(card).join('')}</div>
`;
context.innerHTML = `
<h2 class="context-title">Status</h2>
<div class="context-stack">
<div class="context-card"><h3>${open.length} geoeffnet</h3><p>Aktive Leitstellen werden hervorgehoben.</p></div>
<div class="context-card"><h3>${closed.length} geschlossen</h3><p>Geschlossene Gewerbe sind ausgegraut.</p></div>
</div>
`;
}
function renderMarketplace() {
const items = filteredMarketplaceItems();
content.innerHTML = `
<div class="section-title">
<div><h1>Marktplatz</h1></div>
</div>
${renderMarketplaceCompose()}
<div class="market-tools">
<input class="market-search" id="market-search" type="search" value="${escapeHtml(state.marketQuery)}" placeholder="Suche, z.B. BMX">
<select class="market-sort" id="market-sort">
<option value="newest" ${state.marketSort === 'newest' ? 'selected' : ''}>Neuester Post</option>
<option value="oldest" ${state.marketSort === 'oldest' ? 'selected' : ''}>Aeltester Post</option>
<option value="price_asc" ${state.marketSort === 'price_asc' ? 'selected' : ''}>Preis aufsteigend</option>
<option value="price_desc" ${state.marketSort === 'price_desc' ? 'selected' : ''}>Preis absteigend</option>
</select>
</div>
<div class="simple-list" id="market-results">${items.map(renderMarketplaceItem).join('')}</div>
`;
context.innerHTML = `
<h2 class="context-title">Sortierung</h2>
<div class="context-card">
<h3 id="market-hit-count">${items.length} Treffer</h3>
<p>Suche nach Begriffen im Titel, Text oder Profilnamen.</p>
</div>
`;
}
function filteredMarketplaceItems() {
const query = state.marketQuery.trim().toLowerCase();
let items = (data().marketplace || fallbackData.marketplace).filter(item => {
if (!query) return true;
return `${item.title} ${item.body} ${item.author?.handle || ''} ${item.author?.display_name || ''}`.toLowerCase().includes(query);
});
return [...items].sort((a, b) => {
if (state.marketSort === 'price_asc') return Number(a.price || 0) - Number(b.price || 0);
if (state.marketSort === 'price_desc') return Number(b.price || 0) - Number(a.price || 0);
if (state.marketSort === 'oldest') return Number(a.created || 0) - Number(b.created || 0);
return Number(b.created || 0) - Number(a.created || 0);
});
}
function refreshMarketplaceResults() {
const items = filteredMarketplaceItems();
const results = document.getElementById('market-results');
if (results) {
results.innerHTML = items.map(renderMarketplaceItem).join('');
}
const hitCount = document.getElementById('market-hit-count');
if (hitCount) {
hitCount.textContent = `${items.length} Treffer`;
}
}
function renderMarketplaceItem(item) {
const author = item.author || {};
const mailSubject = `Bleeter Marktplatz: ${item.title || 'Inserat'}`;
return `
<article class="list-card market-post">
<div>
<div class="market-author">
<button class="market-avatar-link" type="button" data-open-profile="${escapeHtml(author.handle || '')}" aria-label="Profil oeffnen">
${author.avatar_url ? `<img src="${escapeHtml(author.avatar_url)}" alt="">` : `<span>${escapeHtml((author.display_name || author.handle || '?').slice(0, 1))}</span>`}
</button>
<div>
<strong class="market-author-name">
<button class="author-link" type="button" data-open-profile="${escapeHtml(author.handle || '')}">${escapeHtml(author.display_name || author.handle || 'Unbekannt')} ${profileBadges(author)}</button>
${author.email ? `<button class="mail-icon-button" type="button" data-mail-to="${escapeHtml(author.email)}" data-mail-subject="${escapeHtml(mailSubject)}" aria-label="Mail schreiben" title="Mail schreiben">${mailIcon()}</button>` : ''}
</strong>
<p>@${escapeHtml(author.handle || '')}</p>
</div>
</div>
<h3>${escapeHtml(item.title)}</h3>
<p>${escapeHtml(item.body)}</p>
${item.media_url ? `<img class="market-image" referrerpolicy="no-referrer" src="${escapeHtml(item.media_url)}" alt="">` : ''}
</div>
<div class="market-side">
<div class="price-tag">${escapeHtml(item.price_label || ('$' + Number(item.price || 0)))}</div>
${item.can_delete ? `<button class="icon-action danger-icon" type="button" data-delete-marketplace="${item.id}" aria-label="Inserat loeschen" title="Inserat loeschen">${trashIcon()}</button>` : ''}
</div>
</article>
`;
}
function renderProfileEventItem(day, event) {
const location = event.location ? ` ..${escapeHtml(event.location)}..` : '';
return `
<article class="list-card profile-event-card">
<h3>${escapeHtml(day.title)}</h3>
<p>${escapeHtml(event.time || '')} ${escapeHtml(event.title || '')}${location}</p>
<p>@${escapeHtml(event.author || '')}</p>
</article>
`;
}
function renderMarketplaceCompose() {
const profile = activeProfile();
return `
<form class="compose">
<img class="avatar" referrerpolicy="no-referrer" src="${escapeHtml(profile.avatar_url)}" alt="">
<div>
<div class="market-compose-grid">
<input class="event-input" id="market-title" maxlength="120" placeholder="Titel, z.B. BMX">
<input class="event-input price-input" id="market-price" maxlength="80" placeholder="Preis, z.B. $450 / VB">
</div>
<textarea id="market-description" placeholder="Was moechtest du anbieten?"></textarea>
<div class="compose-actions">
<div class="upload-row">
<label for="media-file-input" class="upload-button" onclick="setComposeUploadTarget(this)">Datei hochladen</label>
<input class="media-url-input" data-compose-media="market" value="${escapeHtml(state.composeMedia.market || '')}" placeholder="oder Bild-URL einfuegen">
<span class="hint">JPG/PNG, max. 2 MB</span>
</div>
<button class="primary-button" type="button" data-create-marketplace>Inserieren</button>
</div>
</div>
</form>
`;
}
function renderCalendar() {
const days = data().calendarDays || fallbackData.calendarDays;
const selected = days.find(day => day.offset === state.selectedCalendarDay) || days[0];
const profile = activeProfile();
const canCreateEvent = ['small_business', 'company', 'authority'].includes(profile.profile_type);
content.innerHTML = `
<div class="section-title">
<div><h1>Kalender</h1></div>
</div>
<div class="calendar-list">
${days.map(day => `
<div class="day-card ${day.offset === selected.offset ? 'active' : ''}" data-calendar-day="${day.offset}">
<div class="day-title">${escapeHtml(day.title)}</div>
${day.events.map(event => renderCalendarEventLine(event)).join('')}
</div>
`).join('')}
</div>
`;
context.innerHTML = `
<h2 class="context-title">Veranstaltung</h2>
${canCreateEvent ? renderEventEditor(selected, profile) : `
<div class="context-card">
<h3>Nur lesen</h3>
<p>Termine koennen von Unternehmen, Kleingewerben und Behoerden eingetragen werden.</p>
</div>
`}
`;
}
function renderCalendarEventLine(event) {
if (typeof event === 'string') {
return `<div class="event-line">${escapeHtml(event)}</div>`;
}
const location = event.location ? ` ..${escapeHtml(event.location)}..` : '';
const line = `${escapeHtml(event.time || '')} ${escapeHtml(event.title || '')} ${location} @${escapeHtml(event.author || '')}`;
return `
<div class="event-line">
<span>${line}</span>
${event.can_delete ? `<button class="icon-action danger-icon" type="button" data-delete-event="${event.id}" aria-label="Termin loeschen" title="Termin loeschen">${trashIcon()}</button>` : ''}
</div>
`;
}
function renderEventEditor(selected, profile) {
return `
<div class="context-card event-editor">
<div class="event-editor-head">
<span>${escapeHtml(selected.title)}</span>
<span>@${escapeHtml(profile.handle || 'profil')}</span>
</div>
<label>
Uhrzeit
<input class="event-input" id="event-time" type="time" value="20:00">
</label>
<label>
Veranstaltung
<input class="event-input" id="event-title" maxlength="50" placeholder="Freitext/Veranstaltung">
</label>
<label>
Veranstaltungsort
<input class="event-input" id="event-location" maxlength="50" placeholder="Veranstaltungsort">
</label>
<button class="primary-button" type="button" data-create-event="${escapeHtml(selected.date || '')}">Eintragen</button>
</div>
`;
}
function renderLegal() {
content.innerHTML = `
<div class="section-title">
<div><h1>Rechtliches</h1></div>
</div>
<article class="list-card legal-text">Rechtliches &amp; Nutzungsbedingungen
Bleeter powered by Lifeinvader&trade;
Mit der Nutzung von Lifeinvader Bleeter erkl&auml;rst du dich damit einverstanden, dass deine ver&ouml;ffentlichten Inhalte, Profildaten, Nachrichten sowie Interaktionen innerhalb der Plattform durch Lifeinvader gespeichert, verarbeitet und f&uuml;r interne Zwecke ausgewertet werden d&uuml;rfen.
Lifeinvader beh&auml;lt sich das Recht vor, &ouml;ffentlich gepostete Bilder, Beitr&auml;ge, Kommentare oder Profilelemente f&uuml;r Werbezwecke, Kampagnen, Empfehlungen oder Plattformdarstellungen zu verwenden. Mit dem Hochladen von Inhalten erteilst du Lifeinvader hierf&uuml;r eine uneingeschr&auml;nkte Nutzungserlaubnis innerhalb der Plattformdienste.
Zur Sicherheit und zum Schutz aller Nutzer gelten auf Bleeter folgende Grundregeln:
Keine Darstellung extremer Gewalt
Keine Ver&ouml;ffentlichung nackter oder eindeutig sexueller Inhalte
Keine gezielten Drohungen, Erpressung oder Aufrufe zu Straftaten
Keine Ver&ouml;ffentlichung vertraulicher personenbezogener Informationen anderer B&uuml;rger ohne deren Zustimmung
Lifeinvader beh&auml;lt sich jederzeit das Recht vor:
Beitr&auml;ge oder Medien ohne Vorwarnung zu entfernen
Nutzerprofile tempor&auml;r oder dauerhaft zu sperren
Reichweiten einzuschr&auml;nken oder Inhalte unsichtbar zu schalten
Accounts bei Missbrauch oder wiederholten Verst&ouml;&szlig;en dauerhaft zu deaktivieren
Beitr&auml;ge, die gegen geltendes Recht oder die Plattformrichtlinien versto&szlig;en, k&ouml;nnen zur weiteren Pr&uuml;fung an das Department of Justice DOJ &uuml;bermittelt werden. Dies betrifft insbesondere Inhalte mit Gewaltandrohungen, organisierter Kriminalit&auml;t, Erpressung, Betrug oder der &ouml;ffentlichen Verbreitung illegaler Aktivit&auml;ten.
Mit der Nutzung von Bleeter best&auml;tigst du au&szlig;erdem, dass du verstehst, dass Inhalte im Internet niemals vollst&auml;ndig privat sind. Denk nach, bevor du postest &mdash; das Netz vergisst nicht.
Lifeinvader&trade; &mdash; Wir wissen, was dich bewegt.</article>
`;
context.innerHTML = '';
}
function renderProfile() {
const profile = viewedProfile();
const ownProfile = isOwnProfile(profile);
const social = data().social || fallbackData.social;
const following = socialHandles(social.following).has(profile.handle);
const blocked = socialHandles(social.blocked).has(profile.handle);
const visiblePosts = (data().posts || []).filter(post => post.author?.handle === profile.handle);
const visibleMarketplace = (data().marketplace || []).filter(item => item.author?.handle === profile.handle);
const visibleEvents = (data().calendarDays || [])
.flatMap(day => (day.events || [])
.filter(event => event.author === profile.handle)
.map(event => ({ day, event })));
const followersFallback = (social.followers || []).filter(item => item.handle === profile.handle).length;
const followingFallback = ownProfile ? (social.following || []).length : 0;
const followersCount = Number(profile.followers_count ?? followersFallback);
const followingCount = Number(profile.following_count ?? followingFallback);
const canBlock = profile.can_be_blocked !== false;
const avatarUrl = profile.avatar_url || '';
const bannerUrl = profile.banner_url || profile.avatar_url || '';
const contactMail = profile.email_contact || profile.email || '';
const moderationActions = renderLifeinvaderProfileActions(profile);
if (ownProfile && state.editingProfile && profile.can_edit_profile !== false) {
content.innerHTML = renderProfileEditor(profile);
} else {
const profileActions = ownProfile
? `
<div class="profile-actions">
${profile.can_edit_profile !== false ? `<button class="primary-button" type="button" data-edit-profile>Bearbeiten</button>` : ''}
${moderationActions}
</div>
`
: `
<div class="profile-actions">
${following
? `<button class="ghost-button" type="button" data-unfollow-profile="${escapeHtml(profile.handle)}">Entfolgen</button>`
: `<button class="primary-button" type="button" data-follow-profile="${escapeHtml(profile.handle)}">Folgen</button>`}
${contactMail ? `<button class="mail-icon-button profile-mail-button" type="button" data-mail-to="${escapeHtml(contactMail)}" data-mail-subject="${escapeHtml('Bleeter Kontakt: @' + profile.handle)}" aria-label="Mail schreiben" title="Mail schreiben">${mailIcon()}</button>` : ''}
${blocked
? `<button class="ghost-button danger-text" type="button" data-unblock-profile="${escapeHtml(profile.handle)}">Entblocken</button>`
: (canBlock
? `<button class="ghost-button danger-text" type="button" data-block-profile="${escapeHtml(profile.handle)}">Blockieren</button>`
: `<span class="protected-pill">Geschuetzt</span>`)}
${moderationActions}
</div>
`;
content.innerHTML = `
<section class="profile-hero">
${bannerUrl ? `<img class="profile-banner" src="${escapeHtml(bannerUrl)}" alt="">` : '<div class="profile-banner profile-banner-empty"></div>'}
<div class="profile-meta">
${avatarUrl ? `<img class="profile-avatar" src="${escapeHtml(avatarUrl)}" alt="">` : `<div class="profile-avatar profile-avatar-empty">${escapeHtml((profile.display_name || profile.handle || '?').slice(0, 1))}</div>`}
<div>
<h1>${escapeHtml(profile.display_name || profile.handle || 'Profil')} ${profileBadges(profile)}</h1>
<p>@${escapeHtml(profile.handle)}</p>
<p>${escapeHtml(profile.bio || '')}</p>
<div class="profile-stats">
<button type="button" title="Posts" aria-label="Posts" data-profile-jump="posts">${profileStatIcon('posts')}<strong>${visiblePosts.length}</strong></button>
<button type="button" title="Follower" aria-label="Follower" data-profile-social="followers">${profileStatIcon('followers')}<strong>${followersCount}</strong></button>
<button type="button" title="Folgt" aria-label="Folgt" data-profile-social="following">${profileStatIcon('following')}<strong>${followingCount}</strong></button>
<button type="button" title="Inserate" aria-label="Inserate" data-profile-jump="market">${profileStatIcon('market')}<strong>${visibleMarketplace.length}</strong></button>
<button type="button" title="Termine" aria-label="Termine" data-profile-jump="calendar">${profileStatIcon('calendar')}<strong>${visibleEvents.length}</strong></button>
</div>
</div>
${profileActions}
</div>
</section>
<div class="section-title" style="margin-top:24px">
<div>
<h1>Profilinhalte</h1>
<p>${ownProfile ? 'Deine sichtbaren Inhalte.' : `Sichtbare Inhalte von @${escapeHtml(profile.handle)}.`}</p>
</div>
</div>
<section class="profile-content-section" data-profile-section="posts">
<h2>Posts</h2>
<div class="feed-list">${visiblePosts.length ? visiblePosts.map(renderPost).join('') : '<article class="list-card"><p>Noch keine sichtbaren Posts.</p></article>'}</div>
</section>
${visibleMarketplace.length ? `
<section class="profile-content-section" data-profile-section="market">
<h2>Marktplatz</h2>
<div class="simple-list">${visibleMarketplace.map(renderMarketplaceItem).join('')}</div>
</section>
` : ''}
${visibleEvents.length ? `
<section class="profile-content-section" data-profile-section="calendar">
<h2>Kalender</h2>
<div class="simple-list">${visibleEvents.map(item => renderProfileEventItem(item.day, item.event)).join('')}</div>
</section>
` : ''}
`;
}
if (ownProfile) {
context.innerHTML = `
<h2 class="context-title">Profil wechseln</h2>
<div class="context-stack">
${(data().profiles || []).map(item => `
<button class="context-card" data-profile="${item.id}" type="button" style="text-align:left;color:inherit">
<h3>${escapeHtml(item.display_name)} ${profileBadges(item)}</h3>
<p>@${escapeHtml(item.handle)}</p>
</button>
`).join('')}
</div>
`;
} else {
context.innerHTML = `
<h2 class="context-title">Profil</h2>
<div class="context-stack">
<button class="context-card" data-open-own-profile type="button" style="text-align:left;color:inherit">
<h3>Zurueck zu mir</h3>
<p>@${escapeHtml(activeProfile().handle || '')}</p>
</button>
</div>
`;
}
}
function renderProfileEditor(profile) {
const bioMax = profile.profile_type === 'private' ? 200 : 500;
return `
<section class="profile-editor">
<div class="section-title">
<div>
<h1>Profil bearbeiten</h1>
<p>@${escapeHtml(profile.handle)}</p>
</div>
</div>
<form class="edit-form">
<label>
Profilname
<input class="event-input" id="edit-display-name" maxlength="80" value="${escapeHtml(profile.display_name || '')}">
</label>
<label>
Profilbild-URL
<div class="url-upload-row">
<input class="event-input" id="edit-avatar-url" maxlength="255" value="${escapeHtml(profile.avatar_url || '')}" placeholder="https://...">
<label for="media-file-input" class="upload-button" onclick="state.pendingUploadTarget='profile-avatar'">Datei hochladen</label>
</div>
</label>
<label>
Banner-URL
<div class="url-upload-row">
<input class="event-input" id="edit-banner-url" maxlength="255" value="${escapeHtml(profile.banner_url || '')}" placeholder="https://...">
<label for="media-file-input" class="upload-button" onclick="state.pendingUploadTarget='profile-banner'">Datei hochladen</label>
</div>
</label>
<label>
Bio
<textarea class="edit-textarea" id="edit-bio" maxlength="${bioMax}" placeholder="Kurzer Profiltext">${escapeHtml(profile.bio || '')}</textarea>
</label>
<div class="edit-actions">
<button class="ghost-button" type="button" data-cancel-profile-edit>Abbrechen</button>
<button class="primary-button" type="button" data-save-profile>Speichern</button>
</div>
</form>
</section>
`;
}
function renderSimplePage(title, subtitle, cards, contextTitle = '') {
content.innerHTML = `
<div class="section-title">
<div>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(subtitle)}</p>
</div>
</div>
<div class="simple-list">
${cards.map(card => `
<div class="list-card">
<h3>${escapeHtml(card.title)}</h3>
<p>${escapeHtml(card.text)}</p>
</div>
`).join('')}
</div>
`;
context.innerHTML = contextTitle ? `
<h2 class="context-title">${escapeHtml(contextTitle)}</h2>
<div class="context-stack">
<div class="context-card"><h3>V1</h3><p>Diese Ansicht ist im Grundgeruest vorbereitet und bekommt als naechstes echte Daten.</p></div>
</div>
` : '';
}
function socialHandles(list) {
return new Set((list || []).map(item => item.handle));
}
function renderSocialRow(item, mode, followingSet, blockedSet) {
const handle = item.handle || '';
const isFollowing = followingSet.has(handle);
const isBlocked = blockedSet.has(handle);
const canBlock = item.can_be_blocked !== false;
const blockAction = isBlocked
? `<button class="ghost-button danger-text" type="button" data-unblock-profile="${escapeHtml(handle)}">Entblocken</button>`
: (canBlock
? `<button class="ghost-button danger-text" type="button" data-block-profile="${escapeHtml(handle)}">Blockieren</button>`
: `<span class="protected-pill">Geschuetzt</span>`);
const followAction = mode === 'blocked'
? ''
: (isFollowing
? `<button class="ghost-button" type="button" data-unfollow-profile="${escapeHtml(handle)}">Entfolgen</button>`
: `<button class="mini-button" type="button" data-follow-profile="${escapeHtml(handle)}">Folgen</button>`);
return `
<article class="list-card social-row">
<div class="market-author">
${item.avatar_url ? `<img src="${escapeHtml(item.avatar_url)}" alt="">` : `<span>${escapeHtml((item.display_name || handle || '?').slice(0, 1))}</span>`}
<div>
<strong>${escapeHtml(item.display_name || handle)} ${profileBadges(item)}</strong>
<p>@${escapeHtml(handle)}</p>
</div>
</div>
<div class="social-actions">${followAction}${blockAction}</div>
</article>
`;
}
function renderSocialSection(title, items, mode, followingSet, blockedSet, emptyText) {
const rows = (items || []).map(item => renderSocialRow(item, mode, followingSet, blockedSet)).join('');
return `
<section class="social-section">
<h2>${escapeHtml(title)}</h2>
<div class="simple-list">
${rows || `<div class="list-card"><p>${escapeHtml(emptyText)}</p></div>`}
</div>
</section>
`;
}
function renderFollowers() {
const current = data();
const social = current.social || fallbackData.social;
const followingSet = socialHandles(social.following);
const blockedSet = socialHandles(social.blocked);
const suggestions = (current.users || fallbackData.users).filter(item => !blockedSet.has(item.handle)).slice(0, 12);
content.innerHTML = `
<div class="section-title">
<div>
<h1>Follower</h1>
</div>
</div>
${renderSocialSection('Entdecken', suggestions, 'discover', followingSet, blockedSet, 'Keine Profile gefunden.')}
${renderSocialSection('Wem folge ich', social.following, 'following', followingSet, blockedSet, 'Du folgst aktuell niemandem.')}
${renderSocialSection('Wer folgt mir', social.followers, 'followers', followingSet, blockedSet, 'Noch keine Follower.')}
${renderSocialSection('Blockiert', social.blocked, 'blocked', followingSet, blockedSet, 'Keine blockierten Profile.')}
`;
context.innerHTML = '';
}
function renderPage() {
if (state.page === 'feed') {
renderFeed('home');
renderContextFeed();
return;
}
if (state.page === 'ads') {
renderFeed('advertising');
renderContextFeed();
return;
}
if (state.page === 'business') {
renderBusiness();
return;
}
if (state.page === 'profile') {
renderProfile();
return;
}
if (state.page === 'followers') {
renderFollowers();
return;
}
if (state.page === 'market') {
renderMarketplace();
return;
}
if (state.page === 'calendar') {
renderCalendar();
return;
}
if (state.page === 'legal') {
renderLegal();
}
}
function render() {
renderNav();
renderTopbar();
renderPage();
}
document.addEventListener('click', event => {
const pageButton = event.target.closest('[data-page]');
if (pageButton) {
state.page = pageButton.dataset.page;
if (state.page === 'profile') state.viewedProfileHandle = null;
if (state.page !== 'profile') state.editingProfile = false;
render();
return;
}
const openOwnProfileButton = event.target.closest('[data-open-own-profile]');
if (openOwnProfileButton) {
state.viewedProfileHandle = null;
state.page = 'profile';
state.editingProfile = false;
render();
return;
}
const openProfileButton = event.target.closest('[data-open-profile]');
if (openProfileButton) {
openProfile(openProfileButton.dataset.openProfile);
return;
}
const mailButton = event.target.closest('[data-mail-to]');
if (mailButton) {
post('openMailTo', {
mail: mailButton.dataset.mailTo,
subject: mailButton.dataset.mailSubject || ''
});
return;
}
const moderateProfileButton = event.target.closest('[data-moderate-profile]');
if (moderateProfileButton) {
const action = moderateProfileButton.dataset.moderateProfile;
const handle = moderateProfileButton.dataset.moderateProfileHandle;
if (!isServerBacked()) {
const profile = findKnownProfile(handle);
if (profile) {
if (action === 'verify') profile.is_verified = !profile.is_verified;
if (action === 'staff') profile.is_lifeinvader_staff = !profile.is_lifeinvader_staff;
if (action === 'lock') profile.is_locked = !profile.is_locked;
}
}
post('moderateProfile', { action, handle });
render();
return;
}
const profileJumpButton = event.target.closest('[data-profile-jump]');
if (profileJumpButton) {
const target = document.querySelector(`[data-profile-section="${profileJumpButton.dataset.profileJump}"]`);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
return;
}
const profileSocialButton = event.target.closest('[data-profile-social]');
if (profileSocialButton) {
state.page = 'followers';
state.editingProfile = false;
render();
return;
}
const followButton = event.target.closest('[data-follow-profile]');
if (followButton) {
const handle = followButton.dataset.followProfile;
if (!isServerBacked()) {
const item = fallbackData.users.find(user => user.handle === handle);
if (item && !fallbackData.social.following.some(user => user.handle === handle)) {
fallbackData.social.following.push(item);
}
}
post('followProfile', { handle });
render();
return;
}
const unfollowButton = event.target.closest('[data-unfollow-profile]');
if (unfollowButton) {
const handle = unfollowButton.dataset.unfollowProfile;
if (!isServerBacked()) {
fallbackData.social.following = fallbackData.social.following.filter(user => user.handle !== handle);
}
post('unfollowProfile', { handle });
render();
return;
}
const blockButton = event.target.closest('[data-block-profile]');
if (blockButton) {
const handle = blockButton.dataset.blockProfile;
if (!isServerBacked()) {
const item = fallbackData.users.find(user => user.handle === handle) || fallbackData.social.following.find(user => user.handle === handle);
if (item?.can_be_blocked !== false && !fallbackData.social.blocked.some(user => user.handle === handle)) {
fallbackData.social.blocked.push(item);
fallbackData.social.following = fallbackData.social.following.filter(user => user.handle !== handle);
fallbackData.social.followers = fallbackData.social.followers.filter(user => user.handle !== handle);
}
}
post('blockProfile', { handle });
render();
return;
}
const unblockButton = event.target.closest('[data-unblock-profile]');
if (unblockButton) {
const handle = unblockButton.dataset.unblockProfile;
if (!isServerBacked()) {
fallbackData.social.blocked = fallbackData.social.blocked.filter(user => user.handle !== handle);
}
post('unblockProfile', { handle });
render();
return;
}
const createPostButton = event.target.closest('[data-create-post]');
if (createPostButton) {
const feedType = createPostButton.dataset.createPost;
const form = createPostButton.closest('.compose');
const textarea = form?.querySelector('textarea');
const body = textarea?.value.trim();
const mediaInput = form?.querySelector('[data-compose-media]');
const mediaUrl = mediaInput?.value.trim() || state.composeMedia[feedType] || '';
if (!body && !mediaUrl) return;
if (!isServerBacked()) {
const profile = activeProfile();
fallbackData.posts.unshift({
id: Date.now(),
feed_type: feedType,
author: {
id: profile.id,
handle: profile.handle,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
is_verified: profile.is_verified
},
body,
media_url: mediaUrl,
created_at: 'Gerade eben',
likes: 0,
comments: 0,
comments_list: [],
can_delete: true
});
}
textarea.value = '';
if (mediaInput) mediaInput.value = '';
state.composeMedia[feedType] = '';
post('createPost', { feedType, body, mediaUrl });
render();
return;
}
const profileButton = event.target.closest('[data-profile]');
if (profileButton) {
const profileId = Number(profileButton.dataset.profile);
if (state.data) state.data.activeProfileId = profileId;
fallbackData.activeProfileId = profileId;
state.viewedProfileHandle = null;
state.editingProfile = false;
post('setActiveProfile', { profileId });
render();
return;
}
if (event.target.closest('[data-edit-profile]')) {
state.editingProfile = true;
render();
return;
}
// Upload-Buttons sind jetzt <label for="media-file-input"> kein programmatischer Trigger nötig
if (event.target.closest('[data-cancel-profile-edit]')) {
state.editingProfile = false;
render();
return;
}
if (event.target.closest('[data-save-profile]')) {
const payload = {
displayName: document.getElementById('edit-display-name')?.value.trim() || '',
avatarUrl: document.getElementById('edit-avatar-url')?.value.trim() || '',
bannerUrl: document.getElementById('edit-banner-url')?.value.trim() || '',
bio: document.getElementById('edit-bio')?.value.trim() || ''
};
if (!payload.displayName) return;
if (!isServerBacked()) {
const profile = activeProfile();
profile.display_name = payload.displayName;
profile.avatar_url = payload.avatarUrl;
profile.banner_url = payload.bannerUrl;
profile.bio = payload.bio;
}
state.editingProfile = false;
post('updateProfile', payload);
render();
return;
}
if (event.target.closest('[data-create-marketplace]')) {
let title = document.getElementById('market-title')?.value.trim() || '';
let description = document.getElementById('market-description')?.value.trim() || '';
const priceLabel = document.getElementById('market-price')?.value.trim() || '';
const mediaUrl = state.composeMedia.market || '';
if (!title && !description) return;
if (!title) title = description.slice(0, 80);
if (!description) description = title;
if (!isServerBacked()) {
const profile = activeProfile();
fallbackData.marketplace.unshift({
id: Date.now(),
title,
body: description,
price: Number(priceLabel.match(/\d+/)?.[0] || 0),
price_label: priceLabel,
created: 0,
media_url: mediaUrl,
can_delete: true,
author: {
handle: profile.handle,
display_name: profile.display_name,
avatar_url: profile.avatar_url,
email: profile.email_contact || data().account?.mailAddress || '',
is_verified: profile.is_verified
}
});
}
state.composeMedia.market = '';
post('createMarketplaceEntry', { title, description, priceLabel, mediaUrl });
render();
return;
}
const likeButton = event.target.closest('[data-like-post]');
if (likeButton) {
const postId = Number(likeButton.dataset.likePost);
if (state.likedPosts.has(postId)) {
state.likedPosts.delete(postId);
state.likeDeltas[postId] = Number(state.likeDeltas[postId] || 0) - 1;
} else {
state.likedPosts.add(postId);
state.likeDeltas[postId] = Number(state.likeDeltas[postId] || 0) + 1;
}
post('togglePostLike', { postId });
render();
return;
}
const commentsButton = event.target.closest('[data-comments-post]');
if (commentsButton) {
const postId = Number(commentsButton.dataset.commentsPost);
if (state.commentsOpen.has(postId)) state.commentsOpen.delete(postId);
else state.commentsOpen.add(postId);
render();
return;
}
const deletePostButton = event.target.closest('[data-delete-post]');
if (deletePostButton) {
const postId = Number(deletePostButton.dataset.deletePost);
if (!isServerBacked()) {
fallbackData.posts = fallbackData.posts.filter(item => Number(item.id) !== postId);
}
post('deleteOwnPost', { postId });
render();
return;
}
const sendCommentButton = event.target.closest('[data-send-comment]');
if (sendCommentButton) {
const postId = Number(sendCommentButton.dataset.sendComment);
const input = document.querySelector(`[data-comment-input="${postId}"]`);
const body = input?.value.trim();
if (body) {
const profile = activeProfile();
commentsForPost(postId).push({
id: `${postId}-${Date.now()}`,
author: profile.handle || 'ich',
body,
likes: 0,
can_delete: true
});
post('createComment', { postId, body });
render();
}
return;
}
const likeCommentButton = event.target.closest('[data-like-comment]');
if (likeCommentButton) {
const commentId = likeCommentButton.dataset.likeComment;
if (state.likedComments.has(commentId)) {
state.likedComments.delete(commentId);
state.commentLikeDeltas[commentId] = Number(state.commentLikeDeltas[commentId] || 0) - 1;
} else {
state.likedComments.add(commentId);
state.commentLikeDeltas[commentId] = Number(state.commentLikeDeltas[commentId] || 0) + 1;
}
post('toggleCommentLike', { commentId });
render();
return;
}
const deleteCommentButton = event.target.closest('[data-delete-comment]');
if (deleteCommentButton) {
const commentId = deleteCommentButton.dataset.deleteComment;
if (!isServerBacked()) {
for (const postId of Object.keys(state.postComments)) {
state.postComments[postId] = state.postComments[postId].filter(comment => String(comment.id) !== String(commentId));
}
}
post('deleteOwnComment', { commentId });
render();
return;
}
const deleteMarketplaceButton = event.target.closest('[data-delete-marketplace]');
if (deleteMarketplaceButton) {
const entryId = Number(deleteMarketplaceButton.dataset.deleteMarketplace);
if (!isServerBacked()) {
fallbackData.marketplace = fallbackData.marketplace.filter(item => Number(item.id) !== entryId);
}
post('deleteMarketplaceEntry', { entryId });
render();
return;
}
const calendarButton = event.target.closest('[data-calendar-day]');
if (calendarButton) {
state.selectedCalendarDay = Number(calendarButton.dataset.calendarDay);
render();
return;
}
const createEventButton = event.target.closest('[data-create-event]');
if (createEventButton) {
const date = createEventButton.dataset.createEvent;
const time = document.getElementById('event-time')?.value || '20:00';
const title = document.getElementById('event-title')?.value.trim();
const location = document.getElementById('event-location')?.value.trim();
if (!title) return;
if (!isServerBacked()) {
const day = (fallbackData.calendarDays || []).find(item => item.date === date);
const profile = activeProfile();
if (day) {
day.events.push({
id: Date.now(),
time,
title,
location,
author: profile.handle,
can_delete: true
});
}
}
post('createEvent', { date, time, title, location });
render();
return;
}
const deleteEventButton = event.target.closest('[data-delete-event]');
if (deleteEventButton) {
const eventId = Number(deleteEventButton.dataset.deleteEvent);
if (!isServerBacked()) {
for (const day of fallbackData.calendarDays || []) {
day.events = day.events.filter(event => Number(event.id) !== eventId);
}
}
post('deleteEvent', { eventId });
render();
return;
}
const foundProfile = event.target.closest('[data-found-profile]');
if (foundProfile) {
openProfile(foundProfile.dataset.foundProfile);
}
});
document.addEventListener('input', event => {
if (event.target === globalSearch) {
state.searchQuery = globalSearch.value;
renderSearchResults();
return;
}
if (event.target?.id === 'market-search') {
state.marketQuery = event.target.value;
refreshMarketplaceResults();
return;
}
if (event.target?.dataset?.composeMedia) {
state.composeMedia[event.target.dataset.composeMedia] = event.target.value.trim();
}
});
function setComposeUploadTarget(labelEl) {
const form = labelEl.closest('.compose') || labelEl.closest('.upload-row')?.closest('form');
const mediaInput = form?.querySelector('[data-compose-media]');
state.pendingUploadTarget = mediaInput ? `compose:${mediaInput.dataset.composeMedia}` : null;
}
mediaFileInput.addEventListener('change', () => {
const file = mediaFileInput.files?.[0];
const target = state.pendingUploadTarget;
mediaFileInput.value = '';
if (!file || !target) return;
if (file.size > 2 * 1024 * 1024) {
showToast('Datei ist groesser als 2 MB.', 'error');
return;
}
const reader = new FileReader();
reader.addEventListener('load', () => {
const result = String(reader.result || '');
const base64 = result.includes(',') ? result.split(',').pop() : result;
post('uploadMedia', {
target,
name: file.name,
mime: file.type,
size: file.size,
data: base64
});
});
reader.readAsDataURL(file);
});
document.addEventListener('change', event => {
if (event.target?.id === 'market-sort') {
state.marketSort = event.target.value;
refreshMarketplaceResults();
}
});
window.addEventListener('message', event => {
const msg = event.data || {};
if (msg.action === 'open') show(true, msg.mode || 'phone');
if (msg.action === 'close') show(false);
if (msg.action === 'data' && msg.ok) {
const d = msg.data || {};
// Sub-Actions: Registration-Overlay NICHT schließen, Hauptzustand NICHT überschreiben
if (d.action === 'handleCheck') {
handleCheckResult(d);
return;
}
if (d.action === 'registerError') {
const reasonMap = { handle_taken: 'Dieser Handle ist bereits vergeben.', reserved: 'Dieser Handle ist reserviert.' };
showRegError(reasonMap[d.reason] || 'Registrierung fehlgeschlagen.');
return;
}
// Registrierungsflow: Privatprofil fehlt noch
if (d.needs_registration) {
showRegistration(d);
return;
}
// Normaler Bootstrap
state.data = d;
hydrateInteractionState();
state.editingProfile = false;
hideRegistration();
render();
}
if (msg.action === 'notify' && msg.message) {
showToast(msg.message, msg.type || 'info');
}
if (msg.action === 'mediaUploaded' && msg.ok && msg.url) {
applyUploadedMedia(msg.target, msg.url);
showToast('Bild hochgeladen.', 'success');
}
if (msg.action === 'mediaUploaded' && !msg.ok) {
showToast('Upload fehlgeschlagen.', 'error');
}
});
// ── Registrierungs-UI ─────────────────────────────────────────────────────────
let _regData = null;
let _handleCheckTimer = null;
function showRegistration(data) {
_regData = data;
let el = document.getElementById('bleeter-registration');
if (!el) {
el = document.createElement('div');
el.id = 'bleeter-registration';
el.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:9999;display:flex;align-items:center;justify-content:center;';
document.body.appendChild(el);
}
const noMail = data.reason === 'no_mail';
el.innerHTML = `
<div style="background:#0d0f18;border:1px solid #1e2235;border-radius:16px;padding:36px;width:420px;max-width:92vw;">
<div style="text-align:center;margin-bottom:24px;">
<div style="font-size:48px">🐦</div>
<h2 style="color:#fff;font-size:22px;margin:8px 0 4px">Lifeinvader Bleeter</h2>
<p style="color:#888;font-size:13px">Erstelle deinen Account</p>
</div>
${noMail ? `
<div style="background:#1a1000;border:1px solid #5a3800;border-radius:10px;padding:24px;text-align:center;">
<div style="font-size:36px;margin-bottom:12px;">📬</div>
<div style="color:#f0a030;font-size:15px;font-weight:700;margin-bottom:10px;">Keine IC-Mailadresse hinterlegt</div>
<div style="color:#aaa;font-size:13px;line-height:1.6;">
Bleeter benötigt eine persönliche IC-Mailadresse als Login.<br>
Bitte richte deine Mailadresse zuerst im <strong style="color:#fff">IC-PC Mail-App</strong> ein.<br><br>
<span style="color:#666;font-size:11px;">Deine Mailadresse ist dauerhaft mit deinem Bleeter-Profil verknüpft.</span>
</div>
</div>
` : `
<div style="margin-bottom:16px;">
<label style="font-size:11px;color:#666;text-transform:uppercase;letter-spacing:0.5px;display:block;margin-bottom:6px;">IC-Mailadresse</label>
<div style="background:#111520;border:1px solid #1e2235;border-radius:8px;padding:10px 14px;color:#5a8aff;font-size:14px;">${data.mail || ''}</div>
</div>
<div style="margin-bottom:8px;">
<label style="font-size:11px;color:#666;text-transform:uppercase;letter-spacing:0.5px;display:block;margin-bottom:6px;">Bleetername (Handle) <span style="color:#e05050">*</span></label>
<div style="display:flex;gap:8px;align-items:center;">
<span style="color:#888">@</span>
<input id="reg-handle" type="text" value="${data.suggested_handle || ''}"
placeholder="z.B. max_mustermann"
style="flex:1;background:#111520;border:1px solid #1e2235;border-radius:8px;padding:10px 12px;color:#fff;font-size:14px;outline:none;"
oninput="onHandleInput(this.value)" />
</div>
<div id="reg-handle-status" style="font-size:12px;margin-top:6px;min-height:18px;color:#888;">Kleinbuchstaben, Zahlen, . _ - (232 Zeichen)</div>
</div>
<div style="margin-bottom:24px;">
<label style="font-size:11px;color:#666;text-transform:uppercase;letter-spacing:0.5px;display:block;margin-bottom:6px;">Anzeigename</label>
<input id="reg-display" type="text" value="${data.display_name || ''}"
placeholder="Dein Name"
style="width:100%;background:#111520;border:1px solid #1e2235;border-radius:8px;padding:10px 12px;color:#fff;font-size:14px;outline:none;box-sizing:border-box;" />
</div>
<div id="reg-error" style="color:#e05050;font-size:13px;margin-bottom:12px;display:none;"></div>
<button onclick="submitRegistration()"
style="width:100%;padding:13px;background:#1da1f2;border:none;border-radius:10px;color:#fff;font-size:15px;font-weight:700;cursor:pointer;">
Account erstellen
</button>
`}
</div>`;
el.style.display = 'flex';
// Handle vorab prüfen falls vorausgefüllt
if (!noMail && data.suggested_handle) {
setTimeout(() => onHandleInput(data.suggested_handle), 500);
}
}
function hideRegistration() {
const el = document.getElementById('bleeter-registration');
if (el) el.style.display = 'none';
}
function showRegError(msg) {
const el = document.getElementById('reg-error');
if (el) { el.textContent = msg; el.style.display = 'block'; }
const btn = document.querySelector('#bleeter-registration button[onclick="submitRegistration()"]');
if (btn) btn.disabled = false;
}
function onHandleInput(val) {
clearTimeout(_handleCheckTimer);
const status = document.getElementById('reg-handle-status');
val = val.toLowerCase().trim();
if (!status) return;
if (val.length < 2) { status.style.color = '#888'; status.textContent = 'Mindestens 2 Zeichen'; return; }
if (val.length > 32) { status.style.color = '#e05050'; status.textContent = 'Maximal 32 Zeichen'; return; }
if (!/^[a-z0-9._\-]+$/.test(val)) { status.style.color = '#e05050'; status.textContent = 'Ungültige Zeichen (nur a-z, 0-9, . _ -)'; return; }
status.style.color = '#888'; status.textContent = 'Prüfe Verfügbarkeit…';
_handleCheckTimer = setTimeout(() => {
post('checkHandle', { handle: val });
}, 500);
}
function handleCheckResult(data) {
const status = document.getElementById('reg-handle-status');
if (!status) return;
if (data.available) {
status.style.color = '#5dca5d'; status.textContent = '✓ Handle verfügbar';
} else {
const msg = { too_short:'Zu kurz', too_long:'Zu lang', invalid_chars:'Ungültige Zeichen', reserved:'Reserviert', handle_taken:'Bereits vergeben' };
status.style.color = '#e05050'; status.textContent = '✗ ' + (msg[data.reason] || 'Nicht verfügbar');
}
}
function submitRegistration() {
const handle = (document.getElementById('reg-handle')?.value || '').toLowerCase().trim();
const display = (document.getElementById('reg-display')?.value || '').trim();
const status = document.getElementById('reg-handle-status');
if (!handle || (status && status.style.color !== 'rgb(93, 202, 93)')) {
showRegError('Bitte wähle einen verfügbaren Handle.'); return;
}
const btn = document.querySelector('#bleeter-registration button[onclick="submitRegistration()"]');
if (btn) { btn.disabled = true; btn.textContent = '⏳ Wird erstellt…'; }
const errEl = document.getElementById('reg-error');
if (errEl) errEl.style.display = 'none';
post('registerAccount', { handle, displayName: display || handle });
}
function applyUploadedMedia(target, url) {
if (target === 'profile-avatar') {
const input = document.getElementById('edit-avatar-url');
if (input) input.value = url;
return;
}
if (target === 'profile-banner') {
const input = document.getElementById('edit-banner-url');
if (input) input.value = url;
return;
}
if (String(target || '').startsWith('compose:')) {
const feedType = target.split(':')[1];
state.composeMedia[feedType] = url;
const input = document.querySelector(`[data-compose-media="${feedType}"]`);
if (input) input.value = url;
}
}
function showToast(message, type = 'info') {
const layer = document.getElementById('toast-layer');
if (!layer) return;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
layer.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3600);
}
function closeBleeter() {
post('close');
show(false);
}
document.addEventListener('keydown', event => {
if (event.key === 'Escape') closeBleeter();
});
const topCloseButton = document.getElementById('top-close');
if (topCloseButton) topCloseButton.addEventListener('click', closeBleeter);
if (!window.invokeNative) {
show(true);
}