feat(ui): homepage Recent History single-line layout with gradient truncation\n\n- Display as {Title} – {Domain} – {URL}\n- Prevent horizontal overflow and darken URL color\n- Ensure flex children shrink (min-width:0) and container max-widths
- commit
- c0744da63d93a7ab79ac449d95aa1f363d7eab8c
- author
- bamen · · 2025-09-10 06:13 UTC
- parent
- d9a1d3b
- signature
- unsigned
2 files · +74 -21
M frontend/src/utils/dom.ts +46 -10
| @@ -74,10 +74,37 @@ export class DOMRenderer { | |||
| 74 | 74 | historyItem.className = 'history-item'; | |
| 75 | 75 | const parsed = this.safeParseURL(item.url); | |
| 76 | 76 | const title = item.title && item.title.trim() !== '' ? item.title : (parsed.host || 'Untitled'); | |
| 77 | - | historyItem.innerHTML = ` | |
| 78 | - | <div class="history-url">${this.escapeHtml(item.url)}</div> | |
| 79 | - | <div class="history-title">${this.escapeHtml(title)}</div> | |
| 80 | - | `; | |
| 77 | + | ||
| 78 | + | const line = document.createElement('div'); | |
| 79 | + | line.className = 'history-line'; | |
| 80 | + | ||
| 81 | + | const titleEl = document.createElement('span'); | |
| 82 | + | titleEl.className = 'history-title'; | |
| 83 | + | titleEl.textContent = title; | |
| 84 | + | ||
| 85 | + | const sep1 = document.createElement('span'); | |
| 86 | + | sep1.className = 'history-sep'; | |
| 87 | + | sep1.textContent = ' - '; | |
| 88 | + | ||
| 89 | + | const domainEl = document.createElement('span'); | |
| 90 | + | domainEl.className = 'history-domain'; | |
| 91 | + | domainEl.textContent = parsed.host || ''; | |
| 92 | + | ||
| 93 | + | const sep2 = document.createElement('span'); | |
| 94 | + | sep2.className = 'history-sep'; | |
| 95 | + | sep2.textContent = ' - '; | |
| 96 | + | ||
| 97 | + | const urlEl = document.createElement('span'); | |
| 98 | + | urlEl.className = 'history-url'; | |
| 99 | + | urlEl.textContent = item.url; | |
| 100 | + | ||
| 101 | + | line.appendChild(titleEl); | |
| 102 | + | line.appendChild(sep1); | |
| 103 | + | line.appendChild(domainEl); | |
| 104 | + | line.appendChild(sep2); | |
| 105 | + | line.appendChild(urlEl); | |
| 106 | + | ||
| 107 | + | historyItem.appendChild(line); | |
| 81 | 108 | ||
| 82 | 109 | historyItem.addEventListener('click', () => { | |
| 83 | 110 | this.navigateToUrl(item.url); | |
| @@ -110,20 +137,29 @@ export class DOMRenderer { | |||
| 110 | 137 | ||
| 111 | 138 | private extractBaseUrl(templateUrl: string): string { | |
| 112 | 139 | try { | |
| 113 | - | const url = new URL(templateUrl); | |
| 114 | - | // Remove query parameters that contain %s template | |
| 140 | + | // Support both %s and {query} placeholders | |
| 141 | + | const candidate = templateUrl.replace(/%s|\{query\}/g, ""); | |
| 142 | + | const url = new URL(candidate); | |
| 143 | + | // Remove empty query params left after placeholder removal | |
| 115 | 144 | const params = new URLSearchParams(url.search); | |
| 116 | - | for (const [key, value] of params.entries()) { | |
| 117 | - | if (value.includes('%s')) { | |
| 145 | + | for (const key of Array.from(params.keys())) { | |
| 146 | + | const v = params.get(key); | |
| 147 | + | if (v === null || v === "") { | |
| 118 | 148 | params.delete(key); | |
| 119 | 149 | } | |
| 120 | 150 | } | |
| 121 | 151 | url.search = params.toString(); | |
| 122 | 152 | return url.toString(); | |
| 123 | 153 | } catch (error) { | |
| 124 | - | // If URL parsing fails, return the original URL | |
| 154 | + | // Fallbacks: try to return origin or stripped placeholder | |
| 125 | 155 | console.warn('Failed to parse shortcut URL:', templateUrl); | |
| 126 | - | return templateUrl; | |
| 156 | + | try { | |
| 157 | + | const pre = templateUrl.split('%s')[0] || templateUrl.split('{query}')[0] || templateUrl; | |
| 158 | + | const u = new URL(pre); | |
| 159 | + | return `${u.protocol}//${u.host}`; | |
| 160 | + | } catch { | |
| 161 | + | return templateUrl.replace(/%s|\{query\}/g, ""); | |
| 162 | + | } | |
| 127 | 163 | } | |
| 128 | 164 | } | |
| 129 | 165 | ||
| @@ -74,10 +74,37 @@ export class DOMRenderer { | |||||
| 74 | historyItem.className = 'history-item'; | 74 | historyItem.className = 'history-item'; | ||
| 75 | const parsed = this.safeParseURL(item.url); | 75 | const parsed = this.safeParseURL(item.url); | ||
| 76 | const title = item.title && item.title.trim() !== '' ? item.title : (parsed.host || 'Untitled'); | 76 | const title = item.title && item.title.trim() !== '' ? item.title : (parsed.host || 'Untitled'); | ||
| 77 | - | historyItem.innerHTML = ` | 77 | + | |
| 78 | - | <div class="history-url">${this.escapeHtml(item.url)}</div> | 78 | + | const line = document.createElement('div'); |
| 79 | - | <div class="history-title">${this.escapeHtml(title)}</div> | 79 | + | line.className = 'history-line'; |
| 80 | - | `; | 80 | + | |
| 81 | + | const titleEl = document.createElement('span'); | |||
| 82 | + | titleEl.className = 'history-title'; | |||
| 83 | + | titleEl.textContent = title; | |||
| 84 | + | ||||
| 85 | + | const sep1 = document.createElement('span'); | |||
| 86 | + | sep1.className = 'history-sep'; | |||
| 87 | + | sep1.textContent = ' - '; | |||
| 88 | + | ||||
| 89 | + | const domainEl = document.createElement('span'); | |||
| 90 | + | domainEl.className = 'history-domain'; | |||
| 91 | + | domainEl.textContent = parsed.host || ''; | |||
| 92 | + | ||||
| 93 | + | const sep2 = document.createElement('span'); | |||
| 94 | + | sep2.className = 'history-sep'; | |||
| 95 | + | sep2.textContent = ' - '; | |||
| 96 | + | ||||
| 97 | + | const urlEl = document.createElement('span'); | |||
| 98 | + | urlEl.className = 'history-url'; | |||
| 99 | + | urlEl.textContent = item.url; | |||
| 100 | + | ||||
| 101 | + | line.appendChild(titleEl); | |||
| 102 | + | line.appendChild(sep1); | |||
| 103 | + | line.appendChild(domainEl); | |||
| 104 | + | line.appendChild(sep2); | |||
| 105 | + | line.appendChild(urlEl); | |||
| 106 | + | ||||
| 107 | + | historyItem.appendChild(line); | |||
| 81 | 108 | ||||
| 82 | historyItem.addEventListener('click', () => { | 109 | historyItem.addEventListener('click', () => { | ||
| 83 | this.navigateToUrl(item.url); | 110 | this.navigateToUrl(item.url); | ||
| @@ -110,20 +137,29 @@ export class DOMRenderer { | |||||
| 110 | 137 | ||||
| 111 | private extractBaseUrl(templateUrl: string): string { | 138 | private extractBaseUrl(templateUrl: string): string { | ||
| 112 | try { | 139 | try { | ||
| 113 | - | const url = new URL(templateUrl); | 140 | + | // Support both %s and {query} placeholders |
| 114 | - | // Remove query parameters that contain %s template | 141 | + | const candidate = templateUrl.replace(/%s|\{query\}/g, ""); |
| 142 | + | const url = new URL(candidate); | |||
| 143 | + | // Remove empty query params left after placeholder removal | |||
| 115 | const params = new URLSearchParams(url.search); | 144 | const params = new URLSearchParams(url.search); | ||
| 116 | - | for (const [key, value] of params.entries()) { | 145 | + | for (const key of Array.from(params.keys())) { |
| 117 | - | if (value.includes('%s')) { | 146 | + | const v = params.get(key); |
| 147 | + | if (v === null || v === "") { | |||
| 118 | params.delete(key); | 148 | params.delete(key); | ||
| 119 | } | 149 | } | ||
| 120 | } | 150 | } | ||
| 121 | url.search = params.toString(); | 151 | url.search = params.toString(); | ||
| 122 | return url.toString(); | 152 | return url.toString(); | ||
| 123 | } catch (error) { | 153 | } catch (error) { | ||
| 124 | - | // If URL parsing fails, return the original URL | 154 | + | // Fallbacks: try to return origin or stripped placeholder |
| 125 | console.warn('Failed to parse shortcut URL:', templateUrl); | 155 | console.warn('Failed to parse shortcut URL:', templateUrl); | ||
| 126 | - | return templateUrl; | 156 | + | try { |
| 157 | + | const pre = templateUrl.split('%s')[0] || templateUrl.split('{query}')[0] || templateUrl; | |||
| 158 | + | const u = new URL(pre); | |||
| 159 | + | return `${u.protocol}//${u.host}`; | |||
| 160 | + | } catch { | |||
| 161 | + | return templateUrl.replace(/%s|\{query\}/g, ""); | |||
| 162 | + | } | |||
| 127 | } | 163 | } | ||
| 128 | } | 164 | } | ||
| 129 | 165 | ||||
M frontend/src/utils/html-builder.ts +28 -11
| @@ -47,6 +47,7 @@ export class HTMLBuilder { | |||
| 47 | 47 | height: 100vh; | |
| 48 | 48 | display: flex; | |
| 49 | 49 | flex-direction: column; | |
| 50 | + | overflow-x: hidden; /* prevent horizontal scroll from long content */ | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 52 | 53 | .container { | |
| @@ -57,16 +58,19 @@ export class HTMLBuilder { | |||
| 57 | 58 | width: 100%; | |
| 58 | 59 | padding: 2rem; | |
| 59 | 60 | gap: 2rem; | |
| 61 | + | overflow-x: hidden; | |
| 60 | 62 | } | |
| 61 | 63 | ||
| 62 | 64 | .history-section { | |
| 63 | 65 | flex: 1; | |
| 64 | 66 | min-height: 0; | |
| 67 | + | min-width: 0; /* allow flex child to shrink */ | |
| 65 | 68 | } | |
| 66 | 69 | ||
| 67 | 70 | .shortcuts-section { | |
| 68 | 71 | flex: 1; | |
| 69 | 72 | min-height: 0; | |
| 73 | + | min-width: 0; /* allow flex child to shrink */ | |
| 70 | 74 | } | |
| 71 | 75 | ||
| 72 | 76 | .section-title { | |
| @@ -79,7 +83,9 @@ export class HTMLBuilder { | |||
| 79 | 83 | ||
| 80 | 84 | .history-list { | |
| 81 | 85 | overflow-y: auto; | |
| 86 | + | overflow-x: hidden; | |
| 82 | 87 | max-height: calc(100vh - 8rem); | |
| 88 | + | max-width: 100%; | |
| 83 | 89 | } | |
| 84 | 90 | ||
| 85 | 91 | .history-item { | |
| @@ -90,6 +96,8 @@ export class HTMLBuilder { | |||
| 90 | 96 | cursor: pointer; | |
| 91 | 97 | transition: background 0.2s; | |
| 92 | 98 | border-left: 3px solid #404040; | |
| 99 | + | overflow: hidden; | |
| 100 | + | max-width: 100%; | |
| 93 | 101 | } | |
| 94 | 102 | ||
| 95 | 103 | .history-item:hover { | |
| @@ -97,19 +105,28 @@ export class HTMLBuilder { | |||
| 97 | 105 | border-left-color: #0066cc; | |
| 98 | 106 | } | |
| 99 | 107 | ||
| 108 | + | .history-line { | |
| 109 | + | display: flex; | |
| 110 | + | gap: 0.5rem; | |
| 111 | + | white-space: nowrap; | |
| 112 | + | align-items: baseline; | |
| 113 | + | overflow: hidden; | |
| 114 | + | width: 100%; | |
| 115 | + | min-width: 0; /* allow flex children to shrink */ | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | .history-title { font-size: 0.95rem; color: #e6e6e6; flex: 0 0 auto; } | |
| 119 | + | .history-domain { font-size: 0.9rem; color: #a5a5a5; flex: 0 0 auto; } | |
| 120 | + | .history-sep { color: #666; flex: 0 0 auto; } | |
| 100 | 121 | .history-url { | |
| 101 | 122 | font-size: 0.9rem; | |
| 102 | - | color: #cccccc; | |
| 103 | - | margin-bottom: 0.25rem; | |
| 104 | - | word-break: break-all; | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | .history-title { | |
| 108 | - | font-size: 0.8rem; | |
| 109 | - | color: #888; | |
| 123 | + | color: #7a7a7a; /* darker than domain */ | |
| 124 | + | flex: 1 1 auto; | |
| 125 | + | min-width: 0; /* critical for flex truncation */ | |
| 110 | 126 | overflow: hidden; | |
| 111 | - | text-overflow: ellipsis; | |
| 112 | - | white-space: nowrap; | |
| 127 | + | text-overflow: ellipsis; /* fallback */ | |
| 128 | + | -webkit-mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%); | |
| 129 | + | mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%); | |
| 113 | 130 | } | |
| 114 | 131 | ||
| 115 | 132 | .shortcuts-grid { | |
| @@ -177,4 +194,4 @@ export class HTMLBuilder { | |||
| 177 | 194 | } | |
| 178 | 195 | `; | |
| 179 | 196 | } | |
| 180 | - | }⏎̸ | |
| 197 | + | } | |
| @@ -47,6 +47,7 @@ export class HTMLBuilder { | |||||
| 47 | height: 100vh; | 47 | height: 100vh; | ||
| 48 | display: flex; | 48 | display: flex; | ||
| 49 | flex-direction: column; | 49 | flex-direction: column; | ||
| 50 | + | overflow-x: hidden; /* prevent horizontal scroll from long content */ | |||
| 50 | } | 51 | } | ||
| 51 | 52 | ||||
| 52 | .container { | 53 | .container { | ||
| @@ -57,16 +58,19 @@ export class HTMLBuilder { | |||||
| 57 | width: 100%; | 58 | width: 100%; | ||
| 58 | padding: 2rem; | 59 | padding: 2rem; | ||
| 59 | gap: 2rem; | 60 | gap: 2rem; | ||
| 61 | + | overflow-x: hidden; | |||
| 60 | } | 62 | } | ||
| 61 | 63 | ||||
| 62 | .history-section { | 64 | .history-section { | ||
| 63 | flex: 1; | 65 | flex: 1; | ||
| 64 | min-height: 0; | 66 | min-height: 0; | ||
| 67 | + | min-width: 0; /* allow flex child to shrink */ | |||
| 65 | } | 68 | } | ||
| 66 | 69 | ||||
| 67 | .shortcuts-section { | 70 | .shortcuts-section { | ||
| 68 | flex: 1; | 71 | flex: 1; | ||
| 69 | min-height: 0; | 72 | min-height: 0; | ||
| 73 | + | min-width: 0; /* allow flex child to shrink */ | |||
| 70 | } | 74 | } | ||
| 71 | 75 | ||||
| 72 | .section-title { | 76 | .section-title { | ||
| @@ -79,7 +83,9 @@ export class HTMLBuilder { | |||||
| 79 | 83 | ||||
| 80 | .history-list { | 84 | .history-list { | ||
| 81 | overflow-y: auto; | 85 | overflow-y: auto; | ||
| 86 | + | overflow-x: hidden; | |||
| 82 | max-height: calc(100vh - 8rem); | 87 | max-height: calc(100vh - 8rem); | ||
| 88 | + | max-width: 100%; | |||
| 83 | } | 89 | } | ||
| 84 | 90 | ||||
| 85 | .history-item { | 91 | .history-item { | ||
| @@ -90,6 +96,8 @@ export class HTMLBuilder { | |||||
| 90 | cursor: pointer; | 96 | cursor: pointer; | ||
| 91 | transition: background 0.2s; | 97 | transition: background 0.2s; | ||
| 92 | border-left: 3px solid #404040; | 98 | border-left: 3px solid #404040; | ||
| 99 | + | overflow: hidden; | |||
| 100 | + | max-width: 100%; | |||
| 93 | } | 101 | } | ||
| 94 | 102 | ||||
| 95 | .history-item:hover { | 103 | .history-item:hover { | ||
| @@ -97,19 +105,28 @@ export class HTMLBuilder { | |||||
| 97 | border-left-color: #0066cc; | 105 | border-left-color: #0066cc; | ||
| 98 | } | 106 | } | ||
| 99 | 107 | ||||
| 108 | + | .history-line { | |||
| 109 | + | display: flex; | |||
| 110 | + | gap: 0.5rem; | |||
| 111 | + | white-space: nowrap; | |||
| 112 | + | align-items: baseline; | |||
| 113 | + | overflow: hidden; | |||
| 114 | + | width: 100%; | |||
| 115 | + | min-width: 0; /* allow flex children to shrink */ | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | .history-title { font-size: 0.95rem; color: #e6e6e6; flex: 0 0 auto; } | |||
| 119 | + | .history-domain { font-size: 0.9rem; color: #a5a5a5; flex: 0 0 auto; } | |||
| 120 | + | .history-sep { color: #666; flex: 0 0 auto; } | |||
| 100 | .history-url { | 121 | .history-url { | ||
| 101 | font-size: 0.9rem; | 122 | font-size: 0.9rem; | ||
| 102 | - | color: #cccccc; | 123 | + | color: #7a7a7a; /* darker than domain */ |
| 103 | - | margin-bottom: 0.25rem; | 124 | + | flex: 1 1 auto; |
| 104 | - | word-break: break-all; | 125 | + | min-width: 0; /* critical for flex truncation */ |
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | .history-title { | |||
| 108 | - | font-size: 0.8rem; | |||
| 109 | - | color: #888; | |||
| 110 | overflow: hidden; | 126 | overflow: hidden; | ||
| 111 | - | text-overflow: ellipsis; | 127 | + | text-overflow: ellipsis; /* fallback */ |
| 112 | - | white-space: nowrap; | 128 | + | -webkit-mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%); |
| 129 | + | mask-image: linear-gradient(to right, rgba(0,0,0,1) 85%, rgba(0,0,0,0) 100%); | |||
| 113 | } | 130 | } | ||
| 114 | 131 | ||||
| 115 | .shortcuts-grid { | 132 | .shortcuts-grid { | ||
| @@ -177,4 +194,4 @@ export class HTMLBuilder { | |||||
| 177 | } | 194 | } | ||
| 178 | `; | 195 | `; | ||
| 179 | } | 196 | } | ||
| 180 | - | }⏎̸ | 197 | + | } |