├── .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 |
75 |
76 |
79 |
80 |
81 |
82 |
83 |
100 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
Block Limit Reached
111 |
This channel has more than blocks. Loading more may affect performance on your device.
112 |
113 | Load More
114 | Cancel
115 |
116 |
117 |
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 |
106 | Enter a channel slug and click Go to start browsing
107 | Drag content blocks to adjust their position
108 | Use the scroll wheel to rotate content blocks
109 | Double click to view content details
110 | Click channel blocks to jump directly to the corresponding channel
111 | Feel the need to actually connect something? Click the Are.na logo anywhere to view on are.na
112 |
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 |
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 |
--------------------------------------------------------------------------------