Compare commits
4 Commits
cf489c9f4a
...
d9b7c88ccf
| Author | SHA1 | Date | |
|---|---|---|---|
|
d9b7c88ccf
|
|||
|
7b77387182
|
|||
|
65951a23ce
|
|||
|
5bbc100d83
|
@@ -72,8 +72,12 @@ class ConvexDB(ConvexDbBase):
|
|||||||
args={ 'file_id': file_id, 'user_id': user_id }
|
args={ 'file_id': file_id, 'user_id': user_id }
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_file_informations(self, file_id:str):
|
async def get_file_informations(self, file_id:str, user_id:str):
|
||||||
pass
|
data = await self.run_query(
|
||||||
|
name='files:getFileByIdAndUser',
|
||||||
|
args={ 'file_id': file_id, 'user_id': user_id }
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
# File Access Quary Functions
|
# File Access Quary Functions
|
||||||
async def add_file_access(self, file_id: str, ip_address:str, status:str, user_agent:str):
|
async def add_file_access(self, file_id: str, ip_address:str, status:str, user_agent:str):
|
||||||
@@ -90,7 +94,9 @@ class ConvexDB(ConvexDbBase):
|
|||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def get_file_access(self, file_id: str):
|
async def get_file_access(self, file_id:str, user_id:str):
|
||||||
|
return []
|
||||||
|
|
||||||
data = await self.run_query_with_reconnection(
|
data = await self.run_query_with_reconnection(
|
||||||
self.client.query_single,
|
self.client.query_single,
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ REDIRECT_URI_SCHEME = os.getenv('REDIRECT_URI_SCHEME', 'http')
|
|||||||
|
|
||||||
async def get_oidc_metadata():
|
async def get_oidc_metadata():
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
response = await client.get(OIDC_METADATA_URL)
|
response = await client.get(OIDC_METADATA_URL)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
except httpx.ReadTimeout:
|
||||||
|
return await get_oidc_metadata()
|
||||||
|
|
||||||
@auth_login_bp.route('/login', methods=['GET'])
|
@auth_login_bp.route('/login', methods=['GET'])
|
||||||
@auth_login_bp.route('/auth', methods=['GET'])
|
@auth_login_bp.route('/auth', methods=['GET'])
|
||||||
@@ -87,12 +90,18 @@ async def auth_callback():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Exchange code for token
|
# Exchange code for token
|
||||||
|
token_fetched = False
|
||||||
|
while not token_fetched:
|
||||||
|
try:
|
||||||
token = await client.fetch_token(
|
token = await client.fetch_token(
|
||||||
metadata['token_endpoint'],
|
metadata['token_endpoint'],
|
||||||
code=code,
|
code=code,
|
||||||
grant_type='authorization_code'
|
grant_type='authorization_code'
|
||||||
)
|
)
|
||||||
await logger.debug(f'Auth Callback | token: {token}')
|
await logger.debug(f'Auth Callback | token: {token}')
|
||||||
|
token_fetched = True
|
||||||
|
except httpx.ReadTimeout:
|
||||||
|
pass
|
||||||
|
|
||||||
# Decode ID token
|
# Decode ID token
|
||||||
id_token = token.get('id_token')
|
id_token = token.get('id_token')
|
||||||
@@ -126,6 +135,7 @@ async def auth_callback():
|
|||||||
return await render_template('views/api/token.htm', error="You don't have Permissions to Access this API"), 403
|
return await render_template('views/api/token.htm', error="You don't have Permissions to Access this API"), 403
|
||||||
|
|
||||||
session['user'] = claims
|
session['user'] = claims
|
||||||
|
|
||||||
response = await make_response(redirect(url_for('side_main.index')))
|
response = await make_response(redirect(url_for('side_main.index')))
|
||||||
response.set_cookie('auth_id', '', max_age=0, httponly=True, secure=True, samesite='Lax')
|
response.set_cookie('auth_id', '', max_age=0, httponly=True, secure=True, samesite='Lax')
|
||||||
return response
|
return response
|
||||||
|
|||||||
+7
-16
@@ -1,4 +1,4 @@
|
|||||||
from my_modules.decoratory.header import login_required, feature_flag_required
|
from my_modules.decoratory.header import login_required
|
||||||
from my_modules.functions import get_ip
|
from my_modules.functions import get_ip
|
||||||
from my_modules.app.setup import LIMITER
|
from my_modules.app.setup import LIMITER
|
||||||
from my_modules.app.logger import logger
|
from my_modules.app.logger import logger
|
||||||
@@ -41,24 +41,19 @@ async def access_list(user):
|
|||||||
@login_required
|
@login_required
|
||||||
async def files_list(user):
|
async def files_list(user):
|
||||||
files_data = await current_app.convex.get_files(user_id=user['sub'])
|
files_data = await current_app.convex.get_files(user_id=user['sub'])
|
||||||
info_enabled = await current_app.convex.is_feature_enabled(key='nanoshare_files-info', fallback=False)
|
|
||||||
edit_enabled = await current_app.convex.is_feature_enabled(key='nanoshare_files-edit', fallback=False)
|
|
||||||
return await render_template("views/webpage/files/list.htm",
|
return await render_template("views/webpage/files/list.htm",
|
||||||
files=files_data,
|
files=files_data
|
||||||
file_info_enabled=info_enabled,
|
|
||||||
file_edit_enabled=edit_enabled,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@side_main_bp.route('/files/<path:file_id>/info')
|
@side_main_bp.route('/files/<path:file_id>/info')
|
||||||
@login_required
|
@login_required
|
||||||
@feature_flag_required("nanoshare_files-info", fallback=False, status_code=404)
|
|
||||||
async def file_info(file_id, user):
|
async def file_info(file_id, user):
|
||||||
files_data = await current_app.convex.get_files(user_id=user["sub"])
|
files_data = await current_app.convex.get_files(user_id=user["sub"])
|
||||||
file_data = find_file(files_data, file_id)
|
file_data = find_file(files_data, file_id)
|
||||||
if not file_data:
|
if not file_data:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
access_data = await current_app.convex.get_file_access(file_id=file_id) or []
|
access_data = await current_app.convex.get_file_access(file_id=file_id, user_id=user["sub"]) or []
|
||||||
share_url = request.url_root.rstrip("/") + f"/-{file_id}"
|
share_url = request.url_root.rstrip("/") + f"/-{file_id}"
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"views/webpage/files/info.htm",
|
"views/webpage/files/info.htm",
|
||||||
@@ -69,21 +64,18 @@ async def file_info(file_id, user):
|
|||||||
|
|
||||||
@side_main_bp.route("/files/<path:file_id>/edit")
|
@side_main_bp.route("/files/<path:file_id>/edit")
|
||||||
@login_required
|
@login_required
|
||||||
@feature_flag_required("nanoshare_files-edit", fallback=False, status_code=404)
|
|
||||||
async def file_edit(file_id, user):
|
async def file_edit(file_id, user):
|
||||||
files_data = await current_app.convex.get_files(user_id=user["sub"])
|
file_data = await current_app.convex.get_file_informations(file_id=file_id, user_id=user["sub"])
|
||||||
file_data = find_file(files_data, file_id)
|
|
||||||
if not file_data:
|
if not file_data:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
share_url = request.url_root.rstrip("/") + f"/-{file_id}"
|
share_url = request.url_root.rstrip("/") + f"/-{file_id}"
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"views/webpage/files/edit.htm", file=file_data, share_url=share_url
|
"views/webpage/files/edit.htm", file=file_data, share_url=share_url, file_id=file_id
|
||||||
)
|
)
|
||||||
|
|
||||||
@side_main_bp.post("/api/files/<path:file_id>/edit")
|
@side_main_bp.put("/api/file/<path:file_id>")
|
||||||
@login_required
|
@login_required
|
||||||
@feature_flag_required("nanoshare_files-edit", fallback=False, status_code=404)
|
|
||||||
async def file_edit_api(file_id, user):
|
async def file_edit_api(file_id, user):
|
||||||
files_data = await current_app.convex.get_files(user_id=user["sub"])
|
files_data = await current_app.convex.get_files(user_id=user["sub"])
|
||||||
if not find_file(files_data, file_id):
|
if not find_file(files_data, file_id):
|
||||||
@@ -114,9 +106,8 @@ async def file_edit_api(file_id, user):
|
|||||||
|
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
@side_main_bp.post("/api/files/<path:file_id>/delete")
|
@side_main_bp.delete("/api/file/<path:file_id>")
|
||||||
@login_required
|
@login_required
|
||||||
@feature_flag_required("nanoshare_files-edit", fallback=False, status_code=404)
|
|
||||||
async def file_delete_api(file_id, user):
|
async def file_delete_api(file_id, user):
|
||||||
files_data = await current_app.convex.get_files(user_id=user["sub"])
|
files_data = await current_app.convex.get_files(user_id=user["sub"])
|
||||||
if not find_file(files_data, file_id):
|
if not find_file(files_data, file_id):
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">File ID</th>
|
<th scope="row">File ID</th>
|
||||||
<td><code>{{ file.file_id }}</code></td>
|
<td><code>{{ file_id }}</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Uploaded at</th>
|
<th scope="row">Uploaded at</th>
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
|
|
||||||
<div class="save-row">
|
<div class="save-row">
|
||||||
<button id="saveBtn" class="btn" type="button">Save changes</button>
|
<button id="saveBtn" class="btn" type="button">Save changes</button>
|
||||||
<a class="btn btn-ghost" href="{{ url_for('side_main.file_info', file_id=file.file_id) }}">View info</a>
|
<a class="btn btn-ghost" href="{{ url_for('side_main.file_info', file_id=file_id) }}">View info</a>
|
||||||
<a class="btn btn-ghost" href="{{ url_for('side_main.files_list') }}">Back to files</a>
|
<a class="btn btn-ghost" href="{{ url_for('side_main.files_list') }}">Back to files</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-row"><span id="status" class="status"></span></div>
|
<div class="status-row"><span id="status" class="status"></span></div>
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const fileId = '{{ file.file_id }}';
|
const fileId = '{{ file_id }}';
|
||||||
const initialExpires = '{{ file.expires_at }}';
|
const initialExpires = '{{ file.expires_at }}';
|
||||||
const expiresMode = document.getElementById('expiresMode');
|
const expiresMode = document.getElementById('expiresMode');
|
||||||
const customWrap = document.getElementById('customWrap');
|
const customWrap = document.getElementById('customWrap');
|
||||||
@@ -173,8 +173,8 @@
|
|||||||
|
|
||||||
setStatus('Saving...');
|
setStatus('Saving...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/files/${encodeURIComponent(fileId)}/edit`, {
|
const response = await fetch(`/api/file/${encodeURIComponent(fileId)}`, {
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ file_name: fileName, note, expires }),
|
body: JSON.stringify({ file_name: fileName, note, expires }),
|
||||||
});
|
});
|
||||||
@@ -194,8 +194,8 @@
|
|||||||
|
|
||||||
setStatus('Deleting...');
|
setStatus('Deleting...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/files/${encodeURIComponent(fileId)}/delete`, {
|
const response = await fetch(`/api/file/${encodeURIComponent(fileId)}`, {
|
||||||
method: 'POST',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data.ok) {
|
if (!response.ok || !data.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
{% extends "base.htm" %}
|
||||||
|
|
||||||
|
{% block title %}NanoShare - File info{% endblock %}
|
||||||
|
|
||||||
|
{% block meta %}
|
||||||
|
<meta name="description" content="NanoShare file details and access information.">
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.file-info-page { padding: clamp(16px, 3vw, 28px); }
|
||||||
|
.top-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 16px; align-items: start; }
|
||||||
|
.kv { display: grid; grid-template-columns: 180px 1fr; gap: 10px 14px; margin-top: 14px; }
|
||||||
|
.kv dt { color: var(--muted); font-weight: 700; }
|
||||||
|
.kv dd { margin: 0; word-break: break-word; }
|
||||||
|
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: .92rem; }
|
||||||
|
.preview-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: color-mix(in srgb, var(--panel-2) 60%, transparent); }
|
||||||
|
.preview-head { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: .9rem; font-weight: 700; }
|
||||||
|
.preview-body { min-height: 230px; display: grid; place-items: center; padding: 10px; }
|
||||||
|
.preview-body img, .preview-body video, .preview-body audio, .preview-body iframe { width: 100%; max-width: 100%; border: 0; border-radius: 8px; }
|
||||||
|
.preview-body video { max-height: 260px; }
|
||||||
|
.preview-body pre { width: 100%; max-height: 260px; overflow: auto; margin: 0; padding: 10px; border-radius: 8px; border: 1px solid var(--border); background: rgba(0,0,0,.16); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.preview-note { color: var(--muted); font-size: .88rem; text-align: center; }
|
||||||
|
@media (max-width: 980px) { .top-grid { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 720px) { .kv { grid-template-columns: 1fr; gap: 4px; } }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="file-info-page">
|
||||||
|
<section class="card" style="padding: clamp(18px, 2.6vw, 28px);">
|
||||||
|
<div class="top-grid">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">File details</h1>
|
||||||
|
<p class="subtle">Everything about this file, including expiration date and recent accesses.</p>
|
||||||
|
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Filename</dt>
|
||||||
|
<dd>{{ file.file_name }}</dd>
|
||||||
|
|
||||||
|
<dt>Note</dt>
|
||||||
|
<dd>{{ file.note or 'No note' }}</dd>
|
||||||
|
|
||||||
|
<dt>Size</dt>
|
||||||
|
<dd><span class="badge">{{ file.file_size }}</span></dd>
|
||||||
|
|
||||||
|
<dt>Uploaded at</dt>
|
||||||
|
<dd><time class="local-time" data-ts="{{ file.uploaded_at }}"></time></dd>
|
||||||
|
|
||||||
|
<dt>Expires at</dt>
|
||||||
|
<dd>
|
||||||
|
{% if file.expires_at %}
|
||||||
|
<time class="local-time" data-ts="{{ file.expires_at }}"></time>
|
||||||
|
{% else %}
|
||||||
|
Never
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Public URL</dt>
|
||||||
|
<dd><a class="mono" href="{{ share_url }}">{{ share_url }}</a></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="preview-card" aria-label="File preview">
|
||||||
|
<div class="preview-head">Preview</div>
|
||||||
|
<div id="previewBody" class="preview-body">
|
||||||
|
<p class="preview-note">Loading preview...</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<a class="btn" href="{{ url_for('side_main.file_edit', file_id=file.file_id) }}">Edit</a>
|
||||||
|
<button id="copyUrlBtn" class="btn btn-ghost" type="button">Copy link</button>
|
||||||
|
<a class="btn btn-ghost" href="{{ url_for('side_main.files_list') }}">Back to files</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card" style="padding: clamp(18px, 2.6vw, 28px); margin-top: 16px;">
|
||||||
|
<h2 style="margin:0 0 8px;">Access history</h2>
|
||||||
|
<p class="subtle">Latest request metadata for this file.</p>
|
||||||
|
|
||||||
|
<div class="table-wrap" role="region" aria-label="Access history" tabindex="0">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">IP</th>
|
||||||
|
<th scope="col">User Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if accesses %}
|
||||||
|
{% for access in accesses %}
|
||||||
|
<tr>
|
||||||
|
<td><time class="local-time" data-ts="{{ access.accessed_at }}"></time></td>
|
||||||
|
<td><span class="badge">{{ access.status }}</span></td>
|
||||||
|
<td>{{ access.ip or '-' }}</td>
|
||||||
|
<td>{{ access.user_agent or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">No access records yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
const raw = String(value);
|
||||||
|
let date;
|
||||||
|
if (/^\d+$/.test(raw)) {
|
||||||
|
date = new Date(Number.parseInt(raw, 10));
|
||||||
|
} else {
|
||||||
|
date = new Date(raw);
|
||||||
|
}
|
||||||
|
if (Number.isNaN(date.getTime())) return raw;
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('time.local-time').forEach((el) => {
|
||||||
|
const raw = el.dataset.ts || '';
|
||||||
|
el.textContent = formatDate(raw) || '-';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPreview() {
|
||||||
|
const previewBody = document.getElementById('previewBody');
|
||||||
|
if (!previewBody) return;
|
||||||
|
|
||||||
|
const url = '{{ share_url }}';
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method: 'GET' });
|
||||||
|
if (!response.ok) {
|
||||||
|
previewBody.innerHTML = '<p class="preview-note">Preview unavailable.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||||
|
|
||||||
|
if (contentType.startsWith('image/')) {
|
||||||
|
previewBody.innerHTML = `<img src="${url}" alt="File preview">`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentType.startsWith('video/')) {
|
||||||
|
previewBody.innerHTML = `<video controls src="${url}"></video>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentType.startsWith('audio/')) {
|
||||||
|
previewBody.innerHTML = `<audio controls src="${url}"></audio>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentType.includes('pdf')) {
|
||||||
|
previewBody.innerHTML = `<iframe src="${url}" title="PDF preview" height="260"></iframe>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentType.startsWith('text/') || contentType.includes('json') || contentType.includes('xml')) {
|
||||||
|
const text = await response.text();
|
||||||
|
const safe = text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
previewBody.innerHTML = `<pre>${safe.slice(0, 6000) || 'Empty text file.'}</pre>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewBody.innerHTML = `<p class="preview-note">No inline preview for this file type.<br><a href="${url}" target="_blank" rel="noopener">Open file</a></p>`;
|
||||||
|
} catch {
|
||||||
|
previewBody.innerHTML = '<p class="preview-note">Preview unavailable.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreview();
|
||||||
|
|
||||||
|
document.getElementById('copyUrlBtn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText('{{ share_url }}');
|
||||||
|
const btn = document.getElementById('copyUrlBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
const label = btn.textContent;
|
||||||
|
btn.textContent = 'Copied';
|
||||||
|
setTimeout(() => { btn.textContent = label; }, 1200);
|
||||||
|
} catch {
|
||||||
|
alert('Could not copy link.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -38,16 +38,12 @@
|
|||||||
<td><time datetime="{{ file.expires_at }}" class="local-time"></time></td>
|
<td><time datetime="{{ file.expires_at }}" class="local-time"></time></td>
|
||||||
<td class="cell--right">
|
<td class="cell--right">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{% if file_info_enabled %}
|
|
||||||
<button class="icon-btn" title="Info">
|
<button class="icon-btn" title="Info">
|
||||||
<a href="{{ url_for('side_main.file_info', file_id=file.file_id) }}">ℹ️ <span class="sr-only">Info</span></a>
|
<a href="{{ url_for('side_main.file_info', file_id=file.file_id) }}">ℹ️ <span class="sr-only">Info</span></a>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
|
||||||
{% if file_edit_enabled %}
|
|
||||||
<button class="icon-btn" title="Edit">
|
<button class="icon-btn" title="Edit">
|
||||||
<a href="{{ url_for('side_main.file_edit', file_id=file.file_id) }}">✏️ <span class="sr-only">Edit</span></a>
|
<a href="{{ url_for('side_main.file_edit', file_id=file.file_id) }}">✏️ <span class="sr-only">Edit</span></a>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
|
||||||
<button class="icon-btn" title="Copy link" data-action="copy">📋 <span class="sr-only">Copy</span></button>
|
<button class="icon-btn" title="Copy link" data-action="copy">📋 <span class="sr-only">Copy</span></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user