feat: viewer de code style GitHub avec numéros de ligne + Copy
Refonte du _render_file_viewer pour les fichiers texte/code:
- Palette GitHub Dark (#0d1117, #161b22, #c9d1d9)
- Topbar sticky avec: ← Workspace, titre, badge langage, taille
- Bouton 📋 Copy (clipboard API, feedback '✓ Copied!')
- Bouton ⬇ Download
- Numéros de ligne dans une colonne latérale (line-numbers)
- Code rendu ligne par ligne (<span> par ligne)
- Font monospace (Fira Code, Cascadia Code, JetBrains Mono)
- escapeHtml() pour éviter les injections XSS
This commit is contained in:
+53
-16
@@ -938,18 +938,30 @@ def _render_file_viewer(page: dict, request: Request) -> HTMLResponse:
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title} — FlowDeck</title>
|
||||
<style>
|
||||
:root{{--bg:#191919;--bg2:#242424;--text:#e0e0e0;--text2:#999;--accent:#2383e2;--border:#333;}}
|
||||
:root{{--bg:#0d1117;--bg2:#161b22;--text:#c9d1d9;--text2:#8b949e;--accent:#58a6ff;--border:#30363d;--line-num:#484f58;}}
|
||||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||||
body{{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh;}}
|
||||
.topbar{{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--bg2);border-bottom:1px solid var(--border);}}
|
||||
.topbar a,.topbar button{{color:var(--text2);text-decoration:none;background:none;border:none;cursor:pointer;font-size:13px;padding:6px 10px;border-radius:6px;}}
|
||||
.topbar a:hover,.topbar button:hover{{background:var(--bg);color:var(--text);}}
|
||||
.topbar .title{{font-weight:600;color:var(--text);font-size:14px;flex:1;}}
|
||||
.meta{{font-size:12px;color:var(--text2);}}
|
||||
.viewer{{padding:0;max-width:100%;margin:0 auto;}}
|
||||
.topbar{{display:flex;align-items:center;gap:10px;padding:8px 16px;background:var(--bg2);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:10;}}
|
||||
.topbar a,.topbar button{{color:var(--text2);text-decoration:none;background:var(--bg);border:1px solid var(--border);cursor:pointer;font-size:12px;padding:5px 10px;border-radius:6px;display:inline-flex;align-items:center;gap:4px;transition:all 150ms;}}
|
||||
.topbar a:hover,.topbar button:hover{{background:var(--border);color:var(--text);}}
|
||||
.topbar .title{{font-weight:600;color:var(--text);font-size:14px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
|
||||
.topbar .lang-badge{{font-size:11px;padding:2px 8px;background:var(--accent);color:#fff;border-radius:10px;font-weight:500;border:none;}}
|
||||
.meta{{font-size:11px;color:var(--text2);white-space:nowrap;}}
|
||||
.btn-copy{{gap:4px;}}
|
||||
.btn-copy.copied{{background:#238636;border-color:#238636;color:#fff;}}
|
||||
.viewer{{max-width:100%;margin:0 auto;}}
|
||||
.viewer img{{max-width:100%;max-height:85vh;display:block;margin:0 auto;}}
|
||||
.viewer pre{{margin:0;padding:20px;background:var(--bg);color:var(--text);font-size:13px;line-height:1.6;overflow:auto;max-height:90vh;font-family:'Fira Code','Cascadia Code','JetBrains Mono',monospace;white-space:pre-wrap;word-wrap:break-word;}}
|
||||
.viewer iframe{{width:100%;height:90vh;border:none;}}
|
||||
.code-container{{display:flex;overflow:auto;max-height:90vh;font-family:'Fira Code','Cascadia Code','JetBrains Mono','SF Mono',monospace;font-size:12px;line-height:20px;}}
|
||||
.line-numbers{{flex-shrink:0;padding:12px 10px;text-align:right;color:var(--line-num);background:var(--bg2);border-right:1px solid var(--border);user-select:none;min-width:48px;}}
|
||||
.line-numbers span{{display:block;}}
|
||||
.code-content{{flex:1;padding:12px 16px;overflow:auto;white-space:pre;color:var(--text);background:var(--bg);}}
|
||||
.code-content span{{display:block;}}
|
||||
.code-content .hl-kw{{color:#ff7b72;}}
|
||||
.code-content .hl-str{{color:#a5d6ff;}}
|
||||
.code-content .hl-cmt{{color:#8b949e;font-style:italic;}}
|
||||
.code-content .hl-num{{color:#79c0ff;}}
|
||||
.code-content .hl-fn{{color:#d2a8ff;}}
|
||||
.unsupported{{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 20px;text-align:center;}}
|
||||
.unsupported .icon{{font-size:64px;margin-bottom:16px;}}
|
||||
.unsupported h2{{font-size:20px;margin-bottom:8px;}}
|
||||
@@ -959,8 +971,10 @@ body{{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacS
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<a href="/local-workspace">← Workspace</a>
|
||||
<span class="title">📄 {title}</span>
|
||||
<span class="meta">{size_str} · {mime_type}</span>
|
||||
<span class="title">{title}</span>
|
||||
<span class="lang-badge">{lang}</span>
|
||||
<span class="meta">{size_str}</span>
|
||||
<button class="btn-copy" onclick="copyCode()" title="Copy to clipboard">📋 Copy</button>
|
||||
<a href="{file_url}" download>⬇ Download</a>
|
||||
</div>
|
||||
<div class="viewer">"""
|
||||
@@ -970,12 +984,35 @@ body{{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacS
|
||||
elif is_pdf:
|
||||
html += f'<iframe src="{file_url}"></iframe>'
|
||||
elif is_text:
|
||||
# Fetch and embed the content as text
|
||||
html += f'<pre class="language-{lang}"><code id="code-content">Loading...</code></pre>'
|
||||
html += f'''<script>
|
||||
fetch("{file_url}").then(r=>r.text()).then(t=>{{
|
||||
document.getElementById("code-content").textContent=t;
|
||||
}}).catch(e=>{{document.getElementById("code-content").textContent="Error loading file: "+e;}});
|
||||
html += f'''<div class="code-container">
|
||||
<div class="line-numbers" id="line-numbers"></div>
|
||||
<div class="code-content" id="code-content">Loading...</div>
|
||||
</div>
|
||||
<script>
|
||||
var codeEl=document.getElementById("code-content");
|
||||
var lineEl=document.getElementById("line-numbers");
|
||||
fetch("{file_url}").then(function(r){{return r.text()}}).then(function(t){{
|
||||
var lines=t.split("\\n");
|
||||
// Remove trailing empty line
|
||||
if(lines.length>1&&lines[lines.length-1]==="")lines.pop();
|
||||
var numHtml="",codeHtml="";
|
||||
for(var i=0;i<lines.length;i++){{
|
||||
numHtml+="<span>"+(i+1)+"</span>";
|
||||
codeHtml+="<span>"+escHtml(lines[i])+"</span>";
|
||||
}}
|
||||
lineEl.innerHTML=numHtml;
|
||||
codeEl.innerHTML=codeHtml;
|
||||
}}).catch(function(e){{codeEl.textContent="Error loading file: "+e;}});
|
||||
function escHtml(s){{return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}
|
||||
function copyCode(){{
|
||||
var text=Array.from(codeEl.children).map(function(s){{return s.textContent}}).join("\\n");
|
||||
navigator.clipboard.writeText(text).then(function(){{
|
||||
var btn=document.querySelector(".btn-copy");
|
||||
btn.textContent="✓ Copied!";
|
||||
btn.classList.add("copied");
|
||||
setTimeout(function(){{btn.textContent="📋 Copy";btn.classList.remove("copied");}},2000);
|
||||
}});
|
||||
}}
|
||||
</script>'''
|
||||
else:
|
||||
html += f'''<div class="unsupported">
|
||||
|
||||
Reference in New Issue
Block a user