bleeter/html/script.js
Bjoern Flessing 20076b0aee 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:29:24 +00:00

1961 lines
90 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}