fix search events, fonts and login; improve Files labels and search results

This commit is contained in:
jolavillette 2026-02-17 20:39:04 +01:00
parent 15b0d169fe
commit e007e90588
6 changed files with 491 additions and 147 deletions

View File

@ -8,14 +8,21 @@ const fileProxyObj = futil.createProxy({}, () => {
rs.events[rs.RsEventsType.FILE_TRANSFER] = {
handler: (event) => {
console.log('search results : ', event);
console.warn('[RS-DEBUG] FILE_TRANSFER event received:', event);
// if request item doesn't already exists in Object then create new item
if (!Object.prototype.hasOwnProperty.call(fileProxyObj, event.mRequestId)) {
fileProxyObj[event.mRequestId] = [];
}
fileProxyObj[event.mRequestId].push(...event.mResults);
event.mResults.forEach((newRes) => {
const isAlt = fileProxyObj[event.mRequestId].some(
(oldRes) => oldRes.fHash === newRes.fHash && oldRes.fName === newRes.fName
);
if (!isAlt) {
fileProxyObj[event.mRequestId].push(newRes);
}
});
},
};

View File

@ -11,11 +11,12 @@ const reqObj = {};
function handleSubmit() {
rs.rsJsonApiRequest('/rsFiles/turtleSearch', { matchString })
.then((res) => {
console.warn('[RS-DEBUG] turtleSearch response:', res.body);
// Add prefix to obj keys so that javascript doesn't sort them
reqObj['_' + res.body.retval] = matchString;
currentItem = '_' + res.body.retval;
})
.catch((error) => console.log(error));
.catch((error) => console.error('[RS-DEBUG] turtleSearch error:', error));
}
const SearchBar = () => {
@ -63,58 +64,72 @@ const Layout = () => {
m('.widget__body', [
m('div.file-search-container', [
m('div.file-search-container__keywords', [
m('h5.bold', 'Keywords'),
Object.keys(reqObj).length !== 0 &&
m('.keywords-header', [
m('h5.bold', 'Keywords'),
m(
'div.keywords-container',
Object.keys(reqObj)
.reverse()
.map((item, index) => {
return m(
m.route.Link,
{
class: active === index ? 'selected' : '',
onclick: () => {
active = index;
currentItem = item;
},
href: `/files/search/${item}`,
},
reqObj[item]
);
})
'button.red.clear-btn',
{
onclick: () => {
Object.keys(reqObj).forEach((key) => delete reqObj[key]);
Object.keys(fproxy.fileProxyObj).forEach((key) => delete fproxy.fileProxyObj[key]);
currentItem = 0;
active = 0;
},
},
'Clear'
),
]),
Object.keys(reqObj).length !== 0 &&
m(
'div.keywords-container',
Object.keys(reqObj)
.reverse()
.map((item, index) => {
return m(
m.route.Link,
{
class: active === index ? 'selected' : '',
onclick: () => {
active = index;
currentItem = item;
},
href: `/files/search/${item}`,
},
reqObj[item]
);
})
),
]),
m('div.file-search-container__results', [
Object.keys(fproxy.fileProxyObj).length === 0
Object.keys(fproxy.fileProxyObj).length === 0 || currentItem === 0
? m('h5.bold', 'Results')
: m('table.results-container', [
m(
'thead.results-header',
m('tr', [
m('th', 'Name'),
m('th', 'Size'),
m('th', 'Hash'),
m('th', 'Download'),
])
),
m(
'tbody.results',
fproxy.fileProxyObj[currentItem.slice(1)]
? fproxy.fileProxyObj[currentItem.slice(1)].map((item) =>
m('tr', [
m('td.results__name', [m('i.fas.fa-file'), m('span', item.fName)]),
m('td.results__size', rs.formatBytes(item.fSize.xint64)),
m('td.results__hash', item.fHash),
m(
'td.results__download',
m('button', { onclick: () => handleFileDownload(item) }, 'Download')
),
])
)
: 'No Results.'
),
]),
: m('div.results-container', [
m(
'div.results-header',
m('.results-row', [
m('.results-cell.name-col', 'Name'),
m('.results-cell.size-col', 'Size'),
m('.results-cell.hash-col', 'Hash'),
m('.results-cell.action-col', 'Download'),
])
),
m(
'div.results-list',
fproxy.fileProxyObj[currentItem.slice(1)]
? fproxy.fileProxyObj[currentItem.slice(1)].map((item) =>
m('div.results-row.file-item', [
m('.results-cell.name-col', [m('i.fas.fa-file'), m('span', item.fName)]),
m('.results-cell.size-col', rs.formatBytes((item.fSize && (item.fSize.xint64 || item.fSize.xstr64)) || 0)),
m('.results-cell.hash-col', item.fHash),
m(
'.results-cell.action-col',
m('button', { onclick: () => handleFileDownload(item) }, 'Download')
),
])
)
: 'No Results.'
),
]),
]),
]),
]),

View File

@ -3,6 +3,14 @@ const rs = require('rswebui');
const util = require('files/files_util');
const manager = require('files/files_manager');
const translateName = (name) => {
const n = name.toLowerCase().trim();
if (n === 'extra list' || n === '[extra list]') return 'Temporary shared files';
// Match hex strings (IDs) or pure numeric strings
if (/^[0-9a-fA-F]{16,}$/.test(name) || /^\d+$/.test(name)) return 'My Files';
return name;
};
const DisplayFiles = () => {
const childrenList = []; // stores children details
let loaded = false; // checks whether we have loaded the children details or not.
@ -15,28 +23,34 @@ const DisplayFiles = () => {
},
view: (v) => [
m('tr', [
parStruct && Object.keys(parStruct.details.children).length
parStruct && parStruct.details.children && parStruct.details.children.length
? m(
'td',
m('i.fas.fa-angle-right', {
class: `fa-rotate-${parStruct.showChild ? '90' : '0'}`,
style: 'margin-top: 0.5rem',
onclick: () => {
if (!loaded) {
// if it is not already retrieved
parStruct.details.children.map(async (child) => {
const res = await rs.rsJsonApiRequest('/rsfiles/requestDirDetails', {
'td',
m('i.fas.fa-angle-right', {
class: `fa-rotate-${parStruct.showChild ? '90' : '0'}`,
style: 'margin-top: 0.5rem',
onclick: async () => {
if (!loaded) {
// if it is not already retrieved
const results = await Promise.all(
parStruct.details.children.map((child) =>
rs.rsJsonApiRequest('/rsfiles/requestDirDetails', {
handle: child.handle.xint64,
flags: util.RS_FILE_HINTS_LOCAL,
});
})
)
);
results.forEach((res) => {
if (res && res.body && res.body.details) {
childrenList.push(res.body.details);
loaded = true;
});
}
parStruct.showChild = !parStruct.showChild;
},
})
)
}
});
loaded = true;
}
parStruct.showChild = !parStruct.showChild;
},
})
)
: m('td', ''),
m(
'td',
@ -47,29 +61,49 @@ const DisplayFiles = () => {
left: `calc(1.5rem*${v.attrs.replyDepth})`,
},
},
parStruct.details.name
translateName(parStruct.details.name || '')
),
m('td', rs.formatBytes(parStruct.details.size.xint64)),
m('td', rs.formatBytes((parStruct.details.size && parStruct.details.size.xint64) || 0)),
]),
parStruct.showChild &&
childrenList.map((child) =>
m(DisplayFiles, {
// recursive call
par_directory: { details: child, showChild: false },
replyDepth: v.attrs.replyDepth + 1,
})
),
childrenList.map((child) =>
m(DisplayFiles, {
// recursive call
par_directory: { details: child, showChild: false },
replyDepth: v.attrs.replyDepth + 1,
})
),
],
};
};
const Layout = () => {
// let root_handle;
let parent;
let showShareManager = false;
let displayList = [];
let isLoading = true;
let showShareManager = false; // Retain original declaration
return {
oninit: () => {
rs.rsJsonApiRequest('/rsfiles/requestDirDetails', {}).then((res) => (parent = res));
rs.rsJsonApiRequest('/rsfiles/requestDirDetails', {}).then(async (res) => {
if (res && res.body && res.body.details) {
if (res.body.details.name === 'root') {
// Skip root and fetch full details for each child (Location ID, Extra list, etc)
const results = await Promise.all(
res.body.details.children.map((child) =>
rs.rsJsonApiRequest('/rsfiles/requestDirDetails', {
handle: child.handle.xint64,
flags: util.RS_FILE_HINTS_LOCAL,
})
)
);
displayList = results.map((r) => r.body.details);
} else {
displayList = [res.body.details];
}
}
isLoading = false;
m.redraw();
});
},
view: () => [
m('.widget__heading', [
@ -81,11 +115,14 @@ const Layout = () => {
util.MyFilesTable,
m(
'tbody',
parent &&
m(DisplayFiles, {
par_directory: { details: parent.body.details, showChild: false },
replyDepth: 0,
})
isLoading
? m('tr', m('td[colspan=3]', 'Loading...'))
: displayList.map((details) =>
m(DisplayFiles, {
par_directory: { details, showChild: false },
replyDepth: 0,
})
)
)
),
m(

View File

@ -44,7 +44,7 @@ const navbar = () => {
m('.nav-menu__logo-text', [
m('h5', 'RetroShare'),
m('.webui-version-box', [
m('span.webui-version', 'v44'),
m('span.webui-version', 'v61'),
m('i.fas.fa-sync-alt.refresh-icon', {
onclick: () => window.location.reload(true),
title: 'Force reload application',
@ -181,3 +181,16 @@ m.route(document.getElementById('main'), '/', {
render: (v) => m(Layout, m(config, v.attrs)),
},
});
// v51 architectural fix: ensure event queue starts on direct route refresh
if (rs.loginKey.isVerified && rs.loginKey.username && rs.loginKey.passwd) {
console.info('[RS-DEBUG] main.js: Initiating global event queue for verified session...');
rs.logon(
{ Authorization: `Basic ${btoa(`${rs.loginKey.username}:${rs.loginKey.passwd}`)}` },
() => { }, // displayAuthError
() => { }, // displayErrorMessage
() => {
console.info('[RS-DEBUG] Global event queue successfully started.');
}
);
}

View File

@ -106,6 +106,7 @@ function rsJsonApiRequest(
handleSerialize = JSON.stringify,
config = null
) {
console.warn('[RS-DEBUG] rsJsonApiRequest called for path:', path, 'with config:', !!config);
headers['Accept'] = 'application/json';
if (loginKey.isVerified) {
if (loginKey.username && loginKey.passwd) {
@ -135,7 +136,7 @@ function rsJsonApiRequest(
headers,
body: data,
config,
xhr: config,
})
.then((result) => {
if (result.status === 200) {
@ -254,8 +255,9 @@ const eventQueue = {
},
},
handler: (event) => {
console.warn('[RS-DEBUG] Event queue handler received type:', event.mType);
if (!deeperIfExist(eventQueue.events, event.mType, (owner) => owner.handler(event, owner))) {
// console.info('[RS-DEBUG] unhandled event', event);
console.info('[RS-DEBUG] unhandled event', event);
}
},
};
@ -340,42 +342,57 @@ function startEventQueue(
displayErrorMessage = () => { },
successful = () => { }
) {
return rsJsonApiRequest(
'/rsEvents/registerEventsHandler',
{},
(data, success) => {
if (success) {
// unused
} else if (data.status === 401) {
console.warn('[RS-DEBUG] startEventQueue starting raw XHR for:', info);
const xhr = new window.XMLHttpRequest();
let lastIndex = 0;
xhr.open('POST', loginKey.url + '/rsEvents/registerEventsHandler', true);
// Set headers for authentication
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
...loginHeader,
};
if (loginKey.isVerified && !headers['Authorization']) {
if (loginKey.username && loginKey.passwd) {
console.warn('[RS-DEBUG] Setting persistent Auth header for event queue');
headers['Authorization'] = 'Basic ' + btoa(loginKey.username + ':' + loginKey.passwd);
} else {
console.warn('[RS-DEBUG] Missing credentials for event queue auth');
}
}
Object.keys(headers).forEach((key) => {
xhr.setRequestHeader(key, headers[key]);
});
xhr.onreadystatechange = () => {
console.warn('[RS-DEBUG] Event Queue XHR state changed:', xhr.readyState, 'status:', xhr.status);
if (xhr.readyState === 4) {
if (xhr.status === 401) {
displayAuthError('Incorrect login/password.');
} else if (data.status === 0) {
displayErrorMessage([
'Retroshare-jsonapi not available.',
m('br'),
'Please fix host and/or port.',
]);
} else {
displayErrorMessage('Login failed: HTTP ' + data.status + ' ' + data.statusText);
} else if (xhr.status === 0) {
console.error('[RS-DEBUG] Event Queue connection failed (status 0)');
}
},
true,
loginHeader,
JSON.parse,
JSON.stringify,
(xhr, args, url) => {
let lastIndex = 0;
xhr.onprogress = (ev) => {
const currIndex = xhr.responseText.length;
if (currIndex > lastIndex) {
const parts = xhr.responseText.substring(lastIndex, currIndex);
lastIndex = currIndex;
parts
.trim()
.split('\n\n')
.filter((e) => e.startsWith('data: {'))
.map((e) => e.substr(6))
.map(JSON.parse)
.forEach((data) => {
}
};
xhr.onprogress = (ev) => {
const currIndex = xhr.responseText.length;
if (currIndex > lastIndex) {
const parts = xhr.responseText.substring(lastIndex, currIndex);
lastIndex = currIndex;
console.warn('[RS-DEBUG] RAW DATA RECEIVED:', parts);
parts
.trim()
.split('\n\n')
.filter((e) => e.trim().length > 0)
.forEach((e) => {
if (e.startsWith('data: {')) {
try {
const data = JSON.parse(e.substr(6));
console.warn('[RS-DEBUG] PARSED EVENT:', data);
if (Object.prototype.hasOwnProperty.call(data, 'retval')) {
console.info(
'[RS-DEBUG] ' + info + ' [' + data.retval.errorCategory + '] ' + data.retval.errorMessage
@ -389,21 +406,38 @@ function startEventQueue(
data.event.queueSize = currIndex;
try {
eventQueue.handler(data.event);
} catch (e) {
console.error('[RS-DEBUG] Error in event handler:', e, data.event);
} catch (err) {
console.error('[RS-DEBUG] Error in event handler:', err, data.event);
}
}
});
if (currIndex > 1e6) {
// max 1 MB eventQueue
startEventQueue('restart queue');
xhr.abort();
} catch (err) {
console.error('[RS-DEBUG] JSON parse error for part:', e, err);
}
} else {
console.info('[RS-DEBUG] Ignored non-data part:', e);
}
}
};
return xhr;
});
if (currIndex > 1e6) {
// max 1 MB eventQueue
console.warn('[RS-DEBUG] Restarting event queue (size > 1MB)');
startEventQueue('restart queue');
xhr.abort();
}
}
);
};
xhr.onload = () => {
console.warn('[RS-DEBUG] Event Queue XHR load finished. Status:', xhr.status);
};
xhr.onerror = (err) => {
console.error('[RS-DEBUG] Event Queue XHR error occurred:', err);
};
// We need to send an eventType to registerEventsHandler
// 0 means all events
xhr.send(JSON.stringify({ eventType: 0 }));
return xhr;
}
function logon(loginHeader, displayAuthError, displayErrorMessage, successful) {

View File

@ -46,84 +46,84 @@ p {
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-Bold.woff2") format("woff2"), url("./webfonts/Roboto-Bold.woff") format("woff"), url("./webfonts/Roboto-Bold.ttf") format("truetype");
src: url("./webfonts/Roboto-Bold.woff") format("woff"), url("./webfonts/Roboto-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-Bold.woff2") format("woff2"), url("./webfonts/Roboto-Bold.woff") format("woff"), url("./webfonts/Roboto-Bold.ttf") format("truetype");
src: url("./webfonts/Roboto-Bold.woff") format("woff"), url("./webfonts/Roboto-Bold.ttf") format("truetype");
font-weight: bold;
font-style: normal
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-BoldItalic.woff2") format("woff2"), url("./webfonts/Roboto-BoldItalic.woff") format("woff"), url("./webfonts/Roboto-BoldItalic.ttf") format("truetype");
src: url("./webfonts/Roboto-BoldItalic.woff") format("woff"), url("./webfonts/Roboto-BoldItalic.ttf") format("truetype");
font-weight: 700;
font-style: italic
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-BoldItalic.woff2") format("woff2"), url("./webfonts/Roboto-BoldItalic.woff") format("woff"), url("./webfonts/Roboto-BoldItalic.ttf") format("truetype");
src: url("./webfonts/Roboto-BoldItalic.woff") format("woff"), url("./webfonts/Roboto-BoldItalic.ttf") format("truetype");
font-weight: bold;
font-style: italic
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-Medium.woff2") format("woff2"), url("./webfonts/Roboto-Medium.woff") format("woff"), url("./webfonts/Roboto-Medium.ttf") format("truetype");
src: url("./webfonts/Roboto-Medium.woff") format("woff"), url("./webfonts/Roboto-Medium.ttf") format("truetype");
font-weight: 500;
font-style: normal
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-MediumItalic.woff2") format("woff2"), url("./webfonts/Roboto-MediumItalic.woff") format("woff"), url("./webfonts/Roboto-MediumItalic.ttf") format("truetype");
src: url("./webfonts/Roboto-MediumItalic.woff") format("woff"), url("./webfonts/Roboto-MediumItalic.ttf") format("truetype");
font-weight: 500;
font-style: italic
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-Regular.woff2") format("woff2"), url("./webfonts/Roboto-Regular.woff") format("woff"), url("./webfonts/Roboto-Regular.ttf") format("truetype");
src: url("./webfonts/Roboto-Regular.woff") format("woff"), url("./webfonts/Roboto-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-Regular.woff2") format("woff2"), url("./webfonts/Roboto-Regular.woff") format("woff"), url("./webfonts/Roboto-Regular.ttf") format("truetype");
src: url("./webfonts/Roboto-Regular.woff") format("woff"), url("./webfonts/Roboto-Regular.ttf") format("truetype");
font-weight: normal;
font-style: normal
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-Italic.woff2") format("woff2"), url("./webfonts/Roboto-Italic.woff") format("woff"), url("./webfonts/Roboto-Italic.ttf") format("truetype");
src: url("./webfonts/Roboto-Italic.woff") format("woff"), url("./webfonts/Roboto-Italic.ttf") format("truetype");
font-weight: 400;
font-style: italic
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-Italic.woff2") format("woff2"), url("./webfonts/Roboto-Italic.woff") format("woff"), url("./webfonts/Roboto-Italic.ttf") format("truetype");
src: url("./webfonts/Roboto-Italic.woff") format("woff"), url("./webfonts/Roboto-Italic.ttf") format("truetype");
font-weight: normal;
font-style: italic
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-Light.woff2") format("woff2"), url("./webfonts/Roboto-Light.woff") format("woff"), url("./webfonts/Roboto-Light.ttf") format("truetype");
src: url("./webfonts/Roboto-Light.woff") format("woff"), url("./webfonts/Roboto-Light.ttf") format("truetype");
font-weight: 300;
font-style: normal
}
@font-face {
font-family: Roboto;
src: url("./webfonts/Roboto-LightItalic.woff2") format("woff2"), url("./webfonts/Roboto-LightItalic.woff") format("woff"), url("./webfonts/Roboto-LightItalic.ttf") format("truetype");
src: url("./webfonts/Roboto-LightItalic.woff") format("woff"), url("./webfonts/Roboto-LightItalic.ttf") format("truetype");
font-weight: 300;
font-style: italic
}
@ -8565,4 +8565,242 @@ table.boards tr.hidden {
height: auto !important;
z-index: 100;
}
}
}
/* FILES MODULE RESPONSIVENESS (v45) */
@media (max-width: 700px) {
/* General Files Layout */
.file-view__body-details {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
/* File Detail Grid (Downloads/Uploads) */
.file-view__body-details-stat {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.file-view__body-details-stat span {
display: flex;
align-items: center;
}
/* Share Manager Table -> Cards */
.share-manager__table,
.share-manager__table thead,
.share-manager__table tbody,
.share-manager__table tr,
.share-manager__table td {
display: block;
width: 100% !important;
}
.share-manager__table thead {
display: none;
/* Hide header on mobile */
}
.share-manager__table tr {
border: 1px solid #ccc;
border-radius: 8px;
margin-bottom: 1rem;
padding: 0.5rem;
background: white;
}
.share-manager__table td {
margin-bottom: 0.5rem;
border: none !important;
padding-left: 0 !important;
}
/* My Files / Friends Files Tables -> Cards */
table.myfiles,
table.myfiles tr,
table.myfiles td,
table.friendsfiles,
table.friendsfiles tr,
table.friendsfiles td {
display: block;
width: 100% !important;
}
table.myfiles th,
table.friendsfiles th {
display: none;
}
table.myfiles tr,
table.friendsfiles tr {
border: 1px solid #ccc;
border-radius: 8px;
margin-bottom: 1rem;
padding: 0.5rem;
background: white;
}
/* Search Container Layout */
.file-search-container {
flex-direction: column;
}
.file-search-container__keywords {
flex-basis: auto;
width: 100%;
border-right: none;
border-bottom: 1px solid rgba(20, 20, 27, 0.1);
padding-bottom: 1rem;
margin-bottom: 1rem;
}
/* Search results table -> cards */
.results-container,
.results-container thead,
.results-container tbody,
.results-container tr,
.results-container td {
display: block;
width: 100% !important;
}
.results-container thead {
display: none;
}
.results-container tr {
border-bottom: 1px solid #eee;
padding: 1rem 0;
}
.results-container td {
margin-bottom: 0.5rem;
word-break: break-all;
}
/* GLOBAL SIDEBAR TO TABS (v46) */
.tab-content {
flex-direction: column;
}
.sidebar {
width: 100%;
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
border-bottom: 1px solid rgba(20, 20, 27, 0.1);
background: white;
z-index: 50;
flex-shrink: 0;
}
.sidebar a {
display: inline-block;
padding: 0.8rem 1.2rem;
border-bottom: 3px solid transparent;
}
.sidebar .selected-sidebar-link {
border-left: none;
border-bottom: 3px solid #3ba4d7;
animation: none;
}
/* Hide quickview headings on mobile to save space if needed */
.sidebarquickview>h4 {
display: none;
}
}
/* Search Results Responsive Layout (v60) */
.results-container {
display: flex;
flex-direction: column;
width: 100%;
border-radius: 8px;
overflow: hidden;
background: var(--main-bg-color);
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.results-header {
background: var(--secondary-bg-color);
font-weight: bold;
}
.results-row {
display: flex;
border-bottom: 1px solid var(--border-color);
padding: 0.8rem;
align-items: center;
}
.results-cell {
padding: 0 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name-col { flex: 3; }
.size-col { flex: 1; text-align: center; }
.hash-col { flex: 4; font-family: monospace; font-size: 0.8rem; opacity: 0.7; }
.action-col { flex: 1; text-align: right; }
.file-item:hover {
background: rgba(var(--primary-color-rgb), 0.05);
}
/* Mobile Adjustments */
@media screen and (max-width: 768px) {
.results-header {
display: none; /* Hide header on mobile */
}
.results-row {
flex-direction: column;
align-items: flex-start;
padding: 1rem;
gap: 0.5rem;
}
.results-cell {
padding: 0;
width: 100%;
white-space: normal;
}
.name-col {
font-weight: bold;
font-size: 1.1rem;
margin-bottom: 0.3rem;
}
.size-col {
text-align: left;
color: var(--secondary-text-color);
font-size: 0.9rem;
}
.hash-col {
display: none; /* Hide hash on mobile as requested */
}
.action-col {
width: 100%;
text-align: center;
margin-top: 0.5rem;
}
.action-col button {
width: 100%;
padding: 0.8rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 5px;
font-weight: bold;
}
}