Files
Joachim Hummel 13ccbd5b9b Add .env support for configurable Name and Slogan in preview cards
NAME and SLOGAN are read from .env via dotenv and injected into the
LinkedIn preview card template at startup. Avatar initials are auto-
generated from the first letters of NAME. Works identically with
npm start and docker compose (via env_file).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 09:14:15 +00:00

289 lines
16 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LinkedIn Post Formatter</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f3f2ef;--surface:#fff;--border:#e0ddd6;--border2:#c8c5be;
--text:#1a1a18;--muted:#666;--radius:8px;--radius-lg:12px;
}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;flex-direction:column;}
header{background:#fff;border-bottom:1px solid var(--border);padding:13px 24px;display:flex;align-items:center;gap:12px;}
.li-logo{width:28px;height:28px;background:#0077b5;border-radius:5px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:15px;flex-shrink:0;}
header h1{font-size:16px;font-weight:600;}
header span{font-size:13px;color:var(--muted);margin-left:auto;}
.layout{display:flex;flex:1;overflow:hidden;max-height:calc(100vh - 54px);}
.left{width:360px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--border);background:#fff;}
.panel-hd{padding:11px 16px;border-bottom:1px solid var(--border);font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.toolbar{padding:8px 12px;border-bottom:1px solid var(--border);display:flex;flex-wrap:wrap;gap:4px;background:#fafaf8;}
.toolbar button{font-size:13px;padding:4px 10px;border:1px solid var(--border2);background:#fff;color:var(--text);border-radius:var(--radius);cursor:pointer;font-family:inherit;}
.toolbar button:hover{background:var(--border);}
.sep{width:1px;background:var(--border2);margin:2px 2px;align-self:stretch;}
textarea{flex:1;width:100%;padding:16px;font-size:15px;line-height:1.75;border:none;outline:none;resize:none;background:#fff;color:var(--text);font-family:inherit;}
.left-foot{padding:9px 16px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;}
.counter{font-size:13px;color:var(--muted);}
.counter strong{color:var(--text);}
.counter.warn strong{color:#b45309;}
.counter.danger strong{color:#c0392b;}
.prog{flex:1;height:4px;background:var(--border);border-radius:2px;overflow:hidden;}
.prog-fill{height:100%;background:#2d7a3a;border-radius:2px;transition:width .15s,background .15s;}
button.gen{background:#0a66c2;color:#fff;border:none;border-radius:var(--radius);font-size:13px;font-weight:500;padding:6px 16px;cursor:pointer;font-family:inherit;white-space:nowrap;}
button.gen:hover{background:#094fa3;}
.right{flex:1;overflow-y:auto;padding:20px;}
.hint{font-size:13px;color:var(--muted);margin-bottom:16px;}
.hint kbd{background:#eee;padding:1px 5px;border-radius:4px;font-size:12px;border:1px solid var(--border2);}
.cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px;}
.variant-card{background:#fff;border-radius:var(--radius-lg);border:1px solid var(--border);overflow:hidden;}
.variant-label{padding:7px 13px;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--border);background:#fafaf8;}
.li-post{padding:12px 13px 10px;}
.li-post-header{display:flex;align-items:center;gap:9px;margin-bottom:9px;}
.li-avatar{width:36px;height:36px;border-radius:50%;background:#0077b5;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:13px;flex-shrink:0;}
.li-name{font-size:13px;font-weight:600;line-height:1.3;}
.li-sub{font-size:11px;color:var(--muted);}
.li-text{font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-word;color:#1a1a18;max-height:200px;overflow:hidden;position:relative;}
.li-text.expanded{max-height:none;}
.li-fade{position:absolute;bottom:0;left:0;right:0;height:36px;background:linear-gradient(transparent,#fff);}
.li-more{font-size:12px;color:#0a66c2;cursor:pointer;margin-top:3px;}
.card-foot{display:flex;align-items:center;justify-content:space-between;padding:8px 13px;border-top:1px solid var(--border);}
.li-actions{display:flex;gap:0;}
.li-act{font-size:11px;color:var(--muted);padding:3px 7px;border-radius:4px;cursor:pointer;}
.li-act:hover{background:var(--bg);}
.copy-btn{font-size:12px;padding:5px 11px;border:1px solid var(--border2);background:#fff;color:var(--text);border-radius:var(--radius);cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:4px;}
.copy-btn:hover{background:var(--bg);}
.copy-btn.copied{background:#d1fae5;color:#065f46;border-color:#6ee7b7;}
.empty-state{text-align:center;padding:80px 20px;color:var(--muted);}
.empty-state p{font-size:15px;margin-bottom:8px;color:var(--text);}
.empty-state small{font-size:13px;}
@media(max-width:768px){
.layout{flex-direction:column;max-height:none;overflow:visible;}
.left{width:100%;}
.right{padding:14px;}
}
</style>
</head>
<body>
<header>
<div class="li-logo">in</div>
<h1>LinkedIn Post Formatter</h1>
<span>self-hosted · kostenlos · kein Login</span>
</header>
<div class="layout">
<div class="left">
<div class="panel-hd">Eingabe</div>
<div class="toolbar">
<button onclick="wrapSel('**','**')"><b>B</b> Fett</button>
<button onclick="wrapSel('_','_')"><i>I</i> Kursiv</button>
<div class="sep"></div>
<button onclick="ins('• ')"></button>
<button onclick="ins('→ ')"></button>
<div class="sep"></div>
<button onclick="ins('👉 ')">👉</button>
<button onclick="ins('✅ ')"></button>
<button onclick="ins('❌ ')"></button>
<button onclick="ins('⚠️ ')">⚠️</button>
<button onclick="ins('💡')">💡</button>
<button onclick="ins('🔥')">🔥</button>
<button onclick="ins('🚀')">🚀</button>
<button onclick="ins('⚡')"></button>
<button onclick="ins('📌')">📌</button>
<button onclick="ins('🎯')">🎯</button>
<button onclick="ins('📊')">📊</button>
<button onclick="ins('🔧')">🔧</button>
<button onclick="ins('💰')">💰</button>
<button onclick="ins('📈')">📈</button>
<button onclick="ins('🤝')">🤝</button>
<button onclick="ins('👀')">👀</button>
<button onclick="ins('💬')">💬</button>
<button onclick="ins('✍️')">✍️</button>
<button onclick="ins('🏆')">🏆</button>
<button onclick="ins('❓')"></button>
<button onclick="ins('1⃣ ')">1</button>
<button onclick="ins('2⃣ ')">2</button>
<button onclick="ins('3⃣ ')">3</button>
<button onclick="ins('🧠')">🧠</button>
<button onclick="ins('📣')">📣</button>
<button onclick="ins('🔑')">🔑</button>
<button onclick="ins('👆 ')">👆</button>
<button onclick="ins('☑️ ')">☑️</button>
<button onclick="ins('🌍')">🌍</button>
<button onclick="ins('⏰')"></button>
<button onclick="ins('💎')">💎</button>
<div class="sep"></div>
<button onclick="clearAll()"></button>
</div>
<textarea id="ta" placeholder="Text eingeben...
**Doppelstern** = Fett
_Unterstrich_ = Kursiv
Leerzeile = Absatz in LinkedIn"></textarea>
<div class="left-foot">
<div class="counter" id="ctr"><strong>0</strong>&nbsp;/ 3000</div>
<div class="prog"><div class="prog-fill" id="bar" style="width:0%"></div></div>
<button class="gen" onclick="generate()">Vorschau ↗</button>
</div>
</div>
<div class="right" id="right">
<div class="empty-state">
<p>Text eingeben → „Vorschau" klicken</p>
<small>6 Schriftvarianten als LinkedIn-Karten — direkt per „Copy text" in die Zwischenablage</small>
</div>
</div>
</div>
<script>
const B={A:'𝗔',B:'𝗕',C:'𝗖',D:'𝗗',E:'𝗘',F:'𝗙',G:'𝗚',H:'𝗛',I:'𝗜',J:'𝗝',K:'𝗞',L:'𝗟',M:'𝗠',N:'𝗡',O:'𝗢',P:'𝗣',Q:'𝗤',R:'𝗥',S:'𝗦',T:'𝗧',U:'𝗨',V:'𝗩',W:'𝗪',X:'𝗫',Y:'𝗬',Z:'𝗭',a:'𝗮',b:'𝗯',c:'𝗰',d:'𝗱',e:'𝗲',f:'𝗳',g:'𝗴',h:'𝗵',i:'𝗶',j:'𝗷',k:'𝗸',l:'𝗹',m:'𝗺',n:'𝗻',o:'𝗼',p:'𝗽',q:'𝗾',r:'𝗿',s:'𝘀',t:'𝘁',u:'𝘂',v:'𝘃',w:'𝘄',x:'𝘅',y:'𝘆',z:'𝘇','0':'𝟬','1':'𝟭','2':'𝟮','3':'𝟯','4':'𝟰','5':'𝟱','6':'𝟲','7':'𝟳','8':'𝟴','9':'𝟵'};
const I={A:'𝘈',B:'𝘉',C:'𝘊',D:'𝘋',E:'𝘌',F:'𝘍',G:'𝘎',H:'𝘏',I:'𝘐',J:'𝘑',K:'𝘒',L:'𝘓',M:'𝘔',N:'𝘕',O:'𝘖',P:'𝘗',Q:'𝘘',R:'𝘙',S:'𝘚',T:'𝘛',U:'𝘜',V:'𝘝',W:'𝘞',X:'𝘟',Y:'𝘠',Z:'𝘡',a:'𝘢',b:'𝘣',c:'𝘤',d:'𝘥',e:'𝘦',f:'𝘧',g:'𝘨',h:'𝘩',i:'𝘪',j:'𝘫',k:'𝘬',l:'𝘭',m:'𝘮',n:'𝘯',o:'𝘰',p:'𝘱',q:'𝘲',r:'𝘳',s:'𝘴',t:'𝘵',u:'𝘶',v:'𝘷',w:'𝘸',x:'𝘹',y:'𝘺',z:'𝘻'};
const BI={A:'𝘼',B:'𝘽',C:'𝘾',D:'𝘿',E:'𝙀',F:'𝙁',G:'𝙂',H:'𝙃',I:'𝙄',J:'𝙅',K:'𝙆',L:'𝙇',M:'𝙈',N:'𝙉',O:'𝙊',P:'𝙋',Q:'𝙌',R:'𝙍',S:'𝙎',T:'𝙏',U:'𝙐',V:'𝙑',W:'𝙒',X:'𝙓',Y:'𝙔',Z:'𝙕',a:'𝙖',b:'𝙗',c:'𝙘',d:'𝙙',e:'𝙚',f:'𝙛',g:'𝙜',h:'𝙝',i:'𝙞',j:'𝙟',k:'𝙠',l:'𝙡',m:'𝙢',n:'𝙣',o:'𝙤',p:'𝙥',q:'𝙦',r:'𝙧',s:'𝙨',t:'𝙩',u:'𝙪',v:'𝙫',w:'𝙬',x:'𝙭',y:'𝙮',z:'𝙯'};
const MN={A:'𝙰',B:'𝙱',C:'𝙲',D:'𝙳',E:'𝙴',F:'𝙵',G:'𝙶',H:'𝙷',I:'𝙸',J:'𝙹',K:'𝙺',L:'𝙻',M:'𝙼',N:'𝙽',O:'𝙾',P:'𝙿',Q:'𝚀',R:'𝚁',S:'𝚂',T:'𝚃',U:'𝚄',V:'𝚅',W:'𝚆',X:'𝚇',Y:'𝚈',Z:'𝚉',a:'𝚊',b:'𝚋',c:'𝚌',d:'𝚍',e:'𝚎',f:'𝚏',g:'𝚐',h:'𝚑',i:'𝚒',j:'𝚓',k:'𝚔',l:'𝚕',m:'𝚖',n:'𝚗',o:'𝚘',p:'𝚙',q:'𝚚',r:'𝚛',s:'𝚜',t:'𝚝',u:'𝚞',v:'𝚟',w:'𝚠',x:'𝚡',y:'𝚢',z:'𝚣'};
function mp(s,m){return s.split('').map(c=>m[c]||c).join('');}
function applyInline(t){
t=t.replace(/\*\*(.+?)\*\*/g,(_,m)=>mp(m,B));
t=t.replace(/_(.+?)_/g,(_,m)=>mp(m,I));
return t;
}
function mapAll(t,m){
// map every char but preserve newlines & emojis
return t.split('').map(c=>m[c]||c).join('');
}
function stripMarkers(t){
return t.replace(/\*\*(.+?)\*\*/g,'$1').replace(/_(.+?)_/g,'$1');
}
// LinkedIn paragraph fix: blank lines get a zero-width space so they survive paste
function liParagraph(t){
return t.replace(/\n\n/g,'\n\u200b\n');
}
function buildVariants(raw){
const plain = stripMarkers(raw);
return [
{label:'Sans (Standard)', text: liParagraph(applyInline(raw))},
{label:'Bold Sans', text: liParagraph(mapAll(applyInline(raw),B))},
{label:'Italic Sans', text: liParagraph(mapAll(applyInline(raw),I))},
{label:'Bold Italic Sans', text: liParagraph(mapAll(applyInline(raw),BI))},
{label:'Monospace', text: liParagraph(mapAll(plain,MN))},
{label:'Sans (ohne Formatierung)',text: liParagraph(plain)},
];
}
let _variants=[];
function generate(){
const raw=document.getElementById('ta').value;
if(!raw.trim())return;
_variants=buildVariants(raw);
const right=document.getElementById('right');
right.innerHTML='<p class="hint">Variante wählen → <b>Copy text</b> → direkt in LinkedIn einfügen. Zeilenumbrüche bleiben erhalten.</p><div class="cards-grid" id="grid"></div>';
const grid=document.getElementById('grid');
_variants.forEach((v,i)=>{
const d=document.createElement('div');
d.className='variant-card';
d.innerHTML=`
<div class="variant-label">${v.label}</div>
<div class="li-post">
<div class="li-post-header">
<div class="li-avatar">{{INITIALS}}</div>
<div><div class="li-name">{{NAME}}</div><div class="li-sub">{{SLOGAN}}</div></div>
</div>
<div class="li-text" id="t${i}">${esc(v.text)}</div>
<div class="li-more" id="m${i}" onclick="exp(${i})" style="display:none">… mehr anzeigen</div>
</div>
<div class="card-foot">
<div class="li-actions">
<div class="li-act">👍 Like</div>
<div class="li-act">💬 Kommentar</div>
</div>
<button class="copy-btn" id="cb${i}" onclick="cp(${i})">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy text
</button>
</div>`;
grid.appendChild(d);
setTimeout(()=>{
const el=document.getElementById('t'+i);
if(el&&el.scrollHeight>el.clientHeight+4){
const f=document.createElement('div');f.className='li-fade';el.appendChild(f);
document.getElementById('m'+i).style.display='block';
}
},60);
});
}
function exp(i){
const el=document.getElementById('t'+i);
el.classList.add('expanded');
const f=el.querySelector('.li-fade');if(f)f.remove();
document.getElementById('m'+i).style.display='none';
}
function cp(i){
const btn=document.getElementById('cb'+i);
const text=_variants[i].text;
if(navigator.clipboard&&window.isSecureContext){
navigator.clipboard.writeText(text).then(()=>flash(btn)).catch(()=>legacyCopy(text,btn));
} else {
legacyCopy(text,btn);
}
}
function legacyCopy(text,btn){
const ta=document.createElement('textarea');
ta.value=text;
ta.style.cssText='position:fixed;top:0;left:0;width:2px;height:2px;opacity:0.01;border:none;outline:none;';
document.body.appendChild(ta);
ta.focus();ta.select();ta.setSelectionRange(0,text.length);
try{ document.execCommand('copy'); flash(btn); }
catch(e){ alert('Kopieren fehlgeschlagen bitte manuell kopieren (Strg+A, Strg+C) aus dem Vorschau-Feld.'); }
document.body.removeChild(ta);
}
function flash(btn){
btn.textContent='✓ Kopiert!';btn.classList.add('copied');
setTimeout(()=>{
btn.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy text';
btn.classList.remove('copied');
},2000);
}
function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
document.getElementById('ta').addEventListener('input',()=>{
const len=document.getElementById('ta').value.length;
const el=document.getElementById('ctr');
const bar=document.getElementById('bar');
el.querySelector('strong').textContent=len.toLocaleString('de-DE');
const pct=Math.min(len/3000*100,100);
bar.style.width=pct+'%';
bar.style.background=pct>=100?'#c0392b':pct>=80?'#b45309':'#2d7a3a';
el.className='counter'+(pct>=100?' danger':pct>=80?' warn':'');
});
document.getElementById('ta').addEventListener('keydown',e=>{
if((e.ctrlKey||e.metaKey)&&e.key==='b'){e.preventDefault();wrapSel('**','**');}
if((e.ctrlKey||e.metaKey)&&e.key==='i'){e.preventDefault();wrapSel('_','_');}
if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();generate();}
});
function wrapSel(b,a){
const ta=document.getElementById('ta');
const s=ta.selectionStart,e=ta.selectionEnd,v=ta.value;
ta.value=v.slice(0,s)+b+v.slice(s,e)+a+v.slice(e);
ta.selectionStart=s+b.length;ta.selectionEnd=e+b.length;ta.focus();
}
function ins(ch){
const ta=document.getElementById('ta');
const s=ta.selectionStart,v=ta.value;
ta.value=v.slice(0,s)+ch+v.slice(s);
ta.selectionStart=ta.selectionEnd=s+ch.length;ta.focus();
}
function clearAll(){
document.getElementById('ta').value='';
document.getElementById('ctr').querySelector('strong').textContent='0';
document.getElementById('bar').style.width='0%';
document.getElementById('right').innerHTML='<div class="empty-state"><p>Text eingeben → „Vorschau" klicken</p><small>6 Schriftvarianten als LinkedIn-Karten — direkt per „Copy text" kopieren</small></div>';
}
</script>
</body>
</html>