tangled
alpha
login
or
join now
timtinkers.online
/
neocities
1
fork
atom
timconspicuous.neocities.org
1
fork
atom
overview
issues
pulls
pipelines
Better indexing, About and Friends tabs
timtinkers.online
6 months ago
dd9dfae7
dec5bb69
1/1
deploy.yaml
success
16s
+192
-229
4 changed files
expand all
collapse all
unified
split
src
index.page.tsx
scripts
reading-progress.js
tarot.js
styles.css
+62
-7
src/index.page.tsx
···
44
44
"Powered by [Lume](https://lume.land), [source code on Tangled](https://tangled.sh/@timtinkers.online/neocities)",
45
45
);
46
46
47
47
-
// Layout to use for this page
47
47
+
// Define the cards that have content - this drives the navigation
48
48
+
export const cardContent = [
49
49
+
{
50
50
+
cardIndex: 0,
51
51
+
slug: "linktree",
52
52
+
component: "Linktree",
53
53
+
props: { links },
54
54
+
},
55
55
+
{
56
56
+
cardIndex: 1,
57
57
+
slug: "progress",
58
58
+
component: "ReadingProgress",
59
59
+
props: {},
60
60
+
},
61
61
+
{
62
62
+
cardIndex: 6,
63
63
+
slug: "friends",
64
64
+
component: "Friends",
65
65
+
props: {},
66
66
+
},
67
67
+
{
68
68
+
cardIndex: 22,
69
69
+
slug: "about",
70
70
+
component: null,
71
71
+
props: {},
72
72
+
},
73
73
+
];
74
74
+
75
75
+
// Make cardContent available to the client-side script
48
76
export const layout = "layout.tsx";
49
77
50
78
export default ({ comp }: Lume.Data) => {
51
79
return (
52
80
<div id="tarot-content">
53
53
-
<div id="card-0" className="card-content">
54
54
-
<comp.Linktree links={links} />
55
55
-
</div>
56
56
-
<div id="card-1" className="card-content">
57
57
-
<comp.ReadingProgress />
58
58
-
</div>
81
81
+
<script
82
82
+
type="application/json"
83
83
+
id="card-content-data"
84
84
+
dangerouslySetInnerHTML={{
85
85
+
__html: JSON.stringify(cardContent),
86
86
+
}}
87
87
+
/>
88
88
+
89
89
+
{cardContent.map((card) => (
90
90
+
<div
91
91
+
key={card.cardIndex}
92
92
+
id={`card-${card.cardIndex}`}
93
93
+
className="card-content"
94
94
+
>
95
95
+
{card.component === "Linktree" && (
96
96
+
<comp.Linktree {...card.props} />
97
97
+
)}
98
98
+
{card.component === "ReadingProgress" && (
99
99
+
<comp.ReadingProgress {...card.props} />
100
100
+
)}
101
101
+
{card.component === "Friends" && <p>
102
102
+
Check out my friends on Neocities:<br></br>
103
103
+
<a href="https://emelee.neocities.org/">emelee</a><br></br>
104
104
+
<a href="https://looueez.neocities.org/">looueez</a><br></br>
105
105
+
<a href="https://halfbloom.neocities.org/">halfbloom</a>
106
106
+
</p>}
107
107
+
{card.component === null && <p>
108
108
+
Made with <a href="https://deno.com/">Deno</a> & <a href="https://lume.land/">Lume</a>.<br></br>
109
109
+
Source code on <a href="https://tangled.sh/@timtinkers.online/neocities">Tangled</a>.<br></br>
110
110
+
Major Arcana art by <a href="https://jcanabal.itch.io/major-arcana-pixel-art-free">jcanabal</a>.
111
111
+
</p>}
112
112
+
</div>
113
113
+
))}
59
114
</div>
60
115
);
61
116
};
-178
src/scripts/reading-progress.js
···
1
1
-
class ReadingProgressWidget {
2
2
-
constructor(containerId) {
3
3
-
const element = document.getElementById(containerId);
4
4
-
if (!element) {
5
5
-
throw new Error(`Element with id "${containerId}" not found`);
6
6
-
}
7
7
-
this.container = element;
8
8
-
this.currentBook = null;
9
9
-
this.init();
10
10
-
}
11
11
-
12
12
-
async init() {
13
13
-
this.showLoading();
14
14
-
try {
15
15
-
const progress = await this.fetchReadingProgress();
16
16
-
const metadata = await this.fetchMetadata(progress.isbn13);
17
17
-
18
18
-
this.currentBook = { ...progress, ...metadata };
19
19
-
this.render();
20
20
-
} catch (error) {
21
21
-
this.showError(
22
22
-
error instanceof Error ? error.message : "Unknown error",
23
23
-
);
24
24
-
}
25
25
-
}
26
26
-
27
27
-
async fetchReadingProgress() {
28
28
-
const response = await fetch(
29
29
-
"https://pds.timtinkers.online/xrpc/com.atproto.repo.listRecords?repo=did%3Aplc%3Ao6xucog6fghiyrvp7pyqxcs3&collection=social.popfeed.feed.listItem",
30
30
-
);
31
31
-
32
32
-
if (!response.ok) {
33
33
-
throw new Error(`API request failed: ${response.status}`);
34
34
-
}
35
35
-
36
36
-
const data = await response.json();
37
37
-
38
38
-
// Filter for entries with bookProgress field
39
39
-
const booksWithProgress = data.records.filter(
40
40
-
(record) =>
41
41
-
record.value.bookProgress &&
42
42
-
record.value.bookProgress.updatedAt,
43
43
-
);
44
44
-
45
45
-
if (booksWithProgress.length === 0) {
46
46
-
this.currentBook = null;
47
47
-
return;
48
48
-
}
49
49
-
50
50
-
// Find the most recently updated book
51
51
-
const mostRecent = booksWithProgress.reduce((latest, current) => {
52
52
-
const latestDate = new Date(latest.value.updatedAt);
53
53
-
const currentDate = new Date(current.value.updatedAt);
54
54
-
return currentDate > latestDate ? current : latest;
55
55
-
});
56
56
-
57
57
-
return {
58
58
-
isbn13: mostRecent.value.identifiers.isbn13,
59
59
-
progress: mostRecent.value.bookProgress.percent,
60
60
-
updatedAt: mostRecent.value.bookProgress.updatedAt,
61
61
-
totalPages: mostRecent.value.bookProgress.totalPages,
62
62
-
currentPage: mostRecent.value.bookProgress.currentPage,
63
63
-
};
64
64
-
}
65
65
-
66
66
-
async fetchMetadata(isbn13) {
67
67
-
const response = await fetch(
68
68
-
`https://openlibrary.org/api/books?bibkeys=ISBN:${isbn13}&format=json&jscmd=data`,
69
69
-
);
70
70
-
71
71
-
if (!response.ok) {
72
72
-
throw new Error(`API request failed: ${response.status}`);
73
73
-
}
74
74
-
75
75
-
const data = await response.json();
76
76
-
const metadata = Object.values(data)[0];
77
77
-
78
78
-
return {
79
79
-
title: metadata.title,
80
80
-
author: metadata.authors[0]?.name,
81
81
-
coverUrl: metadata.cover?.medium,
82
82
-
};
83
83
-
}
84
84
-
85
85
-
showLoading() {
86
86
-
this.container.innerHTML = `
87
87
-
<div class="reading-progress-container reading-progress-loading">
88
88
-
<div class="progress-skeleton">
89
89
-
<div class="skeleton-text"></div>
90
90
-
<div class="skeleton-bar"></div>
91
91
-
</div>
92
92
-
</div>
93
93
-
`;
94
94
-
}
95
95
-
96
96
-
showError(message) {
97
97
-
this.container.innerHTML = `
98
98
-
<div class="reading-progress-container reading-progress-error">
99
99
-
<p>📚 Unable to load current reading progress</p>
100
100
-
<small>${message}</small>
101
101
-
</div>
102
102
-
`;
103
103
-
}
104
104
-
105
105
-
formatDate(dateString) {
106
106
-
return new Date(dateString).toLocaleDateString("en-US", {
107
107
-
month: "short",
108
108
-
day: "numeric",
109
109
-
year: "numeric",
110
110
-
});
111
111
-
}
112
112
-
113
113
-
render() {
114
114
-
if (!this.currentBook) {
115
115
-
this.container.innerHTML = `
116
116
-
<div class="reading-progress-container reading-progress-empty">
117
117
-
<p>📚 No books currently in progress</p>
118
118
-
</div>
119
119
-
`;
120
120
-
return;
121
121
-
}
122
122
-
123
123
-
const coverImage = this.currentBook.coverUrl
124
124
-
? `<img src="${this.currentBook.coverUrl}" alt="Book cover for ${
125
125
-
this.escapeHtml(this.currentBook.title)
126
126
-
}" class="book-cover" />`
127
127
-
: '<div class="book-cover-placeholder">📖</div>';
128
128
-
129
129
-
this.container.innerHTML = `
130
130
-
<div class="reading-progress-container">
131
131
-
<div class="reading-progress-header">
132
132
-
<span>📚</span> Currently Reading
133
133
-
</div>
134
134
-
<div class="book-info">
135
135
-
${coverImage}
136
136
-
<div class="book-details">
137
137
-
<div class="book-title">
138
138
-
${this.escapeHtml(this.currentBook.title)}
139
139
-
</div>
140
140
-
<div class="book-author">
141
141
-
by ${this.escapeHtml(this.currentBook.author)}
142
142
-
</div>
143
143
-
<div class="book-meta">
144
144
-
<span class="progress-badge">In progress</span>
145
145
-
<span class="last-updated">
146
146
-
Updated ${this.formatDate(this.currentBook.updatedAt)}
147
147
-
</span>
148
148
-
</div>
149
149
-
</div>
150
150
-
</div>
151
151
-
<div class="progress-container">
152
152
-
<div class="progress-bar">
153
153
-
<div class="progress-fill" style="width: ${this.currentBook.progress}%"></div>
154
154
-
</div>
155
155
-
<div class="progress-details">
156
156
-
<span class="progress-percent">${this.currentBook.progress}%</span>
157
157
-
<span class="progress-pages">${this.currentBook.currentPage} / ${this.currentBook.totalPages} pages</span>
158
158
-
</div>
159
159
-
</div>
160
160
-
</div>
161
161
-
`;
162
162
-
}
163
163
-
escapeHtml(text) {
164
164
-
const div = document.createElement("div");
165
165
-
div.textContent = text;
166
166
-
return div.innerHTML;
167
167
-
}
168
168
-
}
169
169
-
170
170
-
// Auto-initialize when DOM is loaded
171
171
-
document.addEventListener("DOMContentLoaded", () => {
172
172
-
const widgets = document.querySelectorAll("[data-reading-progress]");
173
173
-
widgets.forEach((widget) => {
174
174
-
if (widget.id) {
175
175
-
new ReadingProgressWidget(widget.id);
176
176
-
}
177
177
-
});
178
178
-
});
+117
-44
src/scripts/tarot.js
···
1
1
class Tarot {
2
2
constructor() {
3
3
-
this.currentCard = 0;
4
4
-
this.totalCards = 22; // 0-21 for Major Arcana
3
3
+
this.currentCardIndex = 0;
5
4
this.isTransitioning = false;
6
5
this.isFirstLoad = true;
7
6
8
8
-
// Major Arcana card names and roman numerals
9
9
-
this.cardData = [
7
7
+
// Full Major Arcana + back card data
8
8
+
this.allCardData = [
10
9
{ id: "0", name: "The Fool" },
11
10
{ id: "I", name: "The Magician" },
12
11
{ id: "II", name: "The High Priestess" },
···
29
28
{ id: "XIX", name: "The Sun" },
30
29
{ id: "XX", name: "Judgement" },
31
30
{ id: "XXI", name: "The World" },
31
31
+
32
32
+
{ id: "back", name: "Return" },
32
33
];
33
34
35
35
+
// Available cards (loaded from page data)
36
36
+
this.availableCards = [];
37
37
+
this.loadAvailableCards();
38
38
+
34
39
this.init();
35
40
}
36
41
42
42
+
loadAvailableCards() {
43
43
+
const cardContentData = document.getElementById("card-content-data");
44
44
+
if (!cardContentData) {
45
45
+
console.error("Card content data not found");
46
46
+
return;
47
47
+
}
48
48
+
49
49
+
try {
50
50
+
const cardContent = JSON.parse(cardContentData.textContent);
51
51
+
52
52
+
// Map card content to available cards with full data
53
53
+
this.availableCards = cardContent.map((card) => {
54
54
+
const tarotCard = this.allCardData[card.cardIndex] || {
55
55
+
id: card.cardIndex.toString(),
56
56
+
name: "Unknown Card",
57
57
+
};
58
58
+
59
59
+
return {
60
60
+
...tarotCard,
61
61
+
cardIndex: card.cardIndex,
62
62
+
slug: card.slug,
63
63
+
component: card.component,
64
64
+
};
65
65
+
});
66
66
+
67
67
+
console.log("Available cards:", this.availableCards);
68
68
+
} catch (error) {
69
69
+
console.error("Error parsing card content data:", error);
70
70
+
}
71
71
+
}
72
72
+
37
73
init() {
38
74
this.setupEventListeners();
39
75
this.setupTouchEvents();
···
88
124
() => this.nextCard(),
89
125
);
90
126
127
127
+
// Make card indicator clickable to return to first card (card 0)
128
128
+
document.getElementById("card-indicator")?.addEventListener(
129
129
+
"click",
130
130
+
() => this.goToFirstCard(),
131
131
+
);
132
132
+
91
133
// Keyboard navigation
92
134
document.addEventListener("keydown", (e) => {
93
135
if (e.key === "ArrowLeft") {
···
96
138
} else if (e.key === "ArrowRight") {
97
139
e.preventDefault();
98
140
this.nextCard();
141
141
+
} else if (e.key === "Home") {
142
142
+
e.preventDefault();
143
143
+
this.goToFirstCard();
99
144
}
100
145
});
101
146
···
138
183
loadFromHash() {
139
184
const hash = globalThis.location.hash.slice(1);
140
185
if (!hash) {
141
141
-
this.currentCard = 0;
186
186
+
this.currentCardIndex = 0;
142
187
this.updateHash();
143
188
return;
144
189
}
145
190
146
146
-
// Find card index
147
147
-
const cardIndex = this.cardData.findIndex((card) => card.id === hash);
191
191
+
// Try to find by card ID first, then by slug
192
192
+
let foundIndex = this.availableCards.findIndex((card) =>
193
193
+
card.id === hash || card.slug === hash
194
194
+
);
148
195
149
149
-
if (cardIndex !== -1) {
150
150
-
this.currentCard = cardIndex;
196
196
+
// If not found, try parsing as card index
197
197
+
if (foundIndex === -1) {
198
198
+
const cardIndex = parseInt(hash);
199
199
+
if (!isNaN(cardIndex)) {
200
200
+
foundIndex = this.availableCards.findIndex((card) =>
201
201
+
card.cardIndex === cardIndex
202
202
+
);
203
203
+
}
204
204
+
}
205
205
+
206
206
+
if (foundIndex !== -1) {
207
207
+
this.currentCardIndex = foundIndex;
151
208
} else {
152
152
-
// Try parsing as number
153
153
-
const num = parseInt(hash);
154
154
-
if (!isNaN(num) && num >= 0 && num < this.totalCards) {
155
155
-
this.currentCard = num;
156
156
-
}
209
209
+
// Default to first card if hash doesn't match anything
210
210
+
this.currentCardIndex = 0;
157
211
}
158
212
159
213
this.updateImage();
···
161
215
}
162
216
163
217
updateHash() {
164
164
-
const card = this.cardData[this.currentCard];
165
165
-
globalThis.location.hash = card.id;
218
218
+
const currentCard = this.availableCards[this.currentCardIndex];
219
219
+
if (currentCard) {
220
220
+
// Prefer slug over card ID for URL
221
221
+
globalThis.location.hash = currentCard.id || currentCard.slug;
222
222
+
}
223
223
+
}
224
224
+
225
225
+
async goToFirstCard() {
226
226
+
if (this.isTransitioning) return;
227
227
+
228
228
+
// Find the first card (card index 0)
229
229
+
const firstCardIndex = this.availableCards.findIndex((card) =>
230
230
+
card.cardIndex === 0
231
231
+
);
232
232
+
if (firstCardIndex !== -1 && firstCardIndex !== this.currentCardIndex) {
233
233
+
const direction = firstCardIndex < this.currentCardIndex
234
234
+
? "right"
235
235
+
: "left";
236
236
+
this.currentCardIndex = firstCardIndex;
237
237
+
await this.transitionToCard(direction);
238
238
+
}
166
239
}
167
240
168
241
async previousCard() {
169
242
if (this.isTransitioning) return;
170
243
171
171
-
this.currentCard = this.currentCard > 0
172
172
-
? this.currentCard - 1
173
173
-
: this.totalCards - 1;
244
244
+
this.currentCardIndex = this.currentCardIndex > 0
245
245
+
? this.currentCardIndex - 1
246
246
+
: this.availableCards.length - 1;
174
247
await this.transitionToCard("right");
175
248
}
176
249
177
250
async nextCard() {
178
251
if (this.isTransitioning) return;
179
252
180
180
-
this.currentCard = this.currentCard < this.totalCards - 1
181
181
-
? this.currentCard + 1
182
182
-
: 0;
253
253
+
this.currentCardIndex =
254
254
+
this.currentCardIndex < this.availableCards.length - 1
255
255
+
? this.currentCardIndex + 1
256
256
+
: 0;
183
257
await this.transitionToCard("left");
184
258
}
185
259
···
270
344
271
345
updateImage() {
272
346
const cardImage = document.getElementById("card-image");
273
273
-
274
347
if (!cardImage) return;
275
348
349
349
+
const currentCard = this.availableCards[this.currentCardIndex];
350
350
+
if (!currentCard) return;
351
351
+
352
352
+
// Handle special case for back card
353
353
+
if (currentCard.cardIndex === 22) {
354
354
+
cardImage.src = "/images/tarot/back.png";
355
355
+
cardImage.alt = "Card Back";
356
356
+
return;
357
357
+
}
358
358
+
276
359
// Set image source for current card (using PNG extension for pixel art)
277
277
-
const cardName = this.cardData[this.currentCard].name.toLowerCase()
278
278
-
.replace(/\s+/g, "-");
279
279
-
const newSrc = `/images/tarot/${this.currentCard}-${cardName}.png`;
360
360
+
const cardName = currentCard.name.toLowerCase().replace(/\s+/g, "-");
361
361
+
const newSrc = `/images/tarot/${currentCard.cardIndex}-${cardName}.png`;
280
362
281
363
// Update image source and alt text
282
364
cardImage.src = newSrc;
283
283
-
cardImage.alt = this.cardData[this.currentCard].name;
365
365
+
cardImage.alt = currentCard.name;
284
366
}
285
367
286
368
updateCardIndicator() {
287
369
const indicator = document.getElementById("current-card");
288
370
if (indicator) {
289
289
-
indicator.textContent = this.cardData[this.currentCard].id;
371
371
+
const currentCard = this.availableCards[this.currentCardIndex];
372
372
+
if (currentCard) {
373
373
+
indicator.textContent = currentCard.id;
374
374
+
}
290
375
}
291
376
}
292
377
···
295
380
document.querySelectorAll(".card-content").forEach((card) => {
296
381
card.classList.remove("active");
297
382
});
298
298
-
299
299
-
const currentCard = this.cardData[this.currentCard];
383
383
+
384
384
+
const currentCard = this.availableCards[this.currentCardIndex];
385
385
+
if (!currentCard) return;
300
386
301
387
// Update the content title
302
388
const contentTitle = document.getElementById("content-title");
···
306
392
307
393
// Show the current card's content
308
394
const currentCardContent = document.getElementById(
309
309
-
`card-${this.currentCard}`,
395
395
+
`card-${currentCard.cardIndex}`,
310
396
);
311
397
312
398
if (currentCardContent) {
313
399
currentCardContent.classList.add("active");
314
314
-
} else {
315
315
-
// Fallback content if card doesn't exist yet
316
316
-
const contentWrapper = document.getElementById("content-wrapper");
317
317
-
if (contentWrapper) {
318
318
-
// Create a new content element with the proper ID
319
319
-
const newContent = document.createElement("div");
320
320
-
newContent.id = `card-${this.currentCard}`;
321
321
-
newContent.className = "card-content active";
322
322
-
newContent.innerHTML = `
323
323
-
<p>Coming soon...</p>
324
324
-
`;
325
325
-
contentWrapper.appendChild(newContent);
326
326
-
}
327
400
}
328
401
}
329
402
+13
src/styles.css
···
201
201
font-weight: bold;
202
202
min-width: 60px;
203
203
text-align: center;
204
204
+
cursor: pointer;
205
205
+
transition: all 0.3s ease;
206
206
+
user-select: none;
207
207
+
}
208
208
+
209
209
+
.card-indicator:hover {
210
210
+
background: rgba(48, 37, 75, 0.2);
211
211
+
border-color: rgba(75, 57, 118, 0.6);
212
212
+
transform: scale(1.05);
213
213
+
}
214
214
+
215
215
+
.card-indicator:active {
216
216
+
transform: scale(0.95);
204
217
}
205
218
206
219
/* Responsive design */