T7.7 - CR-007/D8: the sheet travels with the package, and opens offline
The field wants the specific PDF attached, not a link to a Bluebeam session.
Storage: a new wp_files table (Alembic f3a9d2c1e8b7, additive only) holding
the BYTES in the same database as everything else - the Aug 18 decision: a
backup that excludes the drawings is a backup you cannot restore from. The
D8 numbers bound the cost and are enforced ON THE SERVER as well as in the
browser: 5MB a file (413, naming the limit), PDF and image mimes only (400,
naming what is accepted), 2GB a project (413 naming the ceiling; response
flags the 80% warning). The ceiling is env-overridable for tests; the shipped
default is the decision, asserted from source.
The package record carries a server-owned meta mirror (data.files): the
upload/patch/delete routes rewrite it, and the upsert re-asserts the stored
copy over whatever a client sends - a save from a browser that had not seen
an upload land cannot erase the list.
Creator: uploads live beside the links (links still work), the limits and the
running project total sit ABOVE the picker (amber from 80%, red at full), a
refused file costs nothing but a toast and never leaves the browser (the
probe counts fetch calls), and each drawing has a description ("Tray section,
Level 3 east only") editable inline and persisted server-side. Uploads attach
to the saved record, so T4.3's autosave keeps the surrounding form safe (X8).
Export: uploads print with the package - name, size tag, description on the
attachments table, images inline as the sheet itself, PDFs as links.
Offline (D8): the service worker gains a drawings cache (cache-first on
/api/files/), and field.js prefetches ONLY the requesting user's assigned
packages - assignment-scoped by decision, not project-wide. The probe's first
offline check used CDP network emulation and PASSED FOR THE WRONG REASON: the
emulation binds to the page's session and the service worker fetches on its
own target, straight past it. The shipped check kills the server instead -
my drawing opens, the other package's does not, against a genuinely dead
network.
Field View: a Drawings section on the package detail, 44px rows, description
inline, inside the 390px screen.
Verification (each probe run alone): NEW tests/files_check.py 36/36; the
Alembic chain applied end-to-end to a scratch DB and the table verified.
Regressions: form_structure_check 50/51 (the standing F6 height gap),
frame_check 39/39.
Items: CR-007, D8 (X8 honored)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,11 @@
|
||||
.log-item img { max-width: 160px; max-height: 120px; margin-top: 6px; display: block; border: 1px solid var(--cds-border-subtle); }
|
||||
.fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: var(--cds-ui-05); color: var(--cds-text-on-color); padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; }
|
||||
.fld-toast.show { opacity: 1; }
|
||||
/* CR-007: drawings open from the card, offline once prefetched. 44px rows. */
|
||||
.fld-drawing { display:block; padding:12px 10px; min-height:44px; box-sizing:border-box;
|
||||
border:1px solid var(--cds-border-subtle-01); border-radius:6px; margin-bottom:8px;
|
||||
color: var(--cds-link-primary); text-decoration:none; font-size:14px; }
|
||||
.fld-drawing:active { background: var(--cds-layer-hover-01); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -65,9 +65,23 @@ function loadWPs() {
|
||||
ProjectData.pullProject(PID).then(function () {
|
||||
WPS = activePkgs(readCache());
|
||||
if (!curId) renderList(); else renderDetail();
|
||||
prefetchMyDrawings();
|
||||
}).catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
// CR-007/D8: warm the drawing cache for MY packages while there is a network.
|
||||
// Deliberately only the requesting user's assignments - the decision was that
|
||||
// offline coverage follows assignment, not the whole project's 2GB.
|
||||
function prefetchMyDrawings() {
|
||||
var myId = (window.WP_USER || {}).id;
|
||||
if (!myId || !('serviceWorker' in navigator)) return;
|
||||
WPS.filter(function (p) { return p.assigneeId === myId; }).forEach(function (p) {
|
||||
((p.files) || []).forEach(function (f) {
|
||||
if (f && f.id) fetch('/api/files/' + f.id).catch(function () {});
|
||||
});
|
||||
});
|
||||
}
|
||||
function showNoProject() {
|
||||
var s = document.getElementById('screen-list');
|
||||
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
|
||||
@@ -132,6 +146,11 @@ function renderDetail() {
|
||||
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
|
||||
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
|
||||
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
|
||||
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
|
||||
p.files.map(function (f) {
|
||||
return '<a class="fld-drawing" href="/api/files/' + esc(f.id) + '" target="_blank" rel="noopener">' +
|
||||
'📄 ' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
|
||||
}).join('') + '</div>' : '') +
|
||||
'<div class="fld-sec"><h3>Add field update</h3>' +
|
||||
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
|
||||
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
|
||||
|
||||
19
html/sw.js
19
html/sw.js
@@ -15,6 +15,11 @@
|
||||
// Bumped when the shell file list changes, so clients fetch the new assets
|
||||
// instead of serving a half-old shell from the previous cache.
|
||||
const CACHE = 'wp-suite-shell-v6';
|
||||
// CR-007/D8: uploaded drawings, cached at first fetch so an assigned package's
|
||||
// sheets open with no network. The CLIENT decides what gets fetched (field.js
|
||||
// prefetches only the requesting user's assigned packages); this worker just
|
||||
// keeps whatever came through. Never precached - a fresh sign-in starts empty.
|
||||
const DRAWINGS = 'wp-suite-drawings-v1';
|
||||
const SHELL = [
|
||||
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
||||
'/field.html', '/login.html', '/admin.html', '/users.html',
|
||||
@@ -43,7 +48,7 @@ self.addEventListener('install', (e) => {
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE && k !== DRAWINGS).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
@@ -65,6 +70,18 @@ self.addEventListener('fetch', (e) => {
|
||||
if (req.method !== 'GET') return; // outbox owns writes
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return; // third-party: default
|
||||
// Drawing bytes are immutable once uploaded (edits replace the row id), so
|
||||
// cache-first is safe and is what makes them open offline (CR-007/D8).
|
||||
if (url.pathname.startsWith('/api/files/')) {
|
||||
e.respondWith(
|
||||
caches.open(DRAWINGS).then((c) => c.match(req).then((hit) => hit ||
|
||||
fetch(req).then((res) => {
|
||||
if (res && res.ok) c.put(req, res.clone());
|
||||
return res;
|
||||
})))
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith('/api/')) return; // never cache the API
|
||||
|
||||
const isCode = CODE_RE.test(url.pathname);
|
||||
|
||||
@@ -66,6 +66,7 @@ let SOP=null, editingId=null, numberDirty=false;
|
||||
let pkgKind='iwp'; // 'iwp' (install) | 'ewp' (BIM) — per-package, only relevant when SOP.bimEnabled
|
||||
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
|
||||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[], pkgQaRejections=[];
|
||||
let pkgFiles=[]; // CR-007: uploaded drawing METAS - bytes live on the server, data['files'] is server-owned
|
||||
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
||||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
||||
let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines)
|
||||
@@ -868,6 +869,116 @@ function buildAttach(){
|
||||
tb.appendChild(tr); });
|
||||
}
|
||||
function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); }
|
||||
|
||||
// ── CR-007 / D8: drawing uploads ─────────────────────────────────────────────
|
||||
// Files attach to the SAVED record (they are rows, not form state), so the form
|
||||
// around an upload is never at risk: a refused or failed upload changes nothing
|
||||
// but the toast. Both limits are ALSO enforced by the server - these checks are
|
||||
// the courtesy of refusing before the bytes travel.
|
||||
function wpFileSize(n){
|
||||
if(n>=1024*1024) return (n/1024/1024).toFixed(1)+'MB';
|
||||
if(n>=1024) return Math.round(n/1024)+'KB';
|
||||
return n+'B';
|
||||
}
|
||||
async function wpFileUpload(ev){
|
||||
const inp=ev.target; const f=inp.files&&inp.files[0]; inp.value='';
|
||||
if(!f) return;
|
||||
if(!/^(application\/pdf|image\/)/.test(f.type||'')){
|
||||
toast('Only PDF and image files are accepted — "'+(f.type||f.name.split('.').pop()||'that')+'" is neither.');
|
||||
return;
|
||||
}
|
||||
if(f.size>5*1024*1024){
|
||||
toast('Files are limited to 5MB each — this one is '+wpFileSize(f.size)+'.');
|
||||
return;
|
||||
}
|
||||
if(!editingId){
|
||||
toast('Save the package first — uploads attach to the saved record.');
|
||||
return;
|
||||
}
|
||||
const desc=(document.getElementById('wp-file-desc')||{value:''}).value.trim();
|
||||
let b64='';
|
||||
try{
|
||||
b64=await new Promise((res,rej)=>{ const r=new FileReader();
|
||||
r.onload=()=>res(String(r.result).split(',')[1]||''); r.onerror=rej; r.readAsDataURL(f); });
|
||||
}catch(e){ toast('Could not read that file — the form is untouched.'); return; }
|
||||
let resp, out={};
|
||||
try{
|
||||
resp=await fetch('/api/wps/'+encodeURIComponent(editingId)+'/files',{
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:f.name, mime:f.type, description:desc, data_base64:b64})});
|
||||
out=await resp.json().catch(()=>({}));
|
||||
}catch(e){
|
||||
toast('Upload failed — the network dropped. Nothing else was lost; try again.');
|
||||
return;
|
||||
}
|
||||
if(!resp.ok){
|
||||
const d=out&&out.detail;
|
||||
const msg=(d&&d.message)||(typeof d==='string'?d:'Upload failed ('+resp.status+')');
|
||||
toast(msg);
|
||||
return;
|
||||
}
|
||||
pkgFiles.push(out.file);
|
||||
wpFilesMirror();
|
||||
const de=document.getElementById('wp-file-desc'); if(de) de.value='';
|
||||
wpFilesRender(); wpFileUsageSet(out.used, out.ceiling);
|
||||
toast('Uploaded '+f.name+'.'); track('file_uploaded');
|
||||
}
|
||||
// Keep the local saved-package copy in step so exports and the offline cache
|
||||
// see the list without another fetch. The server copy is authoritative.
|
||||
function wpFilesMirror(){
|
||||
const ix=savedPackages.findIndex(x=>x.id===editingId);
|
||||
if(ix>=0){ savedPackages[ix].files=pkgFiles.map(x=>({...x})); saveStore(); }
|
||||
}
|
||||
function wpFilesRender(){
|
||||
const box=document.getElementById('wp-file-list'); if(!box) return;
|
||||
box.innerHTML=pkgFiles.map(f=>`<div class="wp-file-item">
|
||||
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${f.mime==='application/pdf'?'📄':'🖼'} ${esc(f.name||'drawing')}</a>
|
||||
<span class="wf-size">${wpFileSize(f.size||0)}</span>
|
||||
<input type="text" value="${(f.description||'').replace(/"/g,'"')}" placeholder="focus area, e.g. Tray section, Level 3 east only"
|
||||
aria-label="Description of ${esc(f.name||'drawing')}" onchange="wpFileDescSave('${f.id}', this.value)">
|
||||
<button type="button" class="btn btn-ghost wp-file-x" onclick="wpFileDelete('${f.id}')">Remove</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
async function wpFileDescSave(id, v){
|
||||
try{
|
||||
const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'PATCH',
|
||||
headers:{'Content-Type':'application/json'}, body:JSON.stringify({description:v})});
|
||||
if(!r.ok) throw 0;
|
||||
const f=pkgFiles.find(x=>x.id===id); if(f) f.description=v;
|
||||
wpFilesMirror();
|
||||
}catch(e){ toast('Could not save the description — it is shown but not saved yet.'); }
|
||||
}
|
||||
async function wpFileDelete(id){
|
||||
if(!confirm('Remove this drawing from the package?')) return;
|
||||
try{
|
||||
const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE'});
|
||||
if(!r.ok) throw 0;
|
||||
const out=await r.json();
|
||||
pkgFiles=pkgFiles.filter(f=>f.id!==id);
|
||||
wpFilesMirror(); wpFilesRender(); wpFileUsageSet(out.used, out.ceiling);
|
||||
toast('Drawing removed.');
|
||||
}catch(e){ toast('Could not remove the drawing — try again.'); }
|
||||
}
|
||||
// The running total, on the same line as the rules (D8: warn from 80%, refuse at
|
||||
// the ceiling - the refusal itself comes from the server and names the number).
|
||||
function wpFileUsageSet(used, ceiling){
|
||||
const el=document.getElementById('file-usage'); if(el==null) return;
|
||||
if(typeof used!=='number' || !ceiling){ el.textContent=''; return; }
|
||||
const pct=used/ceiling;
|
||||
const txt=`Project storage: ${wpFileSize(used)} of ${wpFileSize(ceiling)} used.`;
|
||||
el.innerHTML = pct>=1 ? `<span class="fr-full">${txt} Full — remove a drawing to make room.</span>`
|
||||
: pct>=0.8 ? `<span class="fr-warn">${txt} Approaching the ceiling.</span>`
|
||||
: esc(txt);
|
||||
}
|
||||
async function wpFilesRefreshUsage(){
|
||||
if(!activeProjectId) return;
|
||||
try{
|
||||
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/storage');
|
||||
if(!r.ok) return;
|
||||
const out=await r.json();
|
||||
wpFileUsageSet(out.used, out.ceiling);
|
||||
}catch(e){}
|
||||
}
|
||||
function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
||||
|
||||
// ── ADD FILES FROM SOP FOLDER (no-auth interim) ──────────────────────────────
|
||||
@@ -1292,6 +1403,7 @@ function collectPackage(){
|
||||
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
|
||||
holds:pkgHolds.map(h=>({...h})),
|
||||
qaRejections:pkgQaRejections.map(x=>({...x})),
|
||||
files:pkgFiles.map(x=>({...x})), // metas only; the server re-asserts this key on save
|
||||
actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'),
|
||||
kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) — drives which fields/types/gates apply
|
||||
// AWP traceability: which BIM/model package(s) enabled this install package.
|
||||
@@ -1423,10 +1535,18 @@ function renderPackage(pkg){
|
||||
pkg.materials.forEach(m=>t+=`<tr><td>${cell(m.qty)}</td><td>${cell(m.unit)}</td><td>${cell(m.desc)}</td>${showDisc?`<td>${cell(m.discipline)}</td>`:''}</tr>`);
|
||||
add('materials', 'Material List', t+`</tbody></table>`); }
|
||||
|
||||
if(pkg.attachments&&pkg.attachments.length){
|
||||
let t=`<table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note</th></tr></thead><tbody>`;
|
||||
pkg.attachments.forEach(a=>t+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`);
|
||||
add('drawings', 'Drawings & Attachments', t+`</tbody></table>`); }
|
||||
if((pkg.attachments&&pkg.attachments.length)||(pkg.files&&pkg.files.length)){
|
||||
let t=`<table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note / Focus area</th></tr></thead><tbody>`;
|
||||
(pkg.attachments||[]).forEach(a=>t+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`);
|
||||
// CR-007: uploaded drawings print WITH the package - the row for every file,
|
||||
// the image itself inline (it is the sheet the crew needs), PDFs as links.
|
||||
(pkg.files||[]).forEach(f=>t+=`<tr><td><a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${esc(f.name||'drawing')}</a> <span style="font-size:10px;color:var(--text-dim)">[uploaded, ${wpFileSize(f.size||0)}]</span></td><td>${ns()}</td><td>${cell(f.description)}</td></tr>`);
|
||||
t+=`</tbody></table>`;
|
||||
(pkg.files||[]).filter(f=>/^image\//.test(f.mime||'')).forEach(f=>{
|
||||
t+=`<figure style="margin:10px 0"><img src="/api/files/${encodeURIComponent(f.id)}" alt="${(f.name||'').replace(/"/g,'"')}" style="max-width:100%">`
|
||||
+(f.description?`<figcaption style="font-size:11px;color:var(--text-muted)">${esc(f.description)}</figcaption>`:'')+`</figure>`;
|
||||
});
|
||||
add('drawings', 'Drawings & Attachments', t); }
|
||||
|
||||
add('kitting', 'Kitting & MIMO', `<table><tbody>
|
||||
<tr><th style="width:200px">Kitting Status</th><td>${cell(pkg.kitStatus)}</td></tr>
|
||||
@@ -2435,6 +2555,7 @@ function loadPackageIntoForm(p){
|
||||
pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows();
|
||||
pkgHolds=(p.holds||[]).map(h=>({...h}));
|
||||
pkgQaRejections=(p.qaRejections||[]).map(x=>({...x}));
|
||||
pkgFiles=(p.files||[]).map(x=>({...x})); wpFilesRender(); wpFilesRefreshUsage();
|
||||
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
|
||||
}
|
||||
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
|
||||
@@ -2497,7 +2618,7 @@ function newPackage(){
|
||||
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
||||
pkgWorkSteps=['']; buildWorkSteps();
|
||||
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
|
||||
pkgHolds=[]; pkgQaRejections=[]; pkgOverrides={};
|
||||
pkgHolds=[]; pkgQaRejections=[]; pkgFiles=[]; wpFilesRender(); pkgOverrides={};
|
||||
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||||
prevStatus='Draft';
|
||||
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');
|
||||
@@ -3269,6 +3390,7 @@ function bootData(){
|
||||
setRadio('status','Draft');
|
||||
loadMembers();
|
||||
loadLocations(); // CR-004: the option lists come from the server
|
||||
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
|
||||
initWpNavDrawer();
|
||||
renderSavedList();
|
||||
positionSectionNav();
|
||||
|
||||
@@ -355,6 +355,20 @@
|
||||
<div class="sub-heading">Drawings & Attachments</div>
|
||||
<div id="sop-ref-links" class="sop-ref-links"></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Document / Drawing</th><th style="width:90px">Rev</th><th>Link / Note</th><th style="width:44px"></th></tr></thead><tbody id="attach-body"></tbody></table></div>
|
||||
<!-- CR-007/D8: uploads live beside the links, never instead of them. The
|
||||
limits are stated HERE, before anyone picks a file, and the running
|
||||
project total is on the same line (warning style from 80%). -->
|
||||
<div class="file-rules" id="file-rules">
|
||||
Uploads: <strong>PDF or image, up to 5MB a file.</strong>
|
||||
<span id="file-usage" aria-live="polite"></span>
|
||||
</div>
|
||||
<div id="wp-file-list" class="wp-file-list"></div>
|
||||
<div class="wp-file-row">
|
||||
<label class="add-btn wp-file-pick">⇪ Upload drawing
|
||||
<input type="file" id="wp-file-input" accept="application/pdf,image/png,image/jpeg,image/gif,image/webp" style="display:none" onchange="wpFileUpload(event)">
|
||||
</label>
|
||||
<input type="text" id="wp-file-desc" placeholder="focus area, e.g. Tray section, Level 3 east only" aria-label="Description for the next upload">
|
||||
</div>
|
||||
<button class="add-btn" onclick="addAttach()">+ Add document</button>
|
||||
<button class="add-btn" onclick="toggleSopFilePanel()">+ Add files from SOP folder</button>
|
||||
<div id="sop-file-panel" style="display:none; margin-top:0.75rem; padding:0.75rem; border:1px dashed var(--border); border-radius:6px; background:var(--bg);">
|
||||
|
||||
@@ -581,6 +581,20 @@
|
||||
.pill-hold.selected { background:var(--red) !important; border-color:var(--red) !important; }
|
||||
.pill-hold.selected .dot { background:var(--cds-text-on-color) !important; }
|
||||
|
||||
/* CR-007/D8: the upload strip in Drawings & Attachments. */
|
||||
.file-rules { margin:10px 0 6px; font-size:12px; color:var(--text-muted); }
|
||||
.file-rules .fr-warn { color:var(--accent-amber); font-weight:700; }
|
||||
.file-rules .fr-full { color:var(--red); font-weight:700; }
|
||||
.wp-file-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:6px 0; }
|
||||
.wp-file-row input[type="text"] { flex:1 1 240px; }
|
||||
.wp-file-list { display:flex; flex-direction:column; gap:6px; margin:6px 0; }
|
||||
.wp-file-item { display:flex; gap:10px; align-items:center; flex-wrap:wrap;
|
||||
border:1px solid var(--border); border-radius:var(--radius); padding:8px 10px; }
|
||||
.wp-file-item a { color:var(--accent); text-decoration:none; font-weight:600; overflow-wrap:anywhere; }
|
||||
.wp-file-item .wf-size { color:var(--text-muted); font-size:11px; }
|
||||
.wp-file-item input { flex:1 1 200px; font-size:12px; }
|
||||
.wp-file-x { margin-left:auto; }
|
||||
|
||||
.cstatus { display:inline-flex; border:1px solid var(--border-strong); border-radius:5px; overflow:hidden; }
|
||||
.cstatus button { border:none; background:var(--surface); color:var(--text-muted); font-family:var(--sans); font-size:11px;
|
||||
font-weight:600; padding:4px 10px; cursor:pointer; border-right:1px solid var(--border); }
|
||||
|
||||
Reference in New Issue
Block a user