├── .gitignore ├── LICENSE ├── README.md ├── css ├── components.css ├── main.css └── theme.css ├── images ├── apple-touch-icon.png ├── favicon-96x96.png ├── favicon.ico ├── favicon.svg ├── og-image.png ├── site.webmanifest ├── web-app-manifest-192x192.png └── web-app-manifest-512x512.png ├── index.html └── js ├── blocks.js ├── config.js ├── db.js ├── main.js ├── router.js └── ui.js /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files 2 | .DS_Store 3 | .DS_Store? 4 | ._* 5 | .Spotlight-V100 6 | .Trashes 7 | ehthumbs.db 8 | Thumbs.db 9 | 10 | # IDE files 11 | .idea/ 12 | .vscode/ 13 | *.swp 14 | *.swo 15 | 16 | # Dependencies 17 | node_modules/ 18 | npm-debug.log 19 | yarn-debug.log 20 | yarn-error.log 21 | 22 | # Build output 23 | dist/ 24 | build/ 25 | *.log -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 l3on 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Are.na Blocks Canvas 2 | 3 | A visual tool for exploring Are.na blocks in a canvas interface. This project allows users to interact with Are.na content in a more spatial and intuitive way. 4 | 5 | ## Features 6 | 7 | - Interactive canvas interface for Are.na blocks 8 | - Drag and drop functionality 9 | - Visual organization of blocks 10 | - Real-time updates with Are.na API 11 | - Responsive design for different screen sizes 12 | 13 | ## Technology Stack 14 | 15 | - **Frontend**: Vanilla JavaScript (ES6+) 16 | - **Styling**: CSS3 with custom variables for theming 17 | - **API**: Are.na API integration 18 | - **Storage**: Browser LocalStorage for persistence 19 | - **Dependencies**: No external dependencies, built with native web technologies 20 | 21 | ## Getting Started 22 | 23 | ### Installation 24 | 25 | 1. Clone the repository: 26 | ```bash 27 | git clone https://github.com/l3ony2k/are.na-blocks-canvas.git 28 | ``` 29 | 30 | 2. Navigate to the project directory: 31 | ```bash 32 | cd are.na-blocks-canvas 33 | ``` 34 | 35 | 3. Open `index.html` in your web browser or serve it using a local server: 36 | ```bash 37 | # Using Python 3 38 | python -m http.server 8000 39 | 40 | # Using Node.js 41 | npx serve 42 | ``` 43 | 44 | ## Project Structure 45 | 46 | ``` 47 | are.na-blocks-canvas/ 48 | ├── css/ 49 | │ ├── main.css # Main styles 50 | │ ├── theme.css # Theme variables 51 | │ └── components.css # Component-specific styles 52 | ├── images/ # Favicon and app icons 53 | │ ├── favicon.svg 54 | │ ├── favicon.ico 55 | │ └── ... 56 | ├── js/ 57 | │ ├── main.js # Application entry point 58 | │ ├── blocks.js # Block management 59 | │ ├── config.js # Configuration 60 | │ ├── db.js # Local storage handling 61 | │ ├── router.js # Routing logic 62 | │ └── ui.js # UI components 63 | └── index.html # Main HTML file 64 | ``` 65 | 66 | ## License 67 | 68 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -------------------------------------------------------------------------------- /css/components.css: -------------------------------------------------------------------------------- 1 | #header-bar { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 30px; 7 | background-color: var(--header-bg); 8 | border-bottom: 1px solid var(--header-border); 9 | display: flex; 10 | align-items: center; 11 | padding: 0 10px; 12 | z-index: 10001; 13 | gap: 10px; 14 | font-size: 14px; 15 | font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace; 16 | user-select: none; 17 | } 18 | 19 | #header-bar-logo { 20 | display: flex; 21 | } 22 | 23 | #header-bar-logo a { 24 | display: block; 25 | } 26 | 27 | #header-bar-logo svg { 28 | width: 20px; 29 | height: 20px; 30 | } 31 | 32 | #header-bar-logo svg path { 33 | fill: var(--icon-color); 34 | transition: opacity 0.2s ease; 35 | } 36 | 37 | #header-bar-logo:hover svg path { 38 | opacity: 0.7; 39 | } 40 | 41 | #channel-input-group { 42 | display: flex; 43 | flex-grow: 1; 44 | } 45 | 46 | #channel-slug-input { 47 | flex-grow: 1; 48 | height: 24px; 49 | background-color: var(--block-bg); 50 | color: var(--text-color); 51 | border: 1px solid var(--block-border); 52 | border-right: none; 53 | padding: 0 5px; 54 | font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace; 55 | font-size: 14px; 56 | } 57 | 58 | #goto-button, 59 | #tile-button, 60 | #theme-toggle, 61 | #about-button, 62 | #more-button, 63 | .more-menu button { 64 | height: 24px; 65 | padding: 0 5px; 66 | background-color: var(--button-bg); 67 | border: 1px solid var(--block-border); 68 | color: var(--text-color); 69 | cursor: pointer; 70 | font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace; 71 | font-size: 14px; 72 | transition: all 0.2s ease; 73 | width: 50px; 74 | display: flex; 75 | align-items: center; 76 | justify-content: center; 77 | } 78 | 79 | #goto-button:hover, 80 | #tile-button:hover, 81 | #theme-toggle:hover, 82 | #about-button:hover, 83 | #more-button:hover, 84 | .more-menu button:hover { 85 | background-color: var(--button-hover); 86 | } 87 | 88 | #header-bar-placeholder { 89 | width: 50px; 90 | } 91 | 92 | #log-output { 93 | position: fixed; 94 | top: 50px; 95 | left: 0; 96 | width: 100%; 97 | padding: 5px 10px; 98 | font-size: 12px; 99 | font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace; 100 | color: var(--log-text); 101 | background-color: var(--log-bg); 102 | z-index: 99; 103 | overflow: scroll; 104 | max-height: 80vh; 105 | display: none; 106 | } 107 | 108 | #loading-container { 109 | position: fixed; 110 | top: 30px; 111 | left: 0; 112 | width: 100%; 113 | height: 20px; 114 | background-color: var(--loading-bg); 115 | z-index: 9999; 116 | display: none; 117 | } 118 | 119 | #loading-bar { 120 | height: 100%; 121 | width: 0; 122 | background-color: var(--loading-bar); 123 | transition: width 0.2s ease; 124 | } 125 | 126 | #detail-view { 127 | position: fixed; 128 | top: 50%; 129 | left: 50%; 130 | transform: translate(-50%, -50%); 131 | width: 80%; 132 | max-width: 800px; 133 | max-height: 90vh; 134 | background-color: var(--detail-bg); 135 | border: 3px solid var(--detail-border); 136 | box-shadow: 5px 5px 10px rgba(0,0,0,0.5); 137 | padding: 0; 138 | display: flex; 139 | flex-direction: column; 140 | z-index: 10000; 141 | display: none; 142 | overflow-y: auto; 143 | overflow-x: hidden; 144 | } 145 | 146 | #detail-view-header { 147 | position: sticky; 148 | top: 0; 149 | width: 100%; 150 | background-color: var(--detail-header-bg); 151 | padding: 0 10px; 152 | display: flex; 153 | flex-wrap: wrap; 154 | align-items: center; 155 | justify-content: space-between; 156 | z-index: 10001; 157 | border-bottom: 2px solid var(--detail-border); 158 | overflow-x: hidden; 159 | flex-shrink: 0; 160 | } 161 | 162 | #detail-view-header > div { 163 | display: flex; 164 | align-items: center; 165 | /* gap: 10px; */ 166 | } 167 | 168 | #detail-view-title { 169 | font-size: 1.5em; 170 | font-weight: bold; 171 | margin: 0; 172 | overflow: hidden; 173 | text-overflow: ellipsis; 174 | white-space: nowrap; 175 | max-width: 75%; 176 | } 177 | 178 | #detail-view-arena-link { 179 | display: inline-flex; 180 | align-items: center; 181 | justify-content: center; 182 | padding: 5px; 183 | touch-action: manipulation; 184 | cursor: pointer; 185 | } 186 | 187 | #detail-view-arena-link svg { 188 | width: 30px; 189 | height: 30px; 190 | } 191 | 192 | #detail-view-arena-link svg path { 193 | fill: var(--icon-color); 194 | transition: opacity 0.2s ease; 195 | } 196 | 197 | #detail-view-arena-link:hover svg path { 198 | opacity: 0.7; 199 | } 200 | 201 | #detail-view-close-wrapper { 202 | cursor: pointer; 203 | width: 40px; 204 | height: 40px; 205 | display: flex; 206 | align-items: center; 207 | justify-content: center; 208 | padding: 5px; 209 | touch-action: manipulation; 210 | } 211 | 212 | #detail-view-close { 213 | width: 30px; 214 | height: 30px; 215 | } 216 | 217 | #detail-view-close svg { 218 | width: 100%; 219 | height: 100%; 220 | } 221 | 222 | #detail-view-close svg path { 223 | stroke: var(--icon-color); 224 | transition: opacity 0.2s ease; 225 | } 226 | 227 | #detail-view-close:hover svg path { 228 | opacity: 0.7; 229 | } 230 | 231 | #detail-view-content-wrapper { 232 | padding: 10px; 233 | overflow-x: hidden; 234 | overflow-y: auto; 235 | flex-grow: 1; 236 | flex-shrink: 1; 237 | } 238 | 239 | #detail-view-content img { 240 | max-width: 100%; 241 | height: auto; 242 | } 243 | 244 | #detail-view-content p { 245 | font-size: 1rem; 246 | } 247 | 248 | #detail-view-content div { 249 | padding: 0 1em; 250 | } 251 | 252 | #detail-view-meta { 253 | background-color: var(--detail-meta-bg); 254 | border-top: 1px solid var(--detail-border); 255 | padding: 10px; 256 | font-size: 12px; 257 | font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace; 258 | z-index: 10001; 259 | } 260 | 261 | .meta-item { 262 | margin-bottom: 5px; 263 | } 264 | 265 | .meta-item a { 266 | color: var(--link-color); 267 | text-decoration: none; 268 | } 269 | 270 | .meta-item a:hover { 271 | text-decoration: underline; 272 | } 273 | 274 | .mobile-only { 275 | display: none; 276 | } 277 | 278 | #more-button, 279 | #more-menu { 280 | display: none; 281 | } 282 | 283 | #more-menu { 284 | position: absolute; 285 | top: 100%; 286 | right: 5px; 287 | background-color: var(--header-bg); 288 | border: 1px solid var(--header-border); 289 | box-shadow: 0 2px 4px rgba(0,0,0,0.2); 290 | flex-direction: column; 291 | padding: 5px; 292 | gap: 5px; 293 | z-index: 10002; 294 | } 295 | 296 | #more-menu button { 297 | width: 100%; 298 | background-color: var(--button-bg); 299 | border: 1px solid var(--block-border); 300 | color: var(--text-color); 301 | display: flex; 302 | align-items: center; 303 | justify-content: center; 304 | font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace; 305 | } 306 | 307 | #more-menu button:hover { 308 | background-color: var(--button-hover); 309 | } 310 | 311 | /* Modal Dialog Styles */ 312 | .modal-dialog { 313 | position: fixed; 314 | top: 0; 315 | left: 0; 316 | width: 100%; 317 | height: 100%; 318 | background-color: rgba(0, 0, 0, 0.5); 319 | display: none; 320 | justify-content: center; 321 | align-items: center; 322 | z-index: 10003; /* Higher than detail view */ 323 | } 324 | 325 | .modal-content { 326 | background-color: var(--detail-bg); 327 | border: 3px solid var(--detail-border); 328 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); 329 | width: 90%; 330 | max-width: 400px; 331 | padding: 20px; 332 | text-align: center; 333 | } 334 | 335 | .modal-content h3 { 336 | margin-top: 0; 337 | color: var(--text-color); 338 | font-size: 1.3em; 339 | } 340 | 341 | .modal-content p { 342 | margin-bottom: 20px; 343 | color: var(--text-color); 344 | font-size: 1em; 345 | line-height: 1.4; 346 | } 347 | 348 | .button-group { 349 | display: flex; 350 | justify-content: center; 351 | gap: 10px; 352 | } 353 | 354 | .button-group button { 355 | padding: 8px 16px; 356 | background-color: var(--button-bg); 357 | border: 1px solid var(--block-border); 358 | color: var(--text-color); 359 | cursor: pointer; 360 | font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace; 361 | font-size: 14px; 362 | min-width: 100px; 363 | transition: background-color 0.2s; 364 | } 365 | 366 | .button-group button:hover { 367 | background-color: var(--button-hover); 368 | } 369 | 370 | #load-more-blocks-btn { 371 | background-color: var(--channel-block-border); 372 | color: white; 373 | } 374 | 375 | #load-more-blocks-btn:hover { 376 | background-color: var(--link-color); 377 | } 378 | 379 | @media (max-width: 768px) { 380 | #header-bar { 381 | height: auto; 382 | min-height: 40px; 383 | padding: 5px; 384 | flex-wrap: wrap; 385 | justify-content: flex-start; 386 | gap: 5px; 387 | position: relative; 388 | } 389 | 390 | #header-bar-logo { 391 | order: 1; 392 | margin: 0 5px; 393 | } 394 | 395 | #channel-input-group { 396 | order: 2; 397 | flex: 1; 398 | min-width: 120px; 399 | display: flex; 400 | margin: 0; 401 | } 402 | 403 | #channel-slug-input { 404 | height: 32px; 405 | font-size: 16px; 406 | padding: 0 8px; 407 | flex: 1; 408 | margin: 0; 409 | } 410 | 411 | #goto-button { 412 | order: 3; 413 | height: 32px; 414 | margin: 0; 415 | } 416 | 417 | #more-button { 418 | order: 4; 419 | display: flex; 420 | } 421 | 422 | #tile-button, 423 | #theme-toggle, 424 | #about-button { 425 | display: none; 426 | } 427 | 428 | #goto-button, 429 | #more-button, 430 | #more-menu button { 431 | height: 32px; 432 | min-width: 32px; 433 | padding: 0 12px; 434 | font-size: 14px; 435 | width: auto; 436 | } 437 | 438 | #more-menu.show { 439 | display: flex; 440 | } 441 | 442 | #goto-button:active, 443 | #more-button:active, 444 | #more-menu button:active { 445 | background-color: var(--button-hover); 446 | } 447 | 448 | /* Detail View Mobile Styles */ 449 | #detail-view { 450 | max-height: 75vh; 451 | } 452 | 453 | #detail-view-title { 454 | font-size: 1.2em; 455 | max-width: 60%; 456 | } 457 | 458 | #detail-view-content p { 459 | font-size: 0.9rem; 460 | } 461 | 462 | #detail-view-content h2 { 463 | font-size: 1.1rem; 464 | } 465 | 466 | #detail-view-content div { 467 | font-size: 0.9rem; 468 | } 469 | 470 | #detail-view-meta { 471 | font-size: 11px; 472 | } 473 | 474 | #channel-detail-container { 475 | font-size: 0.9rem; 476 | } 477 | 478 | #channel-stats, 479 | #channel-dates, 480 | #channel-status { 481 | font-size: 0.8em; 482 | } 483 | 484 | .modal-content { 485 | width: 95%; 486 | padding: 15px; 487 | } 488 | 489 | .modal-content h3 { 490 | font-size: 1.1em; 491 | } 492 | 493 | .modal-content p { 494 | font-size: 0.9em; 495 | } 496 | 497 | .button-group button { 498 | padding: 6px 12px; 499 | font-size: 12px; 500 | min-width: 80px; 501 | } 502 | } 503 | -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace; 7 | background-color: var(--bg-color); 8 | color: var(--text-color); 9 | overflow: hidden; 10 | background-image: radial-gradient(circle, var(--block-border) 1px, transparent 1px); 11 | background-size: 20px 20px; 12 | user-select: none; 13 | margin: 0; 14 | } 15 | 16 | a { 17 | color: var(--link-color); 18 | text-decoration: none; 19 | } 20 | 21 | a:visited { 22 | color: var(--link-color); 23 | } 24 | 25 | a:hover { 26 | text-decoration: underline; 27 | } 28 | 29 | blockquote { 30 | border-left: 2px solid #3338; 31 | padding-left: 0.5em; 32 | margin-left: 0.5em; 33 | } 34 | 35 | .block { 36 | position: absolute; 37 | border: 2px solid var(--block-border); 38 | padding: 5px; 39 | background-color: var(--block-bg); 40 | color: var(--text-color); 41 | box-shadow: 3px 3px 5px rgba(0,0,0,0.3); 42 | cursor: move; 43 | max-width: 200px; 44 | word-wrap: break-word; 45 | hyphens: auto; 46 | -webkit-hyphens: auto; 47 | -moz-hyphens: auto; 48 | -ms-hyphens: auto; 49 | will-change: transform; 50 | max-height: 300px; 51 | overflow: hidden; 52 | text-overflow: ellipsis; 53 | z-index: 1; 54 | top: 30px; /* this need to be 30px, this one is necessary */ 55 | } 56 | 57 | /* Remove margin from the first paragraph in a block to maintain consistent padding */ 58 | .block p:first-child { 59 | margin-top: 0; 60 | } 61 | .block p:last-child { 62 | margin-bottom: 0; 63 | } 64 | .block p { 65 | margin: 0.5rem 0; 66 | } 67 | 68 | .block.channel-block { 69 | border-color: var(--channel-block-border); 70 | display: flex; 71 | flex-direction: column; 72 | justify-content: center; 73 | align-items: center; 74 | padding: 10px; 75 | text-align: center; 76 | gap: 5px; 77 | } 78 | 79 | .block.channel-block .channel-header { 80 | display: flex; 81 | align-items: center; 82 | gap: 5px; 83 | color: var(--channel-block-text); 84 | font-size: 0.9em; 85 | } 86 | 87 | .block.channel-block .channel-header svg { 88 | width: 13px; 89 | height: 8px; 90 | } 91 | 92 | .block.channel-block .channel-header svg path { 93 | fill: var(--channel-block-text); 94 | } 95 | 96 | .block.channel-block h2 { 97 | color: var(--channel-block-text); 98 | text-align: center; 99 | margin: 0; 100 | font-size: 1.5em; 101 | line-height: 1.2; 102 | word-wrap: break-word; 103 | hyphens: auto; 104 | -webkit-hyphens: auto; 105 | -moz-hyphens: auto; 106 | -ms-hyphens: auto; 107 | } 108 | 109 | /* Image placeholder styling */ 110 | .block.image-block { 111 | min-width: 1px; 112 | min-height: 1px; 113 | } 114 | 115 | .block .image-placeholder { 116 | width: 200px; 117 | height: 200px; 118 | background-color: var(--block-bg); 119 | /* border: 1px dashed var(--block-border); */ 120 | display: flex; 121 | justify-content: center; 122 | align-items: center; 123 | color: var(--text-color); 124 | opacity: 0.7; 125 | font-size: 0.9em; 126 | text-align: center; 127 | padding: 10px; 128 | } 129 | 130 | .block .image-placeholder::before { 131 | content: "Loading image..."; 132 | } 133 | 134 | .block img { 135 | max-width: 100%; 136 | height: auto; 137 | display: block; 138 | background-color: #eee; 139 | user-select: none; 140 | } 141 | 142 | .dragging { 143 | cursor: move !important; 144 | opacity: 0.8; 145 | } 146 | 147 | /* Channel Detail View Styles */ 148 | #channel-detail-container { 149 | display: flex; 150 | flex-direction: column; 151 | padding: 20px; 152 | } 153 | 154 | #channel-detail-container div, 155 | #channel-text-info div { 156 | padding: 0 !important; 157 | font-size: 1rem; 158 | } 159 | 160 | #channel-basic-info { 161 | display: flex; 162 | gap: 20px; 163 | padding: 0 !important; 164 | } 165 | 166 | #channel-text-info { 167 | flex: 1; 168 | display: flex; 169 | flex-direction: column; 170 | gap: 15px; 171 | min-width: 200px; 172 | } 173 | 174 | #channel-cover-wrapper { 175 | flex: 1; 176 | max-width: 400px; 177 | display: flex; 178 | justify-content: center; 179 | align-items: flex-start; 180 | } 181 | 182 | #channel-cover-image { 183 | width: 100%; 184 | height: auto; 185 | object-fit: cover; 186 | border: 1px solid var(--block-border); 187 | background-color: var(--block-bg); 188 | } 189 | 190 | #channel-stats { 191 | display: flex; 192 | gap: 20px; 193 | font-size: 0.9em; 194 | } 195 | 196 | #channel-dates { 197 | font-size: 0.9em; 198 | } 199 | 200 | #channel-status { 201 | font-size: 0.9em; 202 | } 203 | 204 | #channel-goto-button { 205 | font-size: 1rem; 206 | padding: 10px 20px; 207 | cursor: pointer; 208 | background-color: var(--button-bg); 209 | border: 1px solid var(--block-border); 210 | color: var(--text-color); 211 | font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Consolas", "Courier New", monospace; 212 | align-self: flex-start; 213 | } 214 | 215 | #channel-goto-button:hover { 216 | background-color: var(--button-hover); 217 | } 218 | 219 | #channel-description { 220 | margin-bottom: 15px; 221 | line-height: 1.4; 222 | font-size: 1rem; 223 | color: var(--text-color); 224 | padding: 0 !important; 225 | } 226 | 227 | #channel-description p { 228 | margin: 0 0 10px 0; 229 | } 230 | 231 | #channel-description p:last-child { 232 | margin-bottom: 0; 233 | } 234 | 235 | @media (max-width: 768px) { 236 | #channel-basic-info { 237 | flex-direction: column-reverse; 238 | } 239 | 240 | #channel-cover-wrapper { 241 | max-width: 100%; 242 | margin-bottom: 20px; 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /css/theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-color: #f0f0f0; 3 | --text-color: #333; 4 | --block-bg: #fff; 5 | --block-border: #333; 6 | --header-bg: #f0f0f0; 7 | --header-border: #333; 8 | --button-bg: #ddd; 9 | --button-hover: #ccc; 10 | --detail-bg: #fff; 11 | --detail-header-bg: #f0f0f0; 12 | --detail-meta-bg: #fafafa; 13 | --detail-border: #333; 14 | --log-bg: #333; 15 | --log-text: #f0f0f0; 16 | --loading-bg: #ddd; 17 | --loading-bar: #333; 18 | --link-color: #0077cc; 19 | --icon-color: #333; 20 | --channel-block-border: #0077cc; 21 | --channel-block-text: #0077cc; 22 | } 23 | 24 | @media (prefers-color-scheme: dark) { 25 | :root[data-theme="system"] { 26 | --bg-color: #111; 27 | --text-color: #f0f0f0; 28 | --block-bg: #2a2a2a; 29 | --block-border: #555; 30 | --header-bg: #1a1a1a; 31 | --header-border: #444; 32 | --button-bg: #333; 33 | --button-hover: #444; 34 | --detail-bg: #1a1a1a; 35 | --detail-header-bg: #222; 36 | --detail-meta-bg: #2a2a2a; 37 | --detail-border: #444; 38 | --log-bg: #1a1a1a; 39 | --log-text: #f0f0f0; 40 | --loading-bg: #111; 41 | --loading-bar: #1a1a1a; 42 | --link-color: #66b3ff; 43 | --icon-color: #f0f0f0; 44 | --channel-block-border: #66b3ff; 45 | 46 | --channel-block-text: #66b3ff; 47 | } 48 | } 49 | 50 | :root[data-theme="dark"] { 51 | --bg-color: #111111; 52 | --text-color: #f0f0f0; 53 | --block-bg: #2a2a2a; 54 | --block-border: #555; 55 | --header-bg: #1a1a1a; 56 | --header-border: #444; 57 | --button-bg: #333; 58 | --button-hover: #444; 59 | --detail-bg: #1a1a1a; 60 | --detail-header-bg: #222; 61 | --detail-meta-bg: #2a2a2a; 62 | --detail-border: #444; 63 | --log-bg: #1a1a1a; 64 | --log-text: #f0f0f0; 65 | --loading-bg: #222; 66 | --loading-bar: #444; 67 | --link-color: #66b3ff; 68 | --icon-color: #f0f0f0; 69 | --channel-block-border: #66b3ff; 70 | --channel-block-text: #66b3ff; 71 | } -------------------------------------------------------------------------------- /images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l3ony2k/are.na-blocks-canvas/75fdc3b435a14303bdcaa80e208f748d6f8b1a08/images/apple-touch-icon.png -------------------------------------------------------------------------------- /images/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l3ony2k/are.na-blocks-canvas/75fdc3b435a14303bdcaa80e208f748d6f8b1a08/images/favicon-96x96.png -------------------------------------------------------------------------------- /images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l3ony2k/are.na-blocks-canvas/75fdc3b435a14303bdcaa80e208f748d6f8b1a08/images/favicon.ico -------------------------------------------------------------------------------- /images/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l3ony2k/are.na-blocks-canvas/75fdc3b435a14303bdcaa80e208f748d6f8b1a08/images/og-image.png -------------------------------------------------------------------------------- /images/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "are.na blocks canvas", 3 | "short_name": "abc", 4 | "icons": [ 5 | { 6 | "src": "/images/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/images/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#f0f0f0", 19 | "background_color": "#f0f0f0", 20 | "display": "standalone" 21 | } 22 | -------------------------------------------------------------------------------- /images/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l3ony2k/are.na-blocks-canvas/75fdc3b435a14303bdcaa80e208f748d6f8b1a08/images/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /images/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l3ony2k/are.na-blocks-canvas/75fdc3b435a14303bdcaa80e208f748d6f8b1a08/images/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | are.na blocks canvas 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 54 |
55 | 63 | 64 |
65 | 66 | 67 | 68 | 69 |
70 | 71 | 72 | 73 |
74 |
75 | 76 |
77 |
78 |
79 | 80 |
81 | 82 |
83 |
84 |

85 |
86 | 87 | 88 | 89 | 90 | 91 |
92 |
93 | 94 | 95 | 96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | 104 |
105 |
106 |
107 | 108 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /js/blocks.js: -------------------------------------------------------------------------------- 1 | // Block Manipulation Functions 2 | function temporaryRaiseBlock(element) { 3 | if (!element._tempRaised) { 4 | element.style.zIndex = "2"; 5 | element._tempRaised = true; 6 | element._raiseTimer = setTimeout(() => { 7 | commitRaiseBlock(element); 8 | }, CONFIG.doubleClickDelay); 9 | } 10 | } 11 | 12 | function commitRaiseBlock(element) { 13 | if (element._raiseTimer) { 14 | clearTimeout(element._raiseTimer); 15 | element._raiseTimer = null; 16 | } 17 | element.parentElement.appendChild(element); 18 | element.style.zIndex = ""; 19 | element._tempRaised = false; 20 | 21 | const slug = STATE.channelSlugs[0]; 22 | const newOrder = Array.from(document.querySelectorAll('.block')).map(el => el.dataset.blockId); 23 | STATE.cachedBlockOrder = newOrder; 24 | 25 | arenaDB.getChannel(slug).then(cachedData => { 26 | if (cachedData) { 27 | cachedData.order = newOrder; 28 | return arenaDB.saveChannel(slug, cachedData.data); 29 | } 30 | }).catch(error => { 31 | console.error('Error updating block order in cache:', error); 32 | }); 33 | } 34 | 35 | function handleTouchEnd(event) { 36 | const now = Date.now(); 37 | if (now - STATE.lastTouchEnd < CONFIG.doubleClickDelay) { 38 | commitRaiseBlock(event.currentTarget); 39 | showDetailView(event); 40 | } else { 41 | temporaryRaiseBlock(event.currentTarget); 42 | } 43 | STATE.lastTouchEnd = now; 44 | } 45 | 46 | function handleWheelRotation(event) { 47 | const block = event.currentTarget; 48 | let currentRotation = 0; 49 | const transform = block.style.transform; 50 | const match = transform.match(/rotate\(([^)]+)\)/); 51 | if (match) currentRotation = parseFloat(match[1]); 52 | const delta = Math.sign(event.deltaY); 53 | const rotationStep = 5; 54 | const newRotation = currentRotation + delta * rotationStep; 55 | const x = getTranslateXValue(block); 56 | const y = getTranslateYValue(block); 57 | block.style.transform = `translate(${x}px, ${y}px) rotate(${newRotation}deg)`; 58 | 59 | updateBlockPosition(block, x, y, newRotation); 60 | 61 | event.preventDefault(); 62 | } 63 | 64 | function makeDraggable(element) { 65 | let offsetX = 0, offsetY = 0, startX = 0, startY = 0; 66 | let isDragging = false, lastMoveTime = 0; 67 | const throttleInterval = 25, dragThreshold = 5; 68 | 69 | // 共用的保存位置更新函数 70 | function saveBlockPosition() { 71 | if (isDragging) { 72 | const x = getTranslateXValue(element); 73 | const y = getTranslateYValue(element); 74 | const rotationMatch = element.style.transform.match(/rotate\(([^)]+)\)/); 75 | const rotation = rotationMatch ? parseFloat(rotationMatch[1]) : 0; 76 | 77 | STATE.cachedBlockPositions[element.dataset.blockId] = { x, y, rotation }; 78 | 79 | const blockIdToMove = element.dataset.blockId; 80 | const index = STATE.cachedBlockOrder.indexOf(blockIdToMove); 81 | if (index > -1) { 82 | STATE.cachedBlockOrder.splice(index, 1); 83 | STATE.cachedBlockOrder.push(blockIdToMove); 84 | } 85 | 86 | // 批量存储以减少本地存储写入 87 | requestAnimationFrame(() => { 88 | arenaDB.getChannel(STATE.channelSlugs[0]).then(cachedData => { 89 | if (cachedData) { 90 | cachedData.positions = STATE.cachedBlockPositions; 91 | cachedData.order = STATE.cachedBlockOrder; 92 | return arenaDB.saveChannel(STATE.channelSlugs[0], cachedData.data); 93 | } 94 | }).catch(error => { 95 | console.error('Error updating block positions in cache:', error); 96 | }); 97 | }); 98 | } 99 | isDragging = false; 100 | startX = 0; startY = 0; 101 | element.classList.remove('dragging'); 102 | } 103 | 104 | // 共用的处理移动函数 105 | function handleMove(pageX, pageY) { 106 | if (startX === 0 && startY === 0) return; 107 | const dx = pageX - startX, dy = pageY - startY; 108 | if (!isDragging && Math.sqrt(dx*dx + dy*dy) > dragThreshold) { 109 | isDragging = true; 110 | element.classList.add('dragging'); 111 | commitRaiseBlock(element); 112 | } 113 | if (!isDragging) return; 114 | const now = Date.now(); 115 | if (now - lastMoveTime < throttleInterval) return; 116 | lastMoveTime = now; 117 | let x = pageX - offsetX, y = pageY - offsetY; 118 | const blockWidth = element.offsetWidth, blockHeight = element.offsetHeight; 119 | const minX = -blockWidth/2, minY = -blockHeight/2; 120 | const maxX = window.innerWidth - blockWidth/2, maxY = window.innerHeight - blockHeight/2 - 30; 121 | x = Math.min(Math.max(x, minX), maxX); 122 | y = Math.min(Math.max(y, minY), maxY); 123 | const rotationMatch = element.style.transform.match(/rotate\(([^)]+)\)/); 124 | const currentRotation = rotationMatch ? `rotate(${rotationMatch[1]})` : ''; 125 | element.style.transform = `translate(${x}px, ${y}px) ${currentRotation}`; 126 | } 127 | 128 | // 鼠标事件处理 129 | element.addEventListener('mousedown', (e) => { 130 | startX = e.pageX; startY = e.pageY; 131 | offsetX = e.pageX - getTranslateXValue(element); 132 | offsetY = e.pageY - getTranslateYValue(element); 133 | temporaryRaiseBlock(element); 134 | }); 135 | 136 | document.addEventListener('mousemove', (e) => { 137 | handleMove(e.pageX, e.pageY); 138 | }); 139 | 140 | document.addEventListener('mouseup', saveBlockPosition); 141 | 142 | // 触摸事件处理 143 | element.addEventListener('touchstart', (e) => { 144 | const touch = e.touches[0]; 145 | startX = touch.pageX; startY = touch.pageY; 146 | offsetX = touch.pageX - getTranslateXValue(element); 147 | offsetY = touch.pageY - getTranslateYValue(element); 148 | temporaryRaiseBlock(element); 149 | }); 150 | 151 | element.addEventListener('touchmove', (e) => { 152 | const touch = e.touches[0]; 153 | handleMove(touch.pageX, touch.pageY); 154 | e.preventDefault(); 155 | }); 156 | 157 | element.addEventListener('touchend', (e) => { 158 | saveBlockPosition(); 159 | if (e.cancelable) { 160 | e.preventDefault(); 161 | } 162 | }); 163 | } 164 | 165 | function renderBlock(block) { 166 | // 创建块元素 167 | const blockElement = document.createElement('div'); 168 | blockElement.classList.add('block'); 169 | blockElement.dataset.blockId = block.id; 170 | 171 | // 添加事件监听器 172 | if (!('ontouchstart' in window)) { 173 | // 桌面设备事件 174 | blockElement.addEventListener('click', (e) => temporaryRaiseBlock(e.currentTarget)); 175 | blockElement.addEventListener('dblclick', (e) => { 176 | commitRaiseBlock(e.currentTarget); 177 | showDetailView(e); 178 | }); 179 | } else { 180 | // 触摸设备事件 181 | blockElement.addEventListener('touchend', handleTouchEnd); 182 | } 183 | 184 | // 添加滚轮旋转事件 185 | blockElement.addEventListener('wheel', handleWheelRotation); 186 | 187 | // 根据块类型渲染不同内容 188 | if (block.class === 'Channel') { 189 | renderChannelBlock(blockElement, block); 190 | } else if (block.image) { 191 | renderImageBlock(blockElement, block); 192 | } else if (block.class === 'Link') { 193 | renderLinkBlock(blockElement, block); 194 | } 195 | 196 | // 处理文本内容 197 | if (block.class && block.class.toLowerCase() === 'text') { 198 | renderTextBlock(blockElement, block); 199 | } 200 | 201 | // 添加到DOM 202 | document.body.appendChild(blockElement); 203 | 204 | // 使块可拖动 205 | makeDraggable(blockElement); 206 | 207 | return blockElement; 208 | } 209 | 210 | // 渲染频道块 211 | function renderChannelBlock(element, block) { 212 | element.classList.add('channel-block'); 213 | 214 | // 创建头部 215 | const header = document.createElement('div'); 216 | header.className = 'channel-header'; 217 | header.innerHTML = ` 218 | 219 | 220 | 221 | Connected Channel 222 | `; 223 | element.appendChild(header); 224 | 225 | // 创建标题 226 | const titleElement = document.createElement('h2'); 227 | titleElement.textContent = block.title; 228 | element.appendChild(titleElement); 229 | } 230 | 231 | // 渲染图片块 232 | function renderImageBlock(element, block) { 233 | // Add image-block class to help with styling 234 | element.classList.add('image-block'); 235 | 236 | // Create placeholder that will be shown until image loads 237 | const placeholder = document.createElement('div'); 238 | placeholder.className = 'image-placeholder'; 239 | element.appendChild(placeholder); 240 | 241 | const img = document.createElement('img'); 242 | const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); 243 | 244 | // Hide image initially until it loads 245 | img.style.display = 'none'; 246 | 247 | // 设置初始尺寸和缩略图 248 | if (block.image.thumb) { 249 | img.style.width = block.image.thumb.width + 'px'; 250 | img.style.height = block.image.thumb.height + 'px'; 251 | img.src = block.image.thumb.url; 252 | } 253 | 254 | img.draggable = false; 255 | element.appendChild(img); 256 | 257 | // When any version of the image loads, show it and hide placeholder 258 | img.onload = function() { 259 | img.style.display = 'block'; 260 | placeholder.style.display = 'none'; 261 | }; 262 | 263 | // 使用 IntersectionObserver 进行懒加载 264 | if ('IntersectionObserver' in window) { 265 | const observer = new IntersectionObserver((entries, observer) => { 266 | entries.forEach(entry => { 267 | if (entry.isIntersecting) { 268 | // 只有当块可见时才加载高分辨率图片 269 | loadHigherQualityImage(); 270 | observer.disconnect(); 271 | } 272 | }); 273 | }, { 274 | rootMargin: '100px', // 提前100px开始加载 275 | threshold: 0.1 // 当10%的元素可见时 276 | }); 277 | 278 | observer.observe(element); 279 | element._imageObserver = observer; // 保存引用以便稍后清理 280 | } else { 281 | // 回退到直接加载(旧浏览器) 282 | loadHigherQualityImage(); 283 | } 284 | 285 | function loadHigherQualityImage() { 286 | if (block.image.display && block.image.display.url) { 287 | // 针对移动设备选择合适的图像 288 | let targetSrc = block.image.display.url; 289 | 290 | // 如果是移动设备且存在缩略图,考虑使用中等大小的图像而非原图 291 | if (isMobile && block.image.large && block.image.large.url) { 292 | targetSrc = block.image.large.url; 293 | } else if (isMobile && !block.image.large && block.image.display) { 294 | // 如果没有large但有display,使用display 295 | targetSrc = block.image.display.url; 296 | } 297 | 298 | const displayImg = new Image(); 299 | 300 | // 设置错误处理,确保加载失败时不会导致应用崩溃 301 | displayImg.onerror = () => { 302 | console.warn(`Failed to load image: ${targetSrc}, keeping thumbnail`); 303 | // 保持缩略图,确保不会尝试加载失败的图像 304 | }; 305 | 306 | displayImg.onload = () => { 307 | // 检查元素是否仍在DOM中(可能已被删除) 308 | if (element.isConnected && img.isConnected) { 309 | img.src = displayImg.src; 310 | img.style.width = ''; 311 | img.style.height = ''; 312 | } 313 | }; 314 | 315 | displayImg.src = targetSrc; 316 | } 317 | } 318 | } 319 | 320 | // 渲染链接块 321 | function renderLinkBlock(element, block) { 322 | const link = document.createElement('div'); 323 | link.textContent = block.title || 'Link'; 324 | link.style.color = 'var(--link-color)'; 325 | element.appendChild(link); 326 | } 327 | 328 | // 渲染文本块 329 | function renderTextBlock(element, block) { 330 | if (block.content_html) { 331 | const text = document.createElement('div'); 332 | text.innerHTML = block.content_html; 333 | element.appendChild(text); 334 | } else if (block.title) { 335 | const title = document.createElement('div'); 336 | title.innerHTML = block.title; 337 | element.appendChild(title); 338 | } 339 | } 340 | 341 | // 添加更新块位置的共用函数 342 | function updateBlockPosition(block, x, y, rotation) { 343 | const blockId = block.dataset.blockId; 344 | STATE.cachedBlockPositions[blockId] = { x, y, rotation }; 345 | 346 | // 使用防抖或节流来减少存储操作 347 | if (STATE._savePositionTimeout) { 348 | clearTimeout(STATE._savePositionTimeout); 349 | } 350 | 351 | STATE._savePositionTimeout = setTimeout(() => { 352 | const slug = STATE.channelSlugs[0]; 353 | arenaDB.getChannel(slug).then(cachedData => { 354 | if (cachedData) { 355 | cachedData.positions = STATE.cachedBlockPositions; 356 | return arenaDB.saveChannel(slug, cachedData.data); 357 | } 358 | }).catch(error => { 359 | console.error('Error updating block positions in cache:', error); 360 | }); 361 | }, 1000); // 1秒延迟,避免频繁写入 362 | } 363 | 364 | function loadMoreBlocks() { 365 | if (STATE.isLoading) return; 366 | STATE.isLoading = true; 367 | const nextBatch = STATE.allFetchedBlocks.slice(STATE.currentlyDisplayedBlocks, STATE.currentlyDisplayedBlocks + CONFIG.blocksPerLoad); 368 | if (nextBatch.length === 0) { 369 | outputLog("[loadMoreBlocks] No more blocks to load."); 370 | STATE.isLoading = false; 371 | clearInterval(STATE.loadIntervalId); 372 | STATE.loadIntervalId = null; 373 | return; 374 | } 375 | 376 | let blocksToRender = nextBatch; 377 | if (STATE.cachedBlockOrder.length > 0) { 378 | blocksToRender = STATE.cachedBlockOrder.slice(STATE.currentlyDisplayedBlocks, STATE.currentlyDisplayedBlocks + CONFIG.blocksPerLoad) 379 | .map(blockId => STATE.allFetchedBlocks.find(block => block.id === blockId)) 380 | .filter(block => block); 381 | } 382 | blocksToRender.forEach(block => { 383 | const blockElement = renderBlock(block); 384 | if (STATE.cachedBlockPositions[block.id]) { 385 | const pos = STATE.cachedBlockPositions[block.id]; 386 | blockElement.style.transform = `translate(${pos.x}px, ${pos.y}px) rotate(${pos.rotation}deg)`; 387 | } 388 | }); 389 | 390 | STATE.currentlyDisplayedBlocks += blocksToRender.length; 391 | 392 | if (STATE.currentlyDisplayedBlocks >= STATE.allFetchedBlocks.length) { 393 | outputLog(`[loadMoreBlocks] All blocks loaded: ${STATE.currentlyDisplayedBlocks}`); 394 | clearInterval(STATE.loadIntervalId); 395 | STATE.loadIntervalId = null; 396 | } 397 | 398 | STATE.isLoading = false; 399 | } 400 | 401 | const handleResize = throttle(() => { 402 | // 获取视口边界 403 | const viewport = { 404 | minX: 0, 405 | minY: 0, 406 | maxX: window.innerWidth, 407 | maxY: window.innerHeight - 30 408 | }; 409 | 410 | // 使用DocumentFragment减少DOM重绘 411 | const blocks = document.querySelectorAll('.block'); 412 | 413 | // 批量处理所有块的位置调整 414 | blocks.forEach(block => { 415 | const blockWidth = block.offsetWidth; 416 | const blockHeight = block.offsetHeight; 417 | 418 | // 计算边界 419 | const bounds = { 420 | minX: -blockWidth/2, 421 | minY: -blockHeight/2, 422 | maxX: viewport.maxX - blockWidth/2, 423 | maxY: viewport.maxY - blockHeight/2 424 | }; 425 | 426 | // 获取当前位置 427 | let x = getTranslateXValue(block); 428 | let y = getTranslateYValue(block); 429 | 430 | // 确保在边界内 431 | x = Math.min(Math.max(x, bounds.minX), bounds.maxX); 432 | y = Math.min(Math.max(y, bounds.minY), bounds.maxY); 433 | 434 | // 保持旋转角度不变 435 | const currentRotation = (block.style.transform.match(/rotate\(([^)]+)\)/) || ['','0deg'])[1]; 436 | 437 | // 更新变换 438 | block.style.transform = `translate(${x}px, ${y}px) rotate(${currentRotation})`; 439 | 440 | // 更新缓存的位置 441 | const blockId = block.dataset.blockId; 442 | if (blockId && STATE.cachedBlockPositions[blockId]) { 443 | STATE.cachedBlockPositions[blockId].x = x; 444 | STATE.cachedBlockPositions[blockId].y = y; 445 | } 446 | }); 447 | 448 | // 延迟保存位置到数据库 449 | if (STATE._resizePositionTimeout) { 450 | clearTimeout(STATE._resizePositionTimeout); 451 | } 452 | 453 | STATE._resizePositionTimeout = setTimeout(() => { 454 | const slug = STATE.channelSlugs[0]; 455 | arenaDB.getChannel(slug).then(cachedData => { 456 | if (cachedData) { 457 | cachedData.positions = STATE.cachedBlockPositions; 458 | return arenaDB.saveChannel(slug, cachedData.data); 459 | } 460 | }).catch(error => { 461 | console.error('Error updating positions after resize:', error); 462 | }); 463 | }, 1000); 464 | }, 100); 465 | 466 | window.addEventListener('resize', handleResize); 467 | -------------------------------------------------------------------------------- /js/config.js: -------------------------------------------------------------------------------- 1 | // Global configuration 2 | const CONFIG = { 3 | defaultChannel: 'ephemeral-visions', 4 | accessToken: '', 5 | blocksPerLoad: isMobileDevice() ? 10 : 20, // Reduce batch size on mobile 6 | loadInterval: isMobileDevice() ? 300 : 100, // More time between batches on mobile 7 | doubleClickDelay: 300, 8 | dbName: 'ArenaBlocksDB', 9 | dbVersion: 2, 10 | cacheMaxAge: 24 * 60 * 60 * 1000, // 1 day 11 | memoryCheckInterval: 5000, // Check memory usage every 5 seconds on mobile 12 | maxBlocks: isMobileDevice() ? 150 : 1000, // Maximum blocks to render at once on mobile 13 | userOverrideBlockLimit: false, // Whether the user has chosen to override the block limit 14 | additionalLoadStep: 50, // Number of additional blocks to load when the user overrides the limit 15 | maxBlocksAfterOverride: isMobileDevice() ? 1000 : 5000, // Maximum blocks after user override 16 | version: '3.5.0' // Version increment for the block limit warning feature 17 | }; 18 | 19 | // Helper function to detect mobile devices 20 | function isMobileDevice() { 21 | return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); 22 | } 23 | 24 | // Global state 25 | const STATE = { 26 | channelSlugs: [CONFIG.defaultChannel], 27 | allFetchedBlocks: [], 28 | currentlyDisplayedBlocks: 0, 29 | isLoading: false, 30 | loadIntervalId: null, 31 | cachedBlockPositions: {}, 32 | cachedBlockOrder: [], 33 | lastTouchEnd: 0 34 | }; 35 | -------------------------------------------------------------------------------- /js/db.js: -------------------------------------------------------------------------------- 1 | class ArenaDB { 2 | constructor() { 3 | this.dbName = CONFIG.dbName; 4 | this.dbVersion = CONFIG.dbVersion; 5 | this.db = null; 6 | this.ready = this.initDB(); 7 | } 8 | 9 | async initDB() { 10 | return new Promise((resolve, reject) => { 11 | const request = indexedDB.open(this.dbName, this.dbVersion); 12 | 13 | request.onerror = () => reject(request.error); 14 | request.onsuccess = () => { 15 | this.db = request.result; 16 | resolve(); 17 | }; 18 | 19 | request.onupgradeneeded = (event) => { 20 | const db = event.target.result; 21 | 22 | if (!db.objectStoreNames.contains('channels')) { 23 | const channelStore = db.createObjectStore('channels', { keyPath: 'slug' }); 24 | channelStore.createIndex('timestamp', 'timestamp', { unique: false }); 25 | } 26 | 27 | if (!db.objectStoreNames.contains('history')) { 28 | const historyStore = db.createObjectStore('history', { keyPath: 'id', autoIncrement: true }); 29 | historyStore.createIndex('timestamp', 'timestamp', { unique: false }); 30 | historyStore.createIndex('slug', 'slug', { unique: false }); 31 | } 32 | 33 | if (event.oldVersion < 2) { 34 | this.clearOldCache(); 35 | } 36 | }; 37 | }); 38 | } 39 | 40 | async saveChannel(slug, data) { 41 | await this.ready; 42 | return new Promise((resolve, reject) => { 43 | const tx = this.db.transaction('channels', 'readwrite'); 44 | const store = tx.objectStore('channels'); 45 | 46 | const order = Array.from(document.querySelectorAll('.block')).map(el => el.dataset.blockId); 47 | 48 | const state = { 49 | slug, 50 | data, 51 | positions: STATE.cachedBlockPositions, 52 | order: order, 53 | timestamp: Date.now() 54 | }; 55 | 56 | const request = store.put(state); 57 | request.onsuccess = () => resolve(); 58 | request.onerror = () => reject(request.error); 59 | }); 60 | } 61 | 62 | async getChannel(slug) { 63 | await this.ready; 64 | return new Promise((resolve, reject) => { 65 | const tx = this.db.transaction('channels', 'readonly'); 66 | const store = tx.objectStore('channels'); 67 | const request = store.get(slug); 68 | 69 | request.onsuccess = () => resolve(request.result); 70 | request.onerror = () => reject(request.error); 71 | }); 72 | } 73 | 74 | async addToHistory(slug, title) { 75 | await this.ready; 76 | return new Promise((resolve, reject) => { 77 | const tx = this.db.transaction('history', 'readwrite'); 78 | const store = tx.objectStore('history'); 79 | const request = store.add({ 80 | slug, 81 | title, 82 | timestamp: Date.now() 83 | }); 84 | 85 | request.onsuccess = () => resolve(); 86 | request.onerror = () => reject(request.error); 87 | }); 88 | } 89 | 90 | async getHistory(limit = 50) { 91 | await this.ready; 92 | return new Promise((resolve, reject) => { 93 | const tx = this.db.transaction('history', 'readonly'); 94 | const store = tx.objectStore('history'); 95 | const index = store.index('timestamp'); 96 | const request = index.openCursor(null, 'prev'); 97 | const history = []; 98 | 99 | request.onsuccess = (event) => { 100 | const cursor = event.target.result; 101 | if (cursor && history.length < limit) { 102 | history.push(cursor.value); 103 | cursor.continue(); 104 | } else { 105 | resolve(history); 106 | } 107 | }; 108 | 109 | request.onerror = () => reject(request.error); 110 | }); 111 | } 112 | 113 | async clearOldCache(maxAge = CONFIG.cacheMaxAge) { 114 | await this.ready; 115 | const tx = this.db.transaction(['channels', 'history'], 'readwrite'); 116 | const channelStore = tx.objectStore('channels'); 117 | const historyStore = tx.objectStore('history'); 118 | const now = Date.now(); 119 | 120 | return new Promise((resolve, reject) => { 121 | const request = channelStore.index('timestamp').openCursor(); 122 | 123 | request.onsuccess = (event) => { 124 | const cursor = event.target.result; 125 | if (cursor) { 126 | if (now - cursor.value.timestamp > maxAge) { 127 | cursor.delete(); 128 | } 129 | cursor.continue(); 130 | } 131 | }; 132 | 133 | const historyRequest = historyStore.index('timestamp').openCursor(); 134 | 135 | historyRequest.onsuccess = (event) => { 136 | const cursor = event.target.result; 137 | if (cursor) { 138 | if (now - cursor.value.timestamp > maxAge) { 139 | cursor.delete(); 140 | } 141 | cursor.continue(); 142 | } else { 143 | resolve(); 144 | } 145 | }; 146 | 147 | tx.onerror = () => reject(tx.error); 148 | }); 149 | } 150 | } 151 | 152 | // Global database instance 153 | const arenaDB = new ArenaDB(); -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | // API Functions 2 | async function fetchChannelInfo(slug) { 3 | outputLog(`[fetchChannelInfo] Fetching channel "${slug}" info...`); 4 | const apiUrl = `https://api.are.na/v2/channels/${slug}`; 5 | try { 6 | const response = await fetch(apiUrl, { headers: { 'Authorization': `Bearer ${CONFIG.accessToken}` } }); 7 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 8 | const data = await response.json(); 9 | outputLog(`[fetchChannelInfo] Channel "${slug}" info: ${data.title}`); 10 | return data; 11 | } catch (error) { 12 | outputLog(`[fetchChannelInfo] Error fetching channel info: ${error}`); 13 | return null; 14 | } 15 | } 16 | 17 | async function fetchChannelBlocks(slug) { 18 | outputLog(`[fetchChannelBlocks] Start fetching blocks for channel "${slug}"...`); 19 | let allBlocks = []; 20 | 21 | // 获取频道信息 22 | const channelInfo = await fetchChannelInfo(slug); 23 | if (!channelInfo) { 24 | outputLog("[fetchChannelBlocks] Could not get channel info, aborting block fetching."); 25 | return []; 26 | } 27 | 28 | const totalBlocks = channelInfo.length; 29 | outputLog(`[fetchChannelBlocks] Channel "${slug}" has ${totalBlocks} blocks in total.`); 30 | 31 | // 显示加载UI 32 | const loadingContainer = document.getElementById('loading-container'); 33 | const logOutput = document.getElementById('log-output'); 34 | const loadingBar = document.getElementById('loading-bar'); 35 | 36 | loadingContainer.style.display = 'block'; 37 | logOutput.style.display = 'block'; 38 | logOutput.innerHTML = ''; 39 | loadingBar.style.width = '0%'; 40 | 41 | try { 42 | // 计算需要的页数 43 | const perPage = 100; 44 | const totalPages = Math.ceil(totalBlocks / perPage); 45 | let loadedBlocks = 0; 46 | 47 | // 创建一批Promise请求 48 | const pagePromises = []; 49 | for (let page = 1; page <= totalPages; page++) { 50 | const apiUrl = `https://api.are.na/v2/channels/${slug}/contents?per=${perPage}&page=${page}`; 51 | outputLog(`[fetchChannelBlocks] Requesting page ${page}, URL: ${apiUrl}`); 52 | 53 | const pagePromise = fetch(apiUrl, { 54 | headers: { 'Authorization': `Bearer ${CONFIG.accessToken}` } 55 | }) 56 | .then(response => { 57 | if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); 58 | return response.json(); 59 | }) 60 | .then(data => { 61 | outputLog(`[fetchChannelBlocks] Page ${page} data received, ${data.contents.length} blocks.`); 62 | loadedBlocks += data.contents.length; 63 | loadingBar.style.width = `${(loadedBlocks/totalBlocks)*100}%`; 64 | return data.contents; 65 | }); 66 | 67 | pagePromises.push(pagePromise); 68 | } 69 | 70 | // 使用Promise.allSettled确保即使某些请求失败,我们也能获取尽可能多的数据 71 | const results = await Promise.allSettled(pagePromises); 72 | 73 | // 处理结果 74 | results.forEach((result, index) => { 75 | if (result.status === 'fulfilled') { 76 | allBlocks = allBlocks.concat(result.value); 77 | } else { 78 | outputLog(`[fetchChannelBlocks] Error fetching page ${index + 1}: ${result.reason}`); 79 | } 80 | }); 81 | } catch (error) { 82 | outputLog(`[fetchChannelBlocks] Error fetching channel blocks: ${error}`); 83 | } finally { 84 | // 清理UI 85 | loadingContainer.style.display = 'none'; 86 | logOutput.style.display = 'none'; 87 | } 88 | 89 | outputLog(`[fetchChannelBlocks] Blocks for channel "${slug}" fetched, ${allBlocks.length} blocks in total.`); 90 | 91 | // 初始化区块位置 92 | STATE.cachedBlockPositions = {}; 93 | STATE.cachedBlockOrder = []; 94 | 95 | const viewport = { 96 | width: window.innerWidth, 97 | height: window.innerHeight 98 | }; 99 | 100 | const blockDimensions = { 101 | width: 200, 102 | height: 300 103 | }; 104 | 105 | const bounds = { 106 | minX: 0, 107 | minY: 0, 108 | maxX: viewport.width - blockDimensions.width, 109 | maxY: viewport.height - blockDimensions.height 110 | }; 111 | 112 | // 为所有区块分配随机位置 113 | allBlocks.forEach(block => { 114 | const position = { 115 | x: bounds.minX + Math.random() * (bounds.maxX - bounds.minX), 116 | y: bounds.minY + Math.random() * (bounds.maxY - bounds.minY), 117 | rotation: Math.random() * 20 - 10 // -10 到 +10 度之间的随机旋转 118 | }; 119 | 120 | STATE.cachedBlockPositions[block.id] = position; 121 | STATE.cachedBlockOrder.push(block.id); 122 | }); 123 | 124 | return allBlocks; 125 | } 126 | 127 | async function updateChannel(newSlug, forceRefresh = false) { 128 | closeDetailView(); 129 | resetTileButton(); 130 | outputLog(`[Channel] Switching to: ${newSlug}`); 131 | 132 | // Clean up any existing memory monitoring 133 | if (STATE.memoryMonitorId) { 134 | clearInterval(STATE.memoryMonitorId); 135 | STATE.memoryMonitorId = null; 136 | } 137 | 138 | document.querySelectorAll('.block').forEach(block => { 139 | // Clean up any observers before removing elements 140 | if (block._imageObserver) { 141 | block._imageObserver.disconnect(); 142 | } 143 | block.remove(); 144 | }); 145 | 146 | clearInterval(STATE.loadIntervalId); 147 | 148 | STATE.channelSlugs = [newSlug]; 149 | STATE.allFetchedBlocks = []; 150 | STATE.currentlyDisplayedBlocks = 0; 151 | STATE.cachedBlockPositions = {}; 152 | STATE.cachedBlockOrder = []; 153 | STATE.visibleBlockIds = new Set(); // Track which blocks are currently visible 154 | 155 | if (!forceRefresh) { 156 | try { 157 | const cachedData = await arenaDB.getChannel(newSlug); 158 | if (cachedData && 159 | cachedData.data && 160 | cachedData.positions && 161 | cachedData.timestamp && 162 | Date.now() - cachedData.timestamp < CONFIG.cacheMaxAge) { 163 | 164 | outputLog(`[Cache] Loading data for ${newSlug}`); 165 | STATE.allFetchedBlocks = cachedData.data; 166 | STATE.cachedBlockPositions = cachedData.positions || {}; 167 | 168 | STATE.cachedBlockOrder = (cachedData.order && cachedData.order.length > 0) 169 | ? cachedData.order 170 | : STATE.allFetchedBlocks.map(b => b.id); 171 | 172 | if (!Array.isArray(STATE.allFetchedBlocks) || STATE.allFetchedBlocks.length === 0) { 173 | throw new Error('Invalid cache data structure'); 174 | } 175 | 176 | // On mobile, we'll load blocks progressively 177 | const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); 178 | if (isMobile) { 179 | // Start the memory monitor for mobile devices 180 | startMemoryMonitoring(); 181 | 182 | // Load only a portion of blocks initially 183 | const initialBlocks = Math.min(CONFIG.blocksPerLoad, STATE.cachedBlockOrder.length); 184 | STATE.cachedBlockOrder.slice(0, initialBlocks).forEach(blockId => { 185 | try { 186 | const block = STATE.allFetchedBlocks.find(b => b.id === blockId); 187 | if (block) { 188 | const blockElement = renderBlock(block); 189 | if (STATE.cachedBlockPositions[block.id]) { 190 | const pos = STATE.cachedBlockPositions[block.id]; 191 | blockElement.style.transform = `translate(${pos.x}px, ${pos.y}px) rotate(${pos.rotation}deg)`; 192 | } 193 | STATE.visibleBlockIds.add(blockId); 194 | } 195 | } catch (error) { 196 | console.error('Error rendering cached block:', error); 197 | } 198 | }); 199 | 200 | STATE.currentlyDisplayedBlocks = initialBlocks; 201 | 202 | // Set up interval to load more blocks 203 | STATE.loadIntervalId = setInterval(loadMoreBlocks, CONFIG.loadInterval); 204 | } else { 205 | // On desktop, load all blocks at once (with a maximum limit if specified) 206 | let renderSuccess = true; 207 | const maxBlocks = CONFIG.maxBlocks || STATE.cachedBlockOrder.length; 208 | const blocksToRender = STATE.cachedBlockOrder.slice(0, maxBlocks); 209 | 210 | blocksToRender.forEach(blockId => { 211 | try { 212 | const block = STATE.allFetchedBlocks.find(b => b.id === blockId); 213 | if (block) { 214 | const blockElement = renderBlock(block); 215 | if (STATE.cachedBlockPositions[block.id]) { 216 | const pos = STATE.cachedBlockPositions[block.id]; 217 | blockElement.style.transform = `translate(${pos.x}px, ${pos.y}px) rotate(${pos.rotation}deg)`; 218 | } 219 | STATE.visibleBlockIds.add(blockId); 220 | } 221 | } catch (error) { 222 | renderSuccess = false; 223 | console.error('Error rendering cached block:', error); 224 | } 225 | }); 226 | 227 | if (document.querySelectorAll('.block').length === 0) { 228 | throw new Error('No blocks rendered from cache'); 229 | } 230 | 231 | if (!renderSuccess) { 232 | throw new Error('Failed to render some cached blocks'); 233 | } 234 | 235 | STATE.currentlyDisplayedBlocks = blocksToRender.length; 236 | } 237 | 238 | outputLog(`[Cache] Successfully loaded ${STATE.currentlyDisplayedBlocks} blocks`); 239 | return; 240 | } 241 | } catch (error) { 242 | console.error('Error loading from cache:', error); 243 | outputLog(`[Cache] Load failed: ${error.message}, falling back to API`); 244 | try { 245 | await arenaDB.saveChannel(newSlug, null); 246 | } catch (e) { 247 | console.error('Failed to clear corrupted cache:', e); 248 | } 249 | } 250 | } 251 | 252 | try { 253 | const blocks = await fetchChannelBlocks(newSlug); 254 | if (!blocks || blocks.length === 0) { 255 | throw new Error(`No blocks found in channel: ${newSlug}`); 256 | } 257 | STATE.allFetchedBlocks.push(...blocks); 258 | 259 | try { 260 | await arenaDB.saveChannel(newSlug, blocks); 261 | } catch (error) { 262 | console.error('Failed to save to cache:', error); 263 | outputLog('[Warning] Failed to save to cache, but blocks loaded successfully'); 264 | } 265 | 266 | // On mobile devices, start memory monitoring 267 | if (isMobileDevice()) { 268 | startMemoryMonitoring(); 269 | } 270 | 271 | loadMoreBlocks(); 272 | STATE.loadIntervalId = setInterval(loadMoreBlocks, CONFIG.loadInterval); 273 | outputLog(`[API] Successfully loaded ${blocks.length} blocks`); 274 | } catch (error) { 275 | console.error('Error fetching blocks:', error); 276 | outputLog(`[Error] ${error.message}`); 277 | } 278 | } 279 | 280 | // New function to handle memory monitoring on mobile devices 281 | function startMemoryMonitoring() { 282 | // Only for mobile devices and if supported 283 | if (!isMobileDevice() || !window.performance || !window.performance.memory) return; 284 | 285 | STATE.memoryMonitorId = setInterval(() => { 286 | try { 287 | // Check for high memory usage and remove elements if needed 288 | if (window.performance.memory && window.performance.memory.usedJSHeapSize > 289 | window.performance.memory.jsHeapSizeLimit * 0.8) { 290 | outputLog('[Memory Warning] High memory usage detected, cleaning up offscreen blocks'); 291 | cleanupOffscreenBlocks(); 292 | } 293 | } catch (e) { 294 | console.error('Error monitoring memory:', e); 295 | } 296 | }, CONFIG.memoryCheckInterval); 297 | } 298 | 299 | // Helper function to clean up blocks that are completely off screen 300 | function cleanupOffscreenBlocks() { 301 | if (!isMobileDevice()) return; 302 | 303 | const viewport = { 304 | left: window.scrollX, 305 | top: window.scrollY, 306 | right: window.scrollX + window.innerWidth, 307 | bottom: window.scrollY + window.innerHeight 308 | }; 309 | 310 | const margin = 200; // Extra margin around viewport to prevent too aggressive cleanup 311 | const extendedViewport = { 312 | left: viewport.left - margin, 313 | top: viewport.top - margin, 314 | right: viewport.right + margin, 315 | bottom: viewport.bottom + margin 316 | }; 317 | 318 | // Get all blocks and check if they're in the viewport 319 | const blocks = document.querySelectorAll('.block'); 320 | const blocksToRemove = []; 321 | 322 | blocks.forEach(block => { 323 | const rect = block.getBoundingClientRect(); 324 | const blockCenter = { 325 | x: rect.left + rect.width / 2, 326 | y: rect.top + rect.height / 2 327 | }; 328 | 329 | // If block is completely outside the extended viewport, mark for removal 330 | if (blockCenter.x < extendedViewport.left || 331 | blockCenter.x > extendedViewport.right || 332 | blockCenter.y < extendedViewport.top || 333 | blockCenter.y > extendedViewport.bottom) { 334 | 335 | const blockId = block.dataset.blockId; 336 | blocksToRemove.push({ element: block, id: blockId }); 337 | } 338 | }); 339 | 340 | // Limit how many blocks we remove at once to avoid visual issues 341 | const maxToRemove = Math.min(blocksToRemove.length, 10); 342 | if (maxToRemove > 0) { 343 | outputLog(`[Memory Cleanup] Removing ${maxToRemove} offscreen blocks`); 344 | 345 | for (let i = 0; i < maxToRemove; i++) { 346 | const { element, id } = blocksToRemove[i]; 347 | 348 | // Cleanup observers 349 | if (element._imageObserver) { 350 | element._imageObserver.disconnect(); 351 | } 352 | 353 | // Remove element from DOM 354 | element.remove(); 355 | 356 | // Update tracking 357 | if (id) { 358 | STATE.visibleBlockIds.delete(id); 359 | } 360 | } 361 | } 362 | } 363 | 364 | // Function to dynamically create the warning dialog if it doesn't exist 365 | function createBlockLimitWarningDialog() { 366 | console.log("[createBlockLimitWarningDialog] Creating dialog dynamically"); 367 | 368 | // Check if it already exists 369 | if (document.getElementById('block-limit-warning')) { 370 | console.log("[createBlockLimitWarningDialog] Dialog already exists, not creating"); 371 | return; 372 | } 373 | 374 | // Create dialog container 375 | const dialog = document.createElement('div'); 376 | dialog.id = 'block-limit-warning'; 377 | dialog.className = 'modal-dialog'; 378 | 379 | // Create dialog content 380 | const content = document.createElement('div'); 381 | content.className = 'modal-content'; 382 | 383 | // Add title 384 | const title = document.createElement('h3'); 385 | title.textContent = 'Block Limit Reached'; 386 | 387 | // Add description 388 | const description = document.createElement('p'); 389 | description.innerHTML = 'This channel has more than blocks. Loading more may affect performance on your device.'; 390 | 391 | // Add button group 392 | const buttonGroup = document.createElement('div'); 393 | buttonGroup.className = 'button-group'; 394 | 395 | // Add Load More button 396 | const loadMoreBtn = document.createElement('button'); 397 | loadMoreBtn.id = 'load-more-blocks-btn'; 398 | loadMoreBtn.textContent = 'Load More'; 399 | 400 | // Add Cancel button 401 | const cancelBtn = document.createElement('button'); 402 | cancelBtn.id = 'cancel-load-more-btn'; 403 | cancelBtn.textContent = 'Cancel'; 404 | 405 | // Assemble the dialog 406 | buttonGroup.appendChild(loadMoreBtn); 407 | buttonGroup.appendChild(cancelBtn); 408 | content.appendChild(title); 409 | content.appendChild(description); 410 | content.appendChild(buttonGroup); 411 | dialog.appendChild(content); 412 | 413 | // Add to the document 414 | document.body.appendChild(dialog); 415 | 416 | console.log("[createBlockLimitWarningDialog] Dialog created and added to DOM"); 417 | 418 | // Initialize event listeners 419 | initBlockLimitWarningListeners(); 420 | 421 | return dialog; 422 | } 423 | 424 | // Function to set up event listeners for the dialog 425 | function initBlockLimitWarningListeners() { 426 | const warningDialog = document.getElementById('block-limit-warning'); 427 | const loadMoreButton = document.getElementById('load-more-blocks-btn'); 428 | const cancelButton = document.getElementById('cancel-load-more-btn'); 429 | 430 | if (!warningDialog || !loadMoreButton || !cancelButton) { 431 | console.error("[initBlockLimitWarningListeners] Could not find dialog elements"); 432 | return; 433 | } 434 | 435 | // Make sure any click inside doesn't propagate to body 436 | warningDialog.addEventListener('click', function(event) { 437 | event.stopPropagation(); 438 | }); 439 | 440 | // Prevent clicks inside the modal from closing it 441 | const modalContent = warningDialog.querySelector('.modal-content'); 442 | if (modalContent) { 443 | modalContent.addEventListener('click', function(event) { 444 | event.stopPropagation(); 445 | }); 446 | } 447 | 448 | // Prevent the dialog from disappearing when clicking outside 449 | warningDialog.addEventListener('mousedown', function(event) { 450 | // Only prevent propagation if clicking the dialog background, not content 451 | if (event.target === warningDialog) { 452 | event.preventDefault(); 453 | event.stopPropagation(); 454 | } 455 | }); 456 | 457 | // Set up load more button 458 | loadMoreButton.addEventListener('click', function() { 459 | console.log("[loadMoreButton] User clicked Load More"); 460 | CONFIG.userOverrideBlockLimit = true; 461 | warningDialog.style.display = 'none'; 462 | 463 | // Reset the load interval to continue loading blocks 464 | if (STATE.loadIntervalId === null) { 465 | STATE.loadIntervalId = setInterval(loadMoreBlocks, CONFIG.loadInterval); 466 | } 467 | 468 | // Load a batch immediately 469 | loadMoreBlocks(); 470 | }); 471 | 472 | // Set up cancel button 473 | cancelButton.addEventListener('click', function() { 474 | console.log("[cancelButton] User clicked Cancel"); 475 | warningDialog.style.display = 'none'; 476 | CONFIG.userOverrideBlockLimit = false; 477 | }); 478 | 479 | console.log("[initBlockLimitWarningListeners] Event listeners set up"); 480 | } 481 | 482 | function showBlockLimitWarning() { 483 | // Set up the warning dialog or create it if it doesn't exist 484 | let warningDialog = document.getElementById('block-limit-warning'); 485 | 486 | if (!warningDialog) { 487 | warningDialog = createBlockLimitWarningDialog(); 488 | if (!warningDialog) { 489 | console.error("[Error] Could not create block limit warning dialog"); 490 | return; 491 | } 492 | } 493 | 494 | const blockLimitCount = document.getElementById('block-limit-count'); 495 | const loadMoreButton = document.getElementById('load-more-blocks-btn'); 496 | const cancelButton = document.getElementById('cancel-load-more-btn'); 497 | 498 | // Update the limit count 499 | const currentLimit = isMobileDevice() ? CONFIG.maxBlocks : CONFIG.maxBlocks; 500 | if (blockLimitCount) { 501 | blockLimitCount.textContent = currentLimit; 502 | } 503 | 504 | // Display the dialog 505 | warningDialog.classList.add('show'); 506 | warningDialog.style.display = 'flex'; 507 | 508 | // Set up event listeners for the buttons 509 | loadMoreButton.onclick = function() { 510 | CONFIG.userOverrideBlockLimit = true; 511 | warningDialog.style.display = 'none'; 512 | 513 | // Reset the load interval to continue loading blocks 514 | if (STATE.loadIntervalId === null) { 515 | STATE.loadIntervalId = setInterval(loadMoreBlocks, CONFIG.loadInterval); 516 | } 517 | 518 | // Load a batch immediately 519 | loadMoreBlocks(); 520 | }; 521 | 522 | cancelButton.onclick = function() { 523 | warningDialog.style.display = 'none'; 524 | CONFIG.userOverrideBlockLimit = false; 525 | }; 526 | } 527 | 528 | function loadMoreBlocks() { 529 | if (STATE.isLoading) return; 530 | STATE.isLoading = true; 531 | 532 | const isMobile = isMobileDevice(); 533 | const currentBlockCount = document.querySelectorAll('.block').length; 534 | const maxAllowedBlocks = CONFIG.userOverrideBlockLimit ? 535 | CONFIG.maxBlocksAfterOverride : CONFIG.maxBlocks; 536 | 537 | // If we've reached the initial limit but user hasn't made a choice yet, show warning 538 | // CRITICAL FIX: Check this condition FIRST before checking if we've hit max allowed blocks 539 | if (!CONFIG.userOverrideBlockLimit && currentBlockCount >= CONFIG.maxBlocks) { 540 | outputLog(`[loadMoreBlocks] Initial block limit reached (${CONFIG.maxBlocks}), showing warning`); 541 | showBlockLimitWarning(); 542 | clearInterval(STATE.loadIntervalId); 543 | STATE.loadIntervalId = null; 544 | STATE.isLoading = false; 545 | return; 546 | } 547 | 548 | // Check if we've reached the block limit 549 | if (currentBlockCount >= maxAllowedBlocks) { 550 | outputLog(`[loadMoreBlocks] Block limit reached (${maxAllowedBlocks}), stopping auto-load`); 551 | clearInterval(STATE.loadIntervalId); 552 | STATE.loadIntervalId = null; 553 | STATE.isLoading = false; 554 | return; 555 | } 556 | 557 | const nextBatch = STATE.allFetchedBlocks.slice(STATE.currentlyDisplayedBlocks, STATE.currentlyDisplayedBlocks + CONFIG.blocksPerLoad); 558 | if (nextBatch.length === 0) { 559 | outputLog("[loadMoreBlocks] No more blocks to load."); 560 | STATE.isLoading = false; 561 | clearInterval(STATE.loadIntervalId); 562 | STATE.loadIntervalId = null; 563 | return; 564 | } 565 | 566 | let blocksToRender = nextBatch; 567 | if (STATE.cachedBlockOrder.length > 0) { 568 | // Only render blocks that aren't already visible 569 | const startIdx = STATE.currentlyDisplayedBlocks; 570 | const endIdx = STATE.currentlyDisplayedBlocks + CONFIG.blocksPerLoad; 571 | 572 | blocksToRender = STATE.cachedBlockOrder.slice(startIdx, endIdx) 573 | .filter(blockId => !STATE.visibleBlockIds.has(blockId)) 574 | .map(blockId => STATE.allFetchedBlocks.find(block => block.id === blockId)) 575 | .filter(block => block); 576 | } 577 | 578 | // Render blocks 579 | blocksToRender.forEach(block => { 580 | const blockElement = renderBlock(block); 581 | if (STATE.cachedBlockPositions[block.id]) { 582 | const pos = STATE.cachedBlockPositions[block.id]; 583 | blockElement.style.transform = `translate(${pos.x}px, ${pos.y}px) rotate(${pos.rotation}deg)`; 584 | } 585 | STATE.visibleBlockIds.add(block.id); 586 | }); 587 | 588 | STATE.currentlyDisplayedBlocks += blocksToRender.length; 589 | 590 | // On mobile, do memory cleanup after adding new blocks 591 | if (isMobile && STATE.currentlyDisplayedBlocks > CONFIG.maxBlocks / 2) { 592 | cleanupOffscreenBlocks(); 593 | } 594 | 595 | if (STATE.currentlyDisplayedBlocks >= STATE.allFetchedBlocks.length) { 596 | outputLog(`[loadMoreBlocks] All blocks loaded: ${STATE.currentlyDisplayedBlocks}`); 597 | clearInterval(STATE.loadIntervalId); 598 | STATE.loadIntervalId = null; 599 | } 600 | 601 | STATE.isLoading = false; 602 | } 603 | 604 | async function showDetailView(event) { 605 | // Close current detail view if open 606 | if (document.getElementById('detail-view').style.display === 'flex') { 607 | closeDetailView(); 608 | } 609 | 610 | const blockElement = event.currentTarget; 611 | const blockId = blockElement.dataset.blockId; 612 | 613 | // Hide the current block 614 | STATE.lastViewedBlockElement = blockElement; 615 | blockElement.style.display = 'none'; 616 | 617 | document.querySelectorAll('#detail-view-content img').forEach(img => { 618 | if (img.observer) img.observer.disconnect(); 619 | }); 620 | const block = STATE.allFetchedBlocks.find(b => b.id.toString() === blockId); 621 | if (!block) { 622 | console.error("找不到对应的 block 数据:", blockId); 623 | return; 624 | } 625 | const detailContent = document.getElementById('detail-view-content'); 626 | detailContent.innerHTML = ''; 627 | document.getElementById('detail-view-link').innerHTML = ''; 628 | document.getElementById('detail-view-info').innerHTML = ''; 629 | document.getElementById('detail-view-meta').innerHTML = ''; 630 | const titleElement = document.getElementById('detail-view-title'); 631 | titleElement.textContent = block.title || (block.class === 'Link' ? 'Link' : 'Block Details'); 632 | titleElement.title = block.title || (block.class === 'Link' ? 'Link' : 'Block Details'); 633 | const arenaLinkElement = document.getElementById('detail-view-arena-link'); 634 | 635 | if (block.class === 'Channel') { 636 | detailContent.innerHTML = '
Loading channel details...
'; 637 | document.getElementById('detail-view').style.display = 'flex'; 638 | 639 | try { 640 | const response = await fetch(`https://api.are.na/v2/channels/${block.slug}`); 641 | if (!response.ok) throw new Error('Failed to fetch channel details'); 642 | const channelData = await response.json(); 643 | 644 | detailContent.innerHTML = ''; 645 | 646 | const contentWrapper = document.createElement('div'); 647 | contentWrapper.id = 'channel-detail-container'; 648 | 649 | const basicInfo = document.createElement('div'); 650 | basicInfo.id = 'channel-basic-info'; 651 | 652 | const textInfo = document.createElement('div'); 653 | textInfo.id = 'channel-text-info'; 654 | 655 | if (channelData.metadata && channelData.metadata.description) { 656 | const description = document.createElement('div'); 657 | description.id = 'channel-description'; 658 | description.innerHTML = channelData.metadata.description; 659 | textInfo.appendChild(description); 660 | } 661 | 662 | if (channelData.user) { 663 | const authorInfo = document.createElement('div'); 664 | authorInfo.textContent = 'Channel Author: '; 665 | const authorName = document.createElement('a'); 666 | authorName.href = `https://are.na/${channelData.user.slug}`; 667 | authorName.target = '_blank'; 668 | authorName.textContent = channelData.user.full_name; 669 | authorInfo.appendChild(authorName); 670 | textInfo.appendChild(authorInfo); 671 | } 672 | 673 | const stats = document.createElement('div'); 674 | stats.id = 'channel-stats'; 675 | stats.innerHTML = ` 676 |
Blocks: ${channelData.length || 0}
677 |
Followers: ${channelData.follower_count || 0}
678 | `; 679 | textInfo.appendChild(stats); 680 | 681 | if (channelData.created_at) { 682 | const dates = document.createElement('div'); 683 | dates.id = 'channel-dates'; 684 | const created = new Date(channelData.created_at).toLocaleDateString(); 685 | const updated = channelData.updated_at ? new Date(channelData.updated_at).toLocaleDateString() : created; 686 | dates.innerHTML = ` 687 |
Created: ${created}
688 |
Updated: ${updated}
689 | `; 690 | textInfo.appendChild(dates); 691 | } 692 | 693 | const status = document.createElement('div'); 694 | status.id = 'channel-status'; 695 | const statusText = { 696 | 'public': 'Public', 697 | 'closed': 'Closed', 698 | 'private': 'Private' 699 | }[channelData.status] || 'Public'; 700 | status.innerHTML = ` 701 |
Status: ${statusText}
702 |
${channelData.open ? 'Open' : 'Closed'} Collaboration
703 | `; 704 | textInfo.appendChild(status); 705 | 706 | const goToChannelButton = document.createElement('button'); 707 | goToChannelButton.id = 'channel-goto-button'; 708 | goToChannelButton.textContent = 'Go to Channel'; 709 | goToChannelButton.addEventListener('click', function() { 710 | closeDetailView(); 711 | router.navigate(channelData.slug); 712 | }); 713 | textInfo.appendChild(goToChannelButton); 714 | 715 | if (channelData.image) { 716 | const coverWrapper = document.createElement('div'); 717 | coverWrapper.id = 'channel-cover-wrapper'; 718 | 719 | const cover = document.createElement('img'); 720 | cover.id = 'channel-cover-image'; 721 | cover.src = channelData.image.display.url; 722 | cover.alt = `${channelData.title} channel cover`; 723 | 724 | // Only load high-res cover on desktop 725 | if (!isMobileDevice() && channelData.image.original) { 726 | const originalImg = new Image(); 727 | originalImg.onload = () => { 728 | if (cover.isConnected) { 729 | cover.src = originalImg.src; 730 | } 731 | }; 732 | originalImg.onerror = () => { 733 | console.warn('Failed to load original channel cover image'); 734 | }; 735 | originalImg.src = channelData.image.original.url; 736 | } 737 | 738 | coverWrapper.appendChild(cover); 739 | basicInfo.appendChild(coverWrapper); 740 | } 741 | 742 | basicInfo.insertBefore(textInfo, basicInfo.firstChild); 743 | contentWrapper.appendChild(basicInfo); 744 | detailContent.appendChild(contentWrapper); 745 | 746 | const blockPageUrl = `https://www.are.na/block/${block.id}`; 747 | if (block.description_html) { 748 | addMetaItem('Description', block.description_html, null, true); 749 | } 750 | if (block.connected_at) { 751 | addMetaItem('Connected At', new Date(block.connected_at).toLocaleString(), null); 752 | } 753 | if (block.connected_by_username) { 754 | const userPageUrl = `https://www.are.na/${block.connected_by_user_slug}`; 755 | addMetaItem('Connected By', block.connected_by_username, userPageUrl, false); 756 | } 757 | 758 | arenaLinkElement.href = blockPageUrl; 759 | 760 | } catch (error) { 761 | console.error('Error fetching channel details:', error); 762 | detailContent.innerHTML = '
Failed to load channel details
'; 763 | } 764 | } else { 765 | arenaLinkElement.href = `https://www.are.na/block/${block.id}`; 766 | if (block.image) { 767 | const isMobile = isMobileDevice(); 768 | const img = document.createElement('img'); 769 | 770 | // Start with display version for faster initial load 771 | img.src = block.image.display.url; 772 | 773 | // Set reasonable size constraints for mobile 774 | if (isMobile) { 775 | img.style.maxWidth = '100%'; 776 | img.style.height = 'auto'; 777 | } else { 778 | const originalWidth = block.image.original.width || block.image.display.width * 5; 779 | const originalHeight = block.image.original.height || block.image.display.height * 5; 780 | img.style.width = `${originalWidth}px`; 781 | img.style.height = `${originalHeight}px`; 782 | } 783 | 784 | img.alt = block.title || 'Image'; 785 | detailContent.appendChild(img); 786 | 787 | // For mobile, we may want to stick with the display version to save memory 788 | // For desktop, load the original high-quality version 789 | if (!isMobile && block.image.original && block.image.original.url) { 790 | const originalImg = new Image(); 791 | originalImg.onload = () => { 792 | if (img.isConnected) { // Check if the image is still in the DOM 793 | img.src = originalImg.src; 794 | if (!isMobile) { 795 | img.style.width = ''; 796 | img.style.height = ''; 797 | } 798 | } 799 | }; 800 | originalImg.onerror = () => { 801 | console.warn('Failed to load original image, keeping display version'); 802 | }; 803 | originalImg.src = block.image.original.url; 804 | } 805 | } 806 | if (block.class && block.class.toLowerCase() === 'text') { 807 | if (block.content_html) { 808 | const text = document.createElement('div'); 809 | text.innerHTML = block.content_html; 810 | detailContent.appendChild(text); 811 | } else if (block.title) { 812 | const title = document.createElement('div'); 813 | title.innerHTML = block.title; 814 | detailContent.appendChild(title); 815 | } 816 | } 817 | const blockPageUrl = `https://www.are.na/block/${block.id}`; 818 | if (block.description_html) addMetaItem('Description', block.description_html, null, true); 819 | if (block.id) addMetaItem('Block ID', block.id, blockPageUrl); 820 | if (block.connected_at) addMetaItem('Connected At', new Date(block.connected_at).toLocaleString(), null); 821 | if (block.connected_by_username) { 822 | const userPageUrl = `https://www.are.na/${block.connected_by_user_slug}`; 823 | addMetaItem('Connected By', block.connected_by_username, userPageUrl, false); 824 | } 825 | if (block.source && block.source.url) { 826 | addMetaItem('Source', block.source.title, block.source.url, false); 827 | } 828 | } 829 | 830 | document.getElementById('detail-view').style.display = 'flex'; 831 | } 832 | 833 | // Initialize block limit warning dialog 834 | function initBlockLimitWarning() { 835 | console.log("[initBlockLimitWarning] Initializing block limit warning dialog"); 836 | const warningDialog = document.getElementById('block-limit-warning'); 837 | const loadMoreButton = document.getElementById('load-more-blocks-btn'); 838 | const cancelButton = document.getElementById('cancel-load-more-btn'); 839 | 840 | if (!warningDialog) { 841 | console.error("[initBlockLimitWarning] Block limit warning dialog not found in DOM"); 842 | return; 843 | } 844 | 845 | // Make sure any click inside doesn't propagate to body (which could close it) 846 | warningDialog.addEventListener('click', function(event) { 847 | event.stopPropagation(); 848 | }); 849 | 850 | loadMoreButton.addEventListener('click', function() { 851 | console.log("[loadMoreButton] User clicked Load More"); 852 | CONFIG.userOverrideBlockLimit = true; 853 | warningDialog.style.display = 'none'; 854 | 855 | // Reset the load interval to continue loading blocks 856 | if (STATE.loadIntervalId === null) { 857 | STATE.loadIntervalId = setInterval(loadMoreBlocks, CONFIG.loadInterval); 858 | } 859 | 860 | // Load a batch immediately 861 | loadMoreBlocks(); 862 | }); 863 | 864 | cancelButton.addEventListener('click', function() { 865 | console.log("[cancelButton] User clicked Cancel"); 866 | warningDialog.style.display = 'none'; 867 | CONFIG.userOverrideBlockLimit = false; 868 | }); 869 | 870 | console.log("[initBlockLimitWarning] Block limit warning dialog initialization complete"); 871 | } 872 | 873 | // Initialize application 874 | async function main() { 875 | initHeaderBar(); 876 | router.init(); 877 | initBlockLimitWarning(); // Initialize block limit warning dialog 878 | 879 | try { 880 | await arenaDB.clearOldCache(); 881 | } catch (error) { 882 | console.error('Error clearing old cache:', error); 883 | } 884 | 885 | // Add unload event handler to clean up resources 886 | window.addEventListener('beforeunload', () => { 887 | if (STATE.memoryMonitorId) { 888 | clearInterval(STATE.memoryMonitorId); 889 | } 890 | if (STATE.loadIntervalId) { 891 | clearInterval(STATE.loadIntervalId); 892 | } 893 | 894 | // Clean up any active observers 895 | document.querySelectorAll('.block').forEach(block => { 896 | if (block._imageObserver) { 897 | block._imageObserver.disconnect(); 898 | } 899 | }); 900 | }); 901 | } 902 | 903 | main(); 904 | -------------------------------------------------------------------------------- /js/router.js: -------------------------------------------------------------------------------- 1 | class Router { 2 | constructor() { 3 | this.currentSlug = null; 4 | this.isNavigating = false; 5 | 6 | window.addEventListener('popstate', (event) => { 7 | if (event.state && event.state.slug) { 8 | this.navigate(event.state.slug, false); 9 | } 10 | }); 11 | 12 | window.addEventListener('hashchange', () => { 13 | const slug = this.getSlugFromHash(); 14 | if (slug && slug !== this.currentSlug) { 15 | this.navigate(slug, false); 16 | } 17 | }); 18 | } 19 | 20 | getSlugFromHash() { 21 | return window.location.hash.slice(1) || null; 22 | } 23 | 24 | async navigate(slug, addToHistory = true, forceRefresh = false) { 25 | if (this.isNavigating) return; 26 | if (slug === this.currentSlug && !forceRefresh) return; 27 | 28 | this.isNavigating = true; 29 | this.currentSlug = slug; 30 | 31 | document.getElementById('loading-container').style.display = 'block'; 32 | document.getElementById('loading-bar').style.width = '0%'; 33 | document.getElementById('log-output').style.display = 'block'; 34 | document.getElementById('log-output').innerHTML = ''; 35 | 36 | document.title = `${slug} | are.na blocks canvas`; 37 | 38 | if (addToHistory) { 39 | history.pushState({ slug }, '', `#${slug}`); 40 | } 41 | 42 | try { 43 | document.getElementById('channel-slug-input').value = slug; 44 | document.getElementById('header-bar-logo-link').href = `https://are.na/channel/${slug}`; 45 | 46 | await updateChannel(slug, forceRefresh); 47 | 48 | const channelInfo = await fetchChannelInfo(slug); 49 | if (channelInfo) { 50 | await arenaDB.addToHistory(slug, channelInfo.title); 51 | setTimeout(() => { 52 | document.getElementById('loading-container').style.display = 'none'; 53 | document.getElementById('log-output').style.display = 'none'; 54 | }, 500); 55 | } 56 | } catch (error) { 57 | console.error('Navigation error:', error); 58 | outputLog(`[Error] Failed to load channel: ${error.message}`); 59 | document.getElementById('loading-container').style.display = 'none'; 60 | document.getElementById('log-output').style.display = 'block'; 61 | } finally { 62 | this.isNavigating = false; 63 | } 64 | } 65 | 66 | init() { 67 | const initialSlug = this.getSlugFromHash() || STATE.channelSlugs[0]; 68 | history.replaceState({ slug: initialSlug }, '', `#${initialSlug}`); 69 | this.navigate(initialSlug, false); 70 | } 71 | } 72 | 73 | // Global routing instance 74 | const router = new Router(); -------------------------------------------------------------------------------- /js/ui.js: -------------------------------------------------------------------------------- 1 | // UI Utility Functions 2 | function outputLog(message) { 3 | console.log(message); 4 | const logOutputElement = document.getElementById('log-output'); 5 | if (logOutputElement && logOutputElement.style.display !== 'none') { 6 | logOutputElement.innerHTML += message + '
'; 7 | } 8 | } 9 | 10 | function throttle(func, delay) { 11 | let timeoutId, lastExecTime = 0; 12 | return function(...args) { 13 | const now = Date.now(); 14 | if (now - lastExecTime >= delay) { 15 | func.apply(this, args); 16 | lastExecTime = now; 17 | } else { 18 | clearTimeout(timeoutId); 19 | timeoutId = setTimeout(() => { 20 | func.apply(this, args); 21 | lastExecTime = Date.now(); 22 | }, delay - (now - lastExecTime)); 23 | } 24 | }; 25 | } 26 | 27 | function getTranslateXValue(element) { 28 | const style = window.getComputedStyle(element); 29 | const matrix = new DOMMatrix(style.transform); 30 | return matrix.m41; 31 | } 32 | 33 | function getTranslateYValue(element) { 34 | const style = window.getComputedStyle(element); 35 | const matrix = new DOMMatrix(style.transform); 36 | return matrix.m42; 37 | } 38 | 39 | function updateThemeToggleText(theme) { 40 | const themeToggle = document.getElementById('theme-toggle'); 41 | const moreThemeButton = document.getElementById('more-theme-button'); 42 | themeToggle.textContent = theme === 'system' ? 'sys' : theme.toLowerCase(); 43 | if (moreThemeButton) { 44 | moreThemeButton.textContent = theme === 'system' ? 'sys' : theme.toLowerCase(); 45 | } 46 | } 47 | 48 | function closeDetailView() { 49 | document.getElementById('detail-view').style.display = 'none'; 50 | 51 | // Show the previously hidden block 52 | if (STATE.lastViewedBlockElement) { 53 | STATE.lastViewedBlockElement.style.display = ''; 54 | STATE.lastViewedBlockElement = null; 55 | } 56 | } 57 | 58 | function addMetaItem(label, value, linkHref, isHTML=false) { 59 | const metaContainer = document.getElementById('detail-view-meta'); 60 | if (!value) return; 61 | let item = document.createElement('div'); 62 | item.className = 'meta-item'; 63 | item.innerHTML = `${label}: `; 64 | if (isHTML) { 65 | let contentDiv = document.createElement('div'); 66 | contentDiv.innerHTML = value; 67 | item.appendChild(contentDiv); 68 | } else { 69 | if (linkHref) { 70 | let a = document.createElement('a'); 71 | a.href = linkHref; 72 | a.target = '_blank'; 73 | a.textContent = value; 74 | item.appendChild(a); 75 | } else { 76 | item.appendChild(document.createTextNode(value)); 77 | } 78 | } 79 | metaContainer.appendChild(item); 80 | } 81 | 82 | function showAboutView() { 83 | // 先关闭当前的 detailview 84 | if (document.getElementById('detail-view').style.display === 'flex') { 85 | closeDetailView(); 86 | } 87 | 88 | const detailView = document.getElementById('detail-view'); 89 | const detailTitle = document.getElementById('detail-view-title'); 90 | const detailContent = document.getElementById('detail-view-content'); 91 | const detailMeta = document.getElementById('detail-view-meta'); 92 | const arenaLink = document.getElementById('detail-view-arena-link'); 93 | 94 | detailTitle.textContent = 'About Are.na Blocks Canvas'; 95 | 96 | detailContent.innerHTML = ` 97 |
98 |

Are.na Blocks Canvas is a tool for visually browsing Are.na channel content. It provides a unique, interactive interface to explore Are.na content.

99 |

What is Are.na?

100 |

Are.na is an interest-based social network where users can create and join various channels to share and discover content.

101 |

Visit are.na to create an account.

102 |

Key Features

103 |

Built With 0 productivity in mind: Are.na feels like a park to me, where you can wander around without much purpose but discover interesting content. Therefore, this project is also meant for casual exploration, with no productivity pressure.

104 |

How to

105 | 113 |
114 |

This project is open source. Contributions and feedback are welcome.

115 |
116 | `; 117 | 118 | detailMeta.innerHTML = ` 119 |
120 | Version: ${CONFIG.version} 121 |
122 |
123 | Created by Lok ✶✶ with love 124 |
125 | `; 126 | 127 | arenaLink.href = 'https://www.are.na/lok'; 128 | detailView.style.display = 'flex'; 129 | } 130 | 131 | function initHeaderBar() { 132 | const slugInput = document.getElementById('channel-slug-input'); 133 | slugInput.value = STATE.channelSlugs[0]; 134 | document.getElementById('goto-button').addEventListener('click', handleGoButtonClick); 135 | slugInput.addEventListener('keydown', (event) => { 136 | if (event.key === 'Enter') { 137 | event.preventDefault(); 138 | handleGoButtonClick(); 139 | } 140 | }); 141 | const logoLink = document.getElementById('header-bar-logo-link'); 142 | logoLink.href = '#'; // 移除直接跳转 143 | logoLink.addEventListener('click', async (e) => { 144 | e.preventDefault(); 145 | showCurrentChannelDetail(); 146 | }); 147 | 148 | // Initialize tile/shuffle button 149 | const tileButton = document.getElementById('tile-button'); 150 | STATE.isTiled = false; // 将状态移到全局 151 | tileButton.addEventListener('click', () => { 152 | if (STATE.isTiled) { 153 | shuffleBlocks(); 154 | tileButton.textContent = 'tile'; 155 | if (document.getElementById('more-tile-button')) { 156 | document.getElementById('more-tile-button').textContent = 'tile'; 157 | } 158 | } else { 159 | tileBlocks(); 160 | tileButton.textContent = 'mix'; 161 | if (document.getElementById('more-tile-button')) { 162 | document.getElementById('more-tile-button').textContent = 'mix'; 163 | } 164 | } 165 | STATE.isTiled = !STATE.isTiled; 166 | }); 167 | 168 | const themeToggle = document.getElementById('theme-toggle'); 169 | const root = document.documentElement; 170 | 171 | const savedTheme = localStorage.getItem('theme') || 'system'; 172 | root.setAttribute('data-theme', savedTheme); 173 | updateThemeToggleText(savedTheme); 174 | 175 | themeToggle.addEventListener('click', () => { 176 | const currentTheme = root.getAttribute('data-theme'); 177 | let newTheme; 178 | 179 | switch(currentTheme) { 180 | case 'system': 181 | newTheme = 'light'; 182 | break; 183 | case 'light': 184 | newTheme = 'dark'; 185 | break; 186 | default: 187 | newTheme = 'system'; 188 | } 189 | 190 | root.setAttribute('data-theme', newTheme); 191 | localStorage.setItem('theme', newTheme); 192 | updateThemeToggleText(newTheme); 193 | updatePWAThemeColors(newTheme); 194 | }); 195 | 196 | document.getElementById('about-button').addEventListener('click', showAboutView); 197 | } 198 | 199 | function handleGoButtonClick() { 200 | const newSlug = document.getElementById('channel-slug-input').value.trim(); 201 | if (newSlug) { 202 | router.navigate(newSlug, true, true); 203 | } 204 | } 205 | 206 | // Initialize UI event listeners 207 | document.addEventListener('DOMContentLoaded', () => { 208 | const headerBar = document.getElementById('header-bar'); 209 | 210 | headerBar.addEventListener('touchstart', (e) => { 211 | // Allow button clicks 212 | }, { passive: true }); 213 | 214 | headerBar.addEventListener('touchmove', (e) => { 215 | if (!e.target.matches('button, input')) { 216 | e.preventDefault(); 217 | } 218 | }, { passive: false }); 219 | 220 | const closeWrapper = document.getElementById('detail-view-close-wrapper'); 221 | closeWrapper.addEventListener('click', closeDetailView); 222 | closeWrapper.addEventListener('touchend', closeDetailView); 223 | 224 | const arenaLink = document.getElementById('detail-view-arena-link'); 225 | arenaLink.addEventListener('touchend', function(e) { 226 | window.open(this.href, '_blank'); 227 | }); 228 | }); 229 | 230 | // Add new functions for tile and shuffle 231 | function tileBlocks() { 232 | const blocks = Array.from(document.querySelectorAll('.block')); 233 | const blockWidth = 200; 234 | const blockHeight = 300; 235 | const headerHeight = 0; // used to be 30, seems like not necessary 236 | 237 | // 计算可用空间 238 | const availableWidth = window.innerWidth - blockWidth; 239 | const availableHeight = window.innerHeight - blockHeight - headerHeight; 240 | 241 | // 根据 blocks 数量动态计算布局 242 | const totalBlocks = blocks.length; 243 | const aspectRatio = availableWidth / availableHeight; 244 | 245 | // 计算理想的行列数,考虑屏幕比例 246 | let columnsCount = Math.ceil(Math.sqrt(totalBlocks * aspectRatio)); 247 | let rowsCount = Math.ceil(totalBlocks / columnsCount); 248 | 249 | // 计算每个 block 之间的间距(允许重叠) 250 | const xSpacing = (availableWidth) / (columnsCount - 1 || 1); 251 | const ySpacing = (availableHeight) / (rowsCount - 1 || 1); 252 | 253 | blocks.forEach((block, index) => { 254 | const row = Math.floor(index / columnsCount); 255 | const col = index % columnsCount; 256 | 257 | // 计算基础位置 258 | let x = col * xSpacing; 259 | let y = headerHeight + row * ySpacing; 260 | 261 | // 添加一点随机偏移,但保持在边界内 262 | const maxOffset = Math.min(xSpacing, ySpacing) * 0.2; 263 | const randomOffsetX = (Math.random() - 0.5) * maxOffset; 264 | const randomOffsetY = (Math.random() - 0.5) * maxOffset; 265 | 266 | // 确保不会超出边界 267 | x = Math.max(0, Math.min(availableWidth, x + randomOffsetX)); 268 | y = Math.max(headerHeight, Math.min(window.innerHeight - blockHeight, y + randomOffsetY)); 269 | 270 | block.style.transform = `translate(${x}px, ${y}px) rotate(0deg)`; 271 | 272 | // Update cached position 273 | const blockId = block.dataset.blockId; 274 | if (blockId) { 275 | STATE.cachedBlockPositions[blockId] = { 276 | x: x, 277 | y: y, 278 | rotation: 0 279 | }; 280 | } 281 | }); 282 | 283 | // Save to cache 284 | const slug = STATE.channelSlugs[0]; 285 | arenaDB.getChannel(slug).then(cachedData => { 286 | if (cachedData) { 287 | return arenaDB.saveChannel(slug, cachedData.data); 288 | } 289 | }).catch(error => { 290 | console.error('Error updating block positions in cache:', error); 291 | }); 292 | } 293 | 294 | function shuffleBlocks() { 295 | const blocks = Array.from(document.querySelectorAll('.block')); 296 | const blockWidth = 200; 297 | const blockHeight = 300; 298 | const headerHeight = 0; // used to be 30, seems like not necessary 299 | 300 | const minX = 0; 301 | const minY = 0; 302 | const maxX = window.innerWidth - blockWidth; 303 | const maxY = window.innerHeight - blockHeight - headerHeight; 304 | 305 | blocks.forEach(block => { 306 | const x = minX + Math.random() * (maxX - minX); 307 | const y = minY + Math.random() * (maxY - minY); 308 | const rotation = Math.random() * 20 - 10; 309 | 310 | block.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`; 311 | 312 | // Update cached position 313 | const blockId = block.dataset.blockId; 314 | if (blockId) { 315 | STATE.cachedBlockPositions[blockId] = { 316 | x: x, 317 | y: y, 318 | rotation: rotation 319 | }; 320 | } 321 | }); 322 | 323 | // Save to cache 324 | const slug = STATE.channelSlugs[0]; 325 | arenaDB.getChannel(slug).then(cachedData => { 326 | if (cachedData) { 327 | return arenaDB.saveChannel(slug, cachedData.data); 328 | } 329 | }).catch(error => { 330 | console.error('Error updating block positions in cache:', error); 331 | }); 332 | } 333 | 334 | // Add new function to reset tile button 335 | function resetTileButton() { 336 | const tileButton = document.getElementById('tile-button'); 337 | const moreTileButton = document.getElementById('more-tile-button'); 338 | tileButton.textContent = 'tile'; 339 | if (moreTileButton) { 340 | moreTileButton.textContent = 'tile'; 341 | } 342 | STATE.isTiled = false; 343 | } 344 | 345 | // Add new function to show current channel detail 346 | async function showCurrentChannelDetail() { 347 | // 先关闭当前的 detailview 348 | if (document.getElementById('detail-view').style.display === 'flex') { 349 | closeDetailView(); 350 | } 351 | 352 | const slug = STATE.channelSlugs[0]; 353 | if (!slug) return; 354 | 355 | const detailContent = document.getElementById('detail-view-content'); 356 | const detailTitle = document.getElementById('detail-view-title'); 357 | const detailMeta = document.getElementById('detail-view-meta'); 358 | const arenaLink = document.getElementById('detail-view-arena-link'); 359 | 360 | detailContent.innerHTML = '
Loading channel details...
'; 361 | document.getElementById('detail-view').style.display = 'flex'; 362 | 363 | try { 364 | const response = await fetch(`https://api.are.na/v2/channels/${slug}`); 365 | if (!response.ok) throw new Error('Failed to fetch channel details'); 366 | const channelData = await response.json(); 367 | 368 | detailContent.innerHTML = ''; 369 | detailTitle.textContent = channelData.title; 370 | 371 | const contentWrapper = document.createElement('div'); 372 | contentWrapper.id = 'channel-detail-container'; 373 | 374 | const basicInfo = document.createElement('div'); 375 | basicInfo.id = 'channel-basic-info'; 376 | 377 | const textInfo = document.createElement('div'); 378 | textInfo.id = 'channel-text-info'; 379 | 380 | if (channelData.metadata && channelData.metadata.description) { 381 | const description = document.createElement('div'); 382 | description.id = 'channel-description'; 383 | description.innerHTML = channelData.metadata.description; 384 | textInfo.appendChild(description); 385 | } 386 | 387 | if (channelData.user) { 388 | const authorInfo = document.createElement('div'); 389 | authorInfo.textContent = 'Channel Author: '; 390 | const authorName = document.createElement('a'); 391 | authorName.href = `https://are.na/${channelData.user.slug}`; 392 | authorName.target = '_blank'; 393 | authorName.textContent = channelData.user.full_name; 394 | authorInfo.appendChild(authorName); 395 | textInfo.appendChild(authorInfo); 396 | } 397 | 398 | const stats = document.createElement('div'); 399 | stats.id = 'channel-stats'; 400 | stats.innerHTML = ` 401 |
Blocks: ${channelData.length || 0}
402 |
Followers: ${channelData.follower_count || 0}
403 | `; 404 | textInfo.appendChild(stats); 405 | 406 | if (channelData.created_at) { 407 | const dates = document.createElement('div'); 408 | dates.id = 'channel-dates'; 409 | const created = new Date(channelData.created_at).toLocaleDateString(); 410 | const updated = channelData.updated_at ? new Date(channelData.updated_at).toLocaleDateString() : created; 411 | dates.innerHTML = ` 412 |
Created: ${created}
413 |
Updated: ${updated}
414 | `; 415 | textInfo.appendChild(dates); 416 | } 417 | 418 | const status = document.createElement('div'); 419 | status.id = 'channel-status'; 420 | const statusText = { 421 | 'public': 'Public', 422 | 'closed': 'Closed', 423 | 'private': 'Private' 424 | }[channelData.status] || 'Public'; 425 | status.innerHTML = ` 426 |
Status: ${statusText}
427 |
${channelData.open ? 'Open' : 'Closed'} Collaboration
428 | `; 429 | textInfo.appendChild(status); 430 | 431 | const viewOnArenaButton = document.createElement('button'); 432 | viewOnArenaButton.id = 'channel-goto-button'; 433 | viewOnArenaButton.textContent = 'View Channel on Are.na'; 434 | viewOnArenaButton.addEventListener('click', function() { 435 | window.open(`https://www.are.na/channel/${slug}`, '_blank'); 436 | }); 437 | textInfo.appendChild(viewOnArenaButton); 438 | 439 | if (channelData.image) { 440 | const coverWrapper = document.createElement('div'); 441 | coverWrapper.id = 'channel-cover-wrapper'; 442 | 443 | const cover = document.createElement('img'); 444 | cover.id = 'channel-cover-image'; 445 | cover.src = channelData.image.display.url; 446 | cover.alt = `${channelData.title} channel cover`; 447 | 448 | if (channelData.image.original) { 449 | const originalImg = new Image(); 450 | originalImg.src = channelData.image.original.url; 451 | originalImg.onload = () => { 452 | cover.src = originalImg.src; 453 | }; 454 | } 455 | 456 | coverWrapper.appendChild(cover); 457 | basicInfo.appendChild(coverWrapper); 458 | } 459 | 460 | basicInfo.insertBefore(textInfo, basicInfo.firstChild); 461 | contentWrapper.appendChild(basicInfo); 462 | detailContent.appendChild(contentWrapper); 463 | 464 | arenaLink.href = `https://www.are.na/channel/${slug}`; 465 | 466 | } catch (error) { 467 | console.error('Error fetching channel details:', error); 468 | detailContent.innerHTML = '
Failed to load channel details
'; 469 | } 470 | } 471 | 472 | // More button functionality 473 | const moreButton = document.getElementById('more-button'); 474 | const moreMenu = document.getElementById('more-menu'); 475 | const moreTileButton = document.getElementById('more-tile-button'); 476 | const moreThemeButton = document.getElementById('more-theme-button'); 477 | const moreAboutButton = document.getElementById('more-about-button'); 478 | 479 | // Toggle more menu only when clicking the more button 480 | moreButton.addEventListener('click', (e) => { 481 | e.stopPropagation(); 482 | moreMenu.classList.toggle('show'); 483 | }); 484 | 485 | // Link more menu buttons to original buttons' functionality 486 | moreTileButton.addEventListener('click', () => { 487 | document.getElementById('tile-button').click(); 488 | moreTileButton.textContent = document.getElementById('tile-button').textContent; 489 | }); 490 | 491 | moreThemeButton.addEventListener('click', () => { 492 | document.getElementById('theme-toggle').click(); 493 | moreThemeButton.textContent = document.getElementById('theme-toggle').textContent; 494 | }); 495 | 496 | moreAboutButton.addEventListener('click', () => { 497 | document.getElementById('about-button').click(); 498 | }); 499 | 500 | const savedTheme = localStorage.getItem('theme') || 'system'; 501 | moreThemeButton.textContent = savedTheme; 502 | 503 | // Function to update theme colors for browsers and PWAs status bars and title bars 504 | function updatePWAThemeColors(theme) { 505 | const root = document.documentElement; 506 | let themeColorValue; 507 | 508 | // Get the current effective theme 509 | if (theme === 'system') { 510 | // Check if system is in dark mode 511 | const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; 512 | themeColorValue = isDarkMode ? '#1A1A1A' : '#f0f0f0'; 513 | } else if (theme === 'dark') { 514 | themeColorValue = '#1A1A1A'; // Dark theme header color 515 | } else { 516 | themeColorValue = '#f0f0f0'; // Light theme header color 517 | } 518 | 519 | // Update the theme-color meta tag (works for Chrome, Firefox, and other browsers) 520 | const themeColorMeta = document.getElementById('theme-color-meta'); 521 | if (themeColorMeta) { 522 | themeColorMeta.setAttribute('content', themeColorValue); 523 | } 524 | 525 | // Update the iOS status bar style (for both Safari mobile browser and PWA mode) 526 | const iosStatusBarMeta = document.getElementById('ios-status-bar-meta'); 527 | if (iosStatusBarMeta) { 528 | // For dark theme use black-translucent, for light use default 529 | if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) { 530 | iosStatusBarMeta.setAttribute('content', 'black-translucent'); 531 | } else { 532 | iosStatusBarMeta.setAttribute('content', 'default'); 533 | } 534 | } 535 | 536 | // Force a refresh for Safari on iOS in some cases 537 | // This helps ensure the color changes apply immediately in regular browser mode 538 | if (navigator.userAgent.match(/iPhone|iPad|iPod/i)) { 539 | // Create a small style update to force a repaint 540 | const dummyStyle = document.createElement('style'); 541 | dummyStyle.textContent = '/* */'; 542 | document.head.appendChild(dummyStyle); 543 | setTimeout(() => { 544 | document.head.removeChild(dummyStyle); 545 | }, 10); 546 | } 547 | } 548 | 549 | // Initialize theme colors when page loads 550 | document.addEventListener('DOMContentLoaded', () => { 551 | const savedTheme = localStorage.getItem('theme') || 'system'; 552 | updatePWAThemeColors(savedTheme); 553 | 554 | // Also listen for system color scheme changes 555 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 556 | const currentTheme = document.documentElement.getAttribute('data-theme'); 557 | if (currentTheme === 'system') { 558 | updatePWAThemeColors('system'); 559 | } 560 | }); 561 | }); 562 | --------------------------------------------------------------------------------