v3: follows, notifications, invite-only registration, unread markers
This commit is contained in:
679
src/App.css
679
src/App.css
@@ -13,8 +13,12 @@
|
||||
.md a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.md strong { font-weight: 700; }
|
||||
.md em { font-style: italic; }
|
||||
.md strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
.md em {
|
||||
font-style: italic;
|
||||
}
|
||||
.md code {
|
||||
font-family: monospace;
|
||||
background: var(--color-bg);
|
||||
@@ -40,15 +44,16 @@
|
||||
padding-left: 1.5em;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
.md li { margin: 0.15em 0; }
|
||||
.md li {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
.md blockquote {
|
||||
border-left: 3px solid var(--color-border);
|
||||
margin: 0.5em 0;
|
||||
padding: 0.2em 0.75em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.md h1, .md h2, .md h3,
|
||||
.md h4, .md h5, .md h6 {
|
||||
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 {
|
||||
margin: 0.6em 0 0.2em;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
@@ -62,9 +67,13 @@
|
||||
.md--inline blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
.md--inline li { margin: 0; }
|
||||
.md--inline li {
|
||||
margin: 0;
|
||||
}
|
||||
.md--inline ul,
|
||||
.md--inline ol { padding-left: 1.2em; }
|
||||
.md--inline ol {
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
/* ── Dump detail page ── */
|
||||
.dump-detail {
|
||||
@@ -101,15 +110,6 @@
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.dump-header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.dump-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
@@ -631,32 +631,6 @@
|
||||
background: var(--color-soundcloud);
|
||||
}
|
||||
|
||||
.rich-content-embed {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.rich-content-embed iframe {
|
||||
width: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
.embed-youtube {
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
.embed-youtube iframe {
|
||||
height: 100%;
|
||||
}
|
||||
.embed-soundcloud {
|
||||
height: 166px;
|
||||
}
|
||||
.embed-bandcamp {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
/* ── Global persistent player ── */
|
||||
.global-player {
|
||||
position: fixed;
|
||||
@@ -862,14 +836,6 @@ body.has-player .fab-new {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Online users ── */
|
||||
.online-users {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--color-surface);
|
||||
@@ -1087,19 +1053,6 @@ body.has-player .fab-new {
|
||||
}
|
||||
|
||||
/* ── Profile (own) page ── */
|
||||
.profile-avatar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.profile-avatar-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
@@ -1110,6 +1063,132 @@ body.has-player .fab-new {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-header .follow-btn {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-invited-by {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.15rem 0 0.4rem;
|
||||
}
|
||||
.profile-invited-by--founding {
|
||||
font-style: italic;
|
||||
}
|
||||
.profile-invited-by-link {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.profile-invited-by-link:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.profile-own-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.profile-own-actions .logout-btn {
|
||||
margin-top: 0;
|
||||
}
|
||||
.invite-generate {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.invite-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
border: 1.5px solid var(--color-accent);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.invite-btn:hover {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.invite-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.5rem 0.3rem 0.75rem;
|
||||
max-width: 480px;
|
||||
}
|
||||
.invite-url {
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
color: var(--color-text-muted);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.invite-copy-btn {
|
||||
padding: 0.2rem 0.65rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.invite-copy-btn:hover {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── Profile sub-pages (dumps / upvoted / playlists) ── */
|
||||
.profile-subpage-back {
|
||||
display: inline-block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.profile-subpage-back:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.profile-subpage-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.profile-subpage-title {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Profile "View all" link ── */
|
||||
.profile-view-all {
|
||||
display: inline-block;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.profile-view-all:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
@@ -1126,23 +1205,6 @@ body.has-player .fab-new {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.avatar-upload-label {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 2px solid var(--color-accent);
|
||||
border-radius: 6px;
|
||||
color: var(--color-accent);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.avatar-upload-label:hover {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--color-danger);
|
||||
margin: 0;
|
||||
@@ -1601,10 +1663,6 @@ body.has-player .fab-new {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.feed-header {
|
||||
padding: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.feed-sort-btn {
|
||||
padding: 0.25rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
@@ -1751,6 +1809,20 @@ body.has-player .fab-new {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── Unread dot ── */
|
||||
.unread-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
vertical-align: baseline;
|
||||
position: relative;
|
||||
top: -0.15em;
|
||||
margin-right: 0.4em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Shared card description / comment ── */
|
||||
.dump-card-comment,
|
||||
.playlist-card-description {
|
||||
@@ -1833,6 +1905,14 @@ body.has-player .fab-new {
|
||||
.playlist-card-count {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.playlist-card-owner {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.playlist-card-owner:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── Playlist card delete button ── */
|
||||
.playlist-card-delete-btn {
|
||||
@@ -2060,8 +2140,20 @@ body.has-player .fab-new {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.playlist-detail-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.playlist-detail-title-row .playlist-edit-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playlist-detail-title {
|
||||
margin: 0 0 0.25rem;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
word-break: break-word;
|
||||
@@ -2080,6 +2172,15 @@ body.has-player .fab-new {
|
||||
font-size: 0.82rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.playlist-detail-owner {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.playlist-detail-owner:hover {
|
||||
color: var(--color-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Playlist header inline edit ── */
|
||||
.playlist-detail-content {
|
||||
@@ -2090,14 +2191,6 @@ body.has-player .fab-new {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.playlist-header-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.playlist-edit-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
@@ -2276,73 +2369,6 @@ body.has-player .fab-new {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ── Public/Private toggle ── */
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 2.4rem;
|
||||
height: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--color-text-muted);
|
||||
transition: background 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-thumb::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0.15rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: left 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-thumb {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-thumb::after {
|
||||
left: calc(100% - 1.15rem);
|
||||
}
|
||||
|
||||
/* ── Dump card comment count ── */
|
||||
.dump-card-comment-count {
|
||||
font-size: 0.72rem;
|
||||
@@ -2401,7 +2427,11 @@ body.has-player .fab-new {
|
||||
}
|
||||
|
||||
.comment-node-inner:hover {
|
||||
background: color-mix(in srgb, var(--color-surface) 80%, var(--color-accent) 20%);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-surface) 80%,
|
||||
var(--color-accent) 20%
|
||||
);
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
@@ -2477,7 +2507,8 @@ body.has-player .fab-new {
|
||||
padding-left: 1.25rem;
|
||||
margin-left: 1.1rem;
|
||||
margin-top: 0.35rem;
|
||||
border-left: 2px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
border-left: 2px solid
|
||||
color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
@@ -2516,7 +2547,8 @@ body.has-player .fab-new {
|
||||
.comment-reply-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.comment-form-actions {
|
||||
@@ -2574,3 +2606,324 @@ body.has-player .fab-new {
|
||||
margin: 0;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
/* ── Follow button ── */
|
||||
.follow-btn {
|
||||
padding: 0.25rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--color-accent);
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.follow-btn:hover {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent, #fff);
|
||||
}
|
||||
|
||||
.follow-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.follow-btn--following {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent, #fff);
|
||||
}
|
||||
|
||||
.follow-btn--following:hover {
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── Followed feed layout ── */
|
||||
.followed-feed {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.followed-sub-nav {
|
||||
padding: 0.75rem 1.25rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.followed-feed .dump-feed {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.followed-feed .index-status {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Notification bell ── */
|
||||
@keyframes bell-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
12% {
|
||||
transform: rotate(-20deg);
|
||||
}
|
||||
28% {
|
||||
transform: rotate(20deg);
|
||||
}
|
||||
42% {
|
||||
transform: rotate(-14deg);
|
||||
}
|
||||
56% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
70% {
|
||||
transform: rotate(-6deg);
|
||||
}
|
||||
84% {
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
background: var(--color-header-user-bg);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.notification-bell:hover {
|
||||
background: var(--color-header-user-bg-hover);
|
||||
}
|
||||
.notification-bell-icon {
|
||||
display: inline-block;
|
||||
transform-origin: 50% 10%;
|
||||
}
|
||||
.notification-bell--ringing .notification-bell-icon {
|
||||
animation: bell-ring 0.65s cubic-bezier(0.36, 0.07, 0.19, 0.97);
|
||||
}
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 3px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 0 2px var(--color-bg);
|
||||
}
|
||||
|
||||
/* ── Notifications page ── */
|
||||
.notifications-page {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 3rem;
|
||||
}
|
||||
.notifications-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.notifications-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.notifications-title-bell {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.notifications-unread-pill {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
|
||||
color: var(--color-danger);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent);
|
||||
border-radius: 12px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notifications-empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.notifications-empty-icon {
|
||||
font-size: 2.5rem;
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.notifications-empty p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.notifications-empty-hint {
|
||||
font-size: 0.85rem;
|
||||
max-width: 340px;
|
||||
margin: 0.5rem auto 0 !important;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notif-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.notif-group-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.notif-group-label::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
.notification-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--color-surface);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.notification-item:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-surface) 80%,
|
||||
var(--color-text) 8%
|
||||
);
|
||||
}
|
||||
.notification-item--unread {
|
||||
border-left-color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface));
|
||||
}
|
||||
.notification-item--unread:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, var(--color-surface));
|
||||
}
|
||||
.notif-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
box-shadow: 0 0 5px color-mix(in srgb, var(--color-accent) 70%, transparent);
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
.notif-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notif-icon--upvote {
|
||||
background: color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
color: #f59e0b;
|
||||
}
|
||||
.notif-icon--follow {
|
||||
background: color-mix(in srgb, #8b5cf6 30%, transparent);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
.notif-icon--dump {
|
||||
background: color-mix(in srgb, #3b82f6 30%, transparent);
|
||||
color: #3b82f6;
|
||||
}
|
||||
.notif-icon--playlist {
|
||||
background: color-mix(in srgb, #10b981 30%, transparent);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
min-width: 0;
|
||||
}
|
||||
.notification-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notif-link {
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.notif-link:hover {
|
||||
color: var(--color-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.load-more-btn {
|
||||
display: block;
|
||||
margin: 1.5rem auto 0;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.load-more-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.load-more-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
94
src/App.tsx
94
src/App.tsx
@@ -8,12 +8,16 @@ import { DumpEdit } from "./pages/DumpEdit.tsx";
|
||||
import { UserLogin } from "./pages/UserLogin.tsx";
|
||||
import { UserPublicProfile } from "./pages/UserPublicProfile.tsx";
|
||||
import { UserRegister } from "./pages/UserRegister.tsx";
|
||||
import { UserDumps } from "./pages/UserDumps.tsx";
|
||||
import { UserUpvoted } from "./pages/UserUpvoted.tsx";
|
||||
import { UserPlaylists } from "./pages/UserPlaylists.tsx";
|
||||
import { PlaylistDetail } from "./pages/PlaylistDetail.tsx";
|
||||
import { MyPlaylists } from "./pages/MyPlaylists.tsx";
|
||||
import { Notifications } from "./pages/Notifications.tsx";
|
||||
|
||||
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
||||
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
|
||||
import { WSProvider } from "./contexts/WSProvider.tsx";
|
||||
import { FollowProvider } from "./contexts/FollowProvider.tsx";
|
||||
import { useAuth } from "./hooks/useAuth.ts";
|
||||
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
||||
|
||||
@@ -23,46 +27,54 @@ function AppRoutes() {
|
||||
const { token } = useAuth();
|
||||
return (
|
||||
<WSProvider token={token}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/dumps/:selectedDump" element={<Dump />} />
|
||||
<Route
|
||||
path="/dumps/:selectedDump/edit"
|
||||
element={
|
||||
<RestrictedLoggedIn>
|
||||
<DumpEdit />
|
||||
</RestrictedLoggedIn>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<RestrictedGuest>
|
||||
<UserRegister />
|
||||
</RestrictedGuest>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<RestrictedGuest>
|
||||
<UserLogin />
|
||||
</RestrictedGuest>
|
||||
}
|
||||
/>
|
||||
<Route path="/users/:username" element={<UserPublicProfile />} />
|
||||
<Route
|
||||
path="/playlists"
|
||||
element={
|
||||
<RestrictedLoggedIn>
|
||||
<MyPlaylists />
|
||||
</RestrictedLoggedIn>
|
||||
}
|
||||
/>
|
||||
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<FollowProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/dumps/:selectedDump" element={<Dump />} />
|
||||
<Route
|
||||
path="/dumps/:selectedDump/edit"
|
||||
element={
|
||||
<RestrictedLoggedIn>
|
||||
<DumpEdit />
|
||||
</RestrictedLoggedIn>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<RestrictedGuest>
|
||||
<UserRegister />
|
||||
</RestrictedGuest>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<RestrictedGuest>
|
||||
<UserLogin />
|
||||
</RestrictedGuest>
|
||||
}
|
||||
/>
|
||||
<Route path="/users/:username" element={<UserPublicProfile />} />
|
||||
<Route path="/users/:username/dumps" element={<UserDumps />} />
|
||||
<Route path="/users/:username/upvoted" element={<UserUpvoted />} />
|
||||
<Route
|
||||
path="/users/:username/playlists"
|
||||
element={<UserPlaylists />}
|
||||
/>
|
||||
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
<RestrictedLoggedIn>
|
||||
<Notifications />
|
||||
</RestrictedLoggedIn>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</FollowProvider>
|
||||
</WSProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import type {
|
||||
PlaylistMembership,
|
||||
RawPlaylistMembership,
|
||||
} from "../model.ts";
|
||||
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
|
||||
import { deserializePlaylistMembership } from "../model.ts";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
||||
import { NotificationBell } from "./NotificationBell.tsx";
|
||||
|
||||
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const [showFab, setShowFab] = useState(false);
|
||||
const [_showFab, setShowFab] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,7 +28,9 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
||||
ref={headerRef}
|
||||
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
|
||||
>
|
||||
<Link to="/" className="app-header-brand">🚚 gerbeur</Link>
|
||||
<Link to="/" state={{ tab: "hot" }} className="app-header-brand">
|
||||
🚚 gerbeur
|
||||
</Link>
|
||||
|
||||
{centerSlot && <div className="app-header-center">{centerSlot}</div>}
|
||||
|
||||
@@ -42,9 +44,13 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
||||
>
|
||||
{user.username}
|
||||
</Link>
|
||||
<Link to="/playlists" className="app-header-user">
|
||||
<Link
|
||||
to={`/users/${user.username}/playlists`}
|
||||
className="app-header-user"
|
||||
>
|
||||
Playlists
|
||||
</Link>
|
||||
<NotificationBell />
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
@@ -71,7 +77,8 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* {user && createPortal(
|
||||
{
|
||||
/* {user && createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
|
||||
@@ -81,7 +88,8 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
||||
+ New
|
||||
</button>,
|
||||
document.body,
|
||||
)} */}
|
||||
)} */
|
||||
}
|
||||
|
||||
{createModalOpen && (
|
||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||
|
||||
@@ -105,7 +105,10 @@ function CommentNode({
|
||||
<li className="comment-node">
|
||||
<div className="comment-node-inner comment-node-inner--deleted">
|
||||
<div className="comment-avatar comment-avatar--deleted">
|
||||
<div className="comment-avatar-placeholder" style={{ width: 28, height: 28 }} />
|
||||
<div
|
||||
className="comment-avatar-placeholder"
|
||||
style={{ width: 28, height: 28 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="comment-content">
|
||||
<p className="comment-deleted-placeholder">[deleted]</p>
|
||||
@@ -194,14 +197,14 @@ function CommentNode({
|
||||
value={replyBody}
|
||||
onChange={(e) => setReplyBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReply(e);
|
||||
if (
|
||||
e.key === "Enter" && (e.ctrlKey || e.metaKey)
|
||||
) handleReply(e);
|
||||
}}
|
||||
placeholder="Write a reply…"
|
||||
rows={3}
|
||||
/>
|
||||
{replyError && (
|
||||
<p className="comment-form-error">{replyError}</p>
|
||||
)}
|
||||
{replyError && <p className="comment-form-error">{replyError}</p>}
|
||||
<div className="comment-form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -229,9 +232,7 @@ function CommentNode({
|
||||
{children.length > 0 && (
|
||||
<ul
|
||||
className="comment-replies"
|
||||
style={depth >= MAX_INDENT_DEPTH
|
||||
? { paddingLeft: 0 }
|
||||
: undefined}
|
||||
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
|
||||
>
|
||||
{children.map((child) => (
|
||||
<CommentNode
|
||||
@@ -305,13 +306,18 @@ export function CommentThread({
|
||||
</h2>
|
||||
|
||||
{currentUser && (
|
||||
<form className="comment-form comment-top-form" onSubmit={handleTopLevelSubmit}>
|
||||
<form
|
||||
className="comment-form comment-top-form"
|
||||
onSubmit={handleTopLevelSubmit}
|
||||
>
|
||||
<textarea
|
||||
className="comment-reply-textarea"
|
||||
value={topLevelBody}
|
||||
onChange={(e) => setTopLevelBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleTopLevelSubmit(e);
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
handleTopLevelSubmit(e);
|
||||
}
|
||||
}}
|
||||
placeholder="Add a comment…"
|
||||
rows={3}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import type { Dump } from "../model.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||
import FilePreview from "./FilePreview.tsx";
|
||||
import RichContentCard from "./RichContentCard.tsx";
|
||||
import { VoteButton } from "./VoteButton.tsx";
|
||||
@@ -22,12 +23,19 @@ export function DumpCard(
|
||||
DumpCardProps,
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||
!isDumpVisited(dump.id);
|
||||
|
||||
function handleNavigate() {
|
||||
markDumpVisited(dump.id);
|
||||
navigate(`/dumps/${dump.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={`dump-card${className ? ` ${className}` : ""}`}>
|
||||
<div
|
||||
className="dump-card-inner"
|
||||
onClick={() => navigate(`/dumps/${dump.id}`)}
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<div
|
||||
className="dump-card-preview"
|
||||
@@ -44,12 +52,18 @@ export function DumpCard(
|
||||
<Link
|
||||
to={`/dumps/${dump.id}`}
|
||||
className="dump-card-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markDumpVisited(dump.id);
|
||||
}}
|
||||
>
|
||||
{unread && <span className="unread-dot" aria-hidden="true" />}
|
||||
{dump.title}
|
||||
</Link>
|
||||
{dump.comment && (
|
||||
<Markdown className="dump-card-comment" inline>{dump.comment}</Markdown>
|
||||
<Markdown className="dump-card-comment" inline>
|
||||
{dump.comment}
|
||||
</Markdown>
|
||||
)}
|
||||
<div className="dump-card-meta">
|
||||
<time
|
||||
@@ -61,7 +75,8 @@ export function DumpCard(
|
||||
</time>
|
||||
{dump.commentCount > 0 && (
|
||||
<span className="dump-card-comment-count">
|
||||
{dump.commentCount} {dump.commentCount === 1 ? "comment" : "comments"}
|
||||
{dump.commentCount}{" "}
|
||||
{dump.commentCount === 1 ? "comment" : "comments"}
|
||||
</span>
|
||||
)}
|
||||
{dump.isPrivate && isOwner && (
|
||||
|
||||
@@ -10,11 +10,9 @@ import type {
|
||||
RawDump,
|
||||
RawPlaylistMembership,
|
||||
} from "../model.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
deserializePlaylistMembership,
|
||||
} from "../model.ts";
|
||||
import { deserializeDump, deserializePlaylistMembership } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
import RichContentCard from "./RichContentCard.tsx";
|
||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||
@@ -74,6 +72,7 @@ interface DumpCreateModalProps {
|
||||
|
||||
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
const { authFetch } = useAuth();
|
||||
const { injectDump } = useWS();
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [phase, setPhase] = useState<Phase>("create");
|
||||
@@ -225,6 +224,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
const apiResponse = await res.json();
|
||||
if (apiResponse.success) {
|
||||
const dump = deserializeDump(apiResponse.data as RawDump);
|
||||
injectDump(dump);
|
||||
setCreatedDump(dump);
|
||||
setPhase("playlist");
|
||||
setPlaylistsLoading(true);
|
||||
@@ -281,7 +281,6 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const submitting = submitState.status === "submitting";
|
||||
|
||||
return createPortal(
|
||||
@@ -376,7 +375,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
)}
|
||||
{urlPreview.status === "done" &&
|
||||
urlPreview.richContent && (
|
||||
<RichContentCard richContent={urlPreview.richContent} />
|
||||
<RichContentCard
|
||||
richContent={urlPreview.richContent}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -411,21 +412,24 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="toggle-row">
|
||||
<span className="toggle-label">Public</span>
|
||||
<span className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isPrivate}
|
||||
onChange={(e) => setIsPrivate(!e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
{isPrivate && (
|
||||
<span className="toggle-hint">Only visible to you</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="dump-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={!isPrivate ? "active" : ""}
|
||||
disabled={submitting}
|
||||
onClick={() => setIsPrivate(false)}
|
||||
>
|
||||
Public
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isPrivate ? "active" : ""}
|
||||
disabled={submitting}
|
||||
onClick={() => setIsPrivate(true)}
|
||||
>
|
||||
Private
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
|
||||
65
src/components/FollowButton.tsx
Normal file
65
src/components/FollowButton.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useFollows } from "../hooks/useFollows.ts";
|
||||
|
||||
interface FollowUserButtonProps {
|
||||
targetUserId: string;
|
||||
targetUsername: string;
|
||||
}
|
||||
|
||||
interface FollowPlaylistButtonProps {
|
||||
targetPlaylistId: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export function FollowUserButton(
|
||||
{ targetUserId, targetUsername }: FollowUserButtonProps,
|
||||
) {
|
||||
const { user } = useAuth();
|
||||
const { followedUserIds, followUser, unfollowUser, isLoaded } = useFollows();
|
||||
|
||||
if (!user || user.id === targetUserId) return null;
|
||||
|
||||
const isFollowing = followedUserIds.has(targetUserId);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`follow-btn${isFollowing ? " follow-btn--following" : ""}`}
|
||||
disabled={!isLoaded}
|
||||
onClick={() =>
|
||||
isFollowing ? unfollowUser(targetUserId) : followUser(targetUserId)}
|
||||
aria-label={isFollowing
|
||||
? `Unfollow ${targetUsername}`
|
||||
: `Follow ${targetUsername}`}
|
||||
>
|
||||
{isFollowing ? "Following" : "Follow"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FollowPlaylistButton(
|
||||
{ targetPlaylistId, isPublic }: FollowPlaylistButtonProps,
|
||||
) {
|
||||
const { user } = useAuth();
|
||||
const { followedPlaylistIds, followPlaylist, unfollowPlaylist, isLoaded } =
|
||||
useFollows();
|
||||
|
||||
if (!user || !isPublic) return null;
|
||||
|
||||
const isFollowing = followedPlaylistIds.has(targetPlaylistId);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`follow-btn${isFollowing ? " follow-btn--following" : ""}`}
|
||||
disabled={!isLoaded}
|
||||
onClick={() =>
|
||||
isFollowing
|
||||
? unfollowPlaylist(targetPlaylistId)
|
||||
: followPlaylist(targetPlaylistId)}
|
||||
aria-label={isFollowing ? "Unfollow playlist" : "Follow playlist"}
|
||||
>
|
||||
{isFollowing ? "Following" : "Follow"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,10 @@ export function GlobalPlayer() {
|
||||
document.body.classList.add("has-player");
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
document.body.style.setProperty("--player-height", `${el.offsetHeight}px`);
|
||||
document.body.style.setProperty(
|
||||
"--player-height",
|
||||
`${el.offsetHeight}px`,
|
||||
);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
@@ -37,13 +40,24 @@ export function GlobalPlayer() {
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div className={`global-player global-player--${current.type}${reduced ? " global-player--reduced" : ""}`} ref={ref}>
|
||||
<div
|
||||
className={`global-player global-player--${current.type}${
|
||||
reduced ? " global-player--reduced" : ""
|
||||
}`}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="global-player-header">
|
||||
<span className="global-player-title">{current.title ?? current.embedUrl}</span>
|
||||
<button className="btn btn--ghost" onClick={() => setReduced((r) => !r)}>
|
||||
<span className="global-player-title">
|
||||
{current.title ?? current.embedUrl}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
onClick={() => setReduced((r) => !r)}
|
||||
>
|
||||
{reduced ? "▲" : "▼"}
|
||||
</button>
|
||||
<button className="btn btn--ghost" onClick={stop}>
|
||||
<button type="button" className="btn btn--ghost" onClick={stop}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,15 @@ interface MarkdownProps {
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm];
|
||||
|
||||
export function Markdown({ children, className, inline = false }: MarkdownProps) {
|
||||
export function Markdown(
|
||||
{ children, className, inline = false }: MarkdownProps,
|
||||
) {
|
||||
return (
|
||||
<div className={`md${className ? ` ${className}` : ""}${inline ? " md--inline" : ""}`}>
|
||||
<div
|
||||
className={`md${className ? ` ${className}` : ""}${
|
||||
inline ? " md--inline" : ""
|
||||
}`}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
components={{
|
||||
|
||||
50
src/components/NotificationBell.tsx
Normal file
50
src/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
|
||||
export function NotificationBell() {
|
||||
const { unreadNotificationCount, lastNotification } = useWS();
|
||||
const navigate = useNavigate();
|
||||
const [ringing, setRinging] = useState(false);
|
||||
const animatingRef = useRef(false);
|
||||
// Initialised to the ID already in context — so the first effect run never
|
||||
// animates, regardless of whether lastNotification is null or stale.
|
||||
const lastSeenIdRef = useRef<string | null>(lastNotification?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastNotification) return;
|
||||
if (lastNotification.id === lastSeenIdRef.current) return;
|
||||
lastSeenIdRef.current = lastNotification.id;
|
||||
|
||||
if (animatingRef.current) return;
|
||||
animatingRef.current = true;
|
||||
setRinging(true);
|
||||
const t = setTimeout(() => {
|
||||
setRinging(false);
|
||||
animatingRef.current = false;
|
||||
}, 700);
|
||||
return () => clearTimeout(t);
|
||||
}, [lastNotification]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`notification-bell${
|
||||
ringing ? " notification-bell--ringing" : ""
|
||||
}`}
|
||||
onClick={() => navigate("/notifications")}
|
||||
aria-label={`Notifications${
|
||||
unreadNotificationCount > 0
|
||||
? ` (${unreadNotificationCount} unread)`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span className="notification-bell-icon">🔔</span>
|
||||
{unreadNotificationCount > 0 && (
|
||||
<span className="notification-badge">
|
||||
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -2,19 +2,35 @@ import { Link, useNavigate } from "react-router";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Playlist } from "../model.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import {
|
||||
isPlaylistVisited,
|
||||
isRecent,
|
||||
markPlaylistVisited,
|
||||
} from "../utils/visited.ts";
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: Playlist;
|
||||
onDelete?: () => void;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
|
||||
export function PlaylistCard(
|
||||
{ playlist, onDelete, isOwner }: PlaylistCardProps,
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
const unread = !isOwner && isRecent(playlist.createdAt) &&
|
||||
!isPlaylistVisited(playlist.id);
|
||||
|
||||
function handleNavigate() {
|
||||
markPlaylistVisited(playlist.id);
|
||||
navigate(`/playlists/${playlist.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="playlist-card">
|
||||
<div
|
||||
className="playlist-card-inner"
|
||||
onClick={() => navigate(`/playlists/${playlist.id}`)}
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<div className="playlist-card-preview">
|
||||
{playlist.imageMime
|
||||
@@ -31,8 +47,12 @@ export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
|
||||
<Link
|
||||
to={`/playlists/${playlist.id}`}
|
||||
className="playlist-card-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markPlaylistVisited(playlist.id);
|
||||
}}
|
||||
>
|
||||
{unread && <span className="unread-dot" aria-hidden="true" />}
|
||||
{playlist.title}
|
||||
</Link>
|
||||
{playlist.description && (
|
||||
@@ -46,6 +66,15 @@ export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
|
||||
>
|
||||
{playlist.isPublic ? "public" : "private"}
|
||||
</span>
|
||||
{playlist.ownerUsername && !isOwner && (
|
||||
<Link
|
||||
to={`/users/${playlist.ownerUsername}`}
|
||||
className="playlist-card-owner"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{playlist.ownerUsername}
|
||||
</Link>
|
||||
)}
|
||||
{playlist.dumpCount !== undefined && (
|
||||
<span className="playlist-card-count">
|
||||
{playlist.dumpCount}{" "}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function PlaylistCreateForm(
|
||||
<div className="form-actions-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
className="form-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
|
||||
21
src/contexts/FollowContext.ts
Normal file
21
src/contexts/FollowContext.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface FollowContextValue {
|
||||
followedUserIds: Set<string>;
|
||||
followedPlaylistIds: Set<string>;
|
||||
followUser: (userId: string) => Promise<void>;
|
||||
unfollowUser: (userId: string) => Promise<void>;
|
||||
followPlaylist: (playlistId: string) => Promise<void>;
|
||||
unfollowPlaylist: (playlistId: string) => Promise<void>;
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
export const FollowContext = createContext<FollowContextValue>({
|
||||
followedUserIds: new Set(),
|
||||
followedPlaylistIds: new Set(),
|
||||
followUser: async () => {},
|
||||
unfollowUser: async () => {},
|
||||
followPlaylist: async () => {},
|
||||
unfollowPlaylist: async () => {},
|
||||
isLoaded: false,
|
||||
});
|
||||
125
src/contexts/FollowProvider.tsx
Normal file
125
src/contexts/FollowProvider.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import type { FollowStatus } from "../model.ts";
|
||||
|
||||
export function FollowProvider({ children }: { children: ReactNode }) {
|
||||
const { token, authFetch } = useAuth();
|
||||
const [followedUserIds, setFollowedUserIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [followedPlaylistIds, setFollowedPlaylistIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setFollowedUserIds(new Set());
|
||||
setFollowedPlaylistIds(new Set());
|
||||
setIsLoaded(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetch(`${API_URL}/api/follows/status`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (cancelled || !body.success) return;
|
||||
const status = body.data as FollowStatus;
|
||||
setFollowedUserIds(new Set(status.followedUserIds));
|
||||
setFollowedPlaylistIds(new Set(status.followedPlaylistIds));
|
||||
setIsLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setIsLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const followUser = useCallback(async (userId: string) => {
|
||||
setFollowedUserIds((prev) => new Set([...prev, userId]));
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/follows/users/${userId}`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
setFollowedUserIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(userId);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
const unfollowUser = useCallback(async (userId: string) => {
|
||||
setFollowedUserIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(userId);
|
||||
return n;
|
||||
});
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/follows/users/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
setFollowedUserIds((prev) => new Set([...prev, userId]));
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
const followPlaylist = useCallback(async (playlistId: string) => {
|
||||
setFollowedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/follows/playlists/${playlistId}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
setFollowedPlaylistIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(playlistId);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
const unfollowPlaylist = useCallback(async (playlistId: string) => {
|
||||
setFollowedPlaylistIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(playlistId);
|
||||
return n;
|
||||
});
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/follows/playlists/${playlistId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch {
|
||||
setFollowedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
const value: FollowContextValue = {
|
||||
followedUserIds,
|
||||
followedPlaylistIds,
|
||||
followUser,
|
||||
unfollowUser,
|
||||
followPlaylist,
|
||||
unfollowPlaylist,
|
||||
isLoaded,
|
||||
};
|
||||
|
||||
return (
|
||||
<FollowContext.Provider value={value}>
|
||||
{children}
|
||||
</FollowContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { createContext } from "react";
|
||||
import type { Comment, Dump, OnlineUser, Playlist } from "../model.ts";
|
||||
import type {
|
||||
Comment,
|
||||
Dump,
|
||||
Notification,
|
||||
OnlineUser,
|
||||
Playlist,
|
||||
} from "../model.ts";
|
||||
|
||||
export interface VoteEvent {
|
||||
dumpId: string;
|
||||
@@ -33,8 +39,12 @@ export interface WSContextValue {
|
||||
lastPlaylistEvent: PlaylistEvent | null;
|
||||
deletedPlaylistIds: Set<string>;
|
||||
lastCommentEvent: CommentEvent | null;
|
||||
unreadNotificationCount: number;
|
||||
lastNotification: Notification | null;
|
||||
castVote: (dumpId: string) => void;
|
||||
removeVote: (dumpId: string) => void;
|
||||
injectDump: (dump: Dump) => void;
|
||||
clearUnreadNotifications: () => void;
|
||||
}
|
||||
|
||||
export const WSContext = createContext<WSContextValue>({
|
||||
@@ -48,6 +58,10 @@ export const WSContext = createContext<WSContextValue>({
|
||||
lastPlaylistEvent: null,
|
||||
deletedPlaylistIds: new Set(),
|
||||
lastCommentEvent: null,
|
||||
unreadNotificationCount: 0,
|
||||
lastNotification: null,
|
||||
castVote: () => {},
|
||||
removeVote: () => {},
|
||||
injectDump: () => {},
|
||||
clearUnreadNotifications: () => {},
|
||||
});
|
||||
|
||||
@@ -16,14 +16,17 @@ import {
|
||||
import { WS_URL } from "../config/api.ts";
|
||||
import type {
|
||||
Dump,
|
||||
Notification,
|
||||
OnlineUser,
|
||||
RawComment,
|
||||
RawDump,
|
||||
RawNotification,
|
||||
RawPlaylist,
|
||||
} from "../model.ts";
|
||||
import {
|
||||
deserializeComment,
|
||||
deserializeDump,
|
||||
deserializeNotification,
|
||||
deserializePlaylist,
|
||||
} from "../model.ts";
|
||||
|
||||
@@ -52,6 +55,10 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
|
||||
null,
|
||||
);
|
||||
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
||||
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Refs to avoid stale closures in event handlers
|
||||
const voteCountsRef = useRef(voteCounts);
|
||||
@@ -100,6 +107,9 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
const votes = msg.myVotes as string[];
|
||||
setOnlineUsers(users);
|
||||
setMyVotes(new Set(votes));
|
||||
setUnreadNotificationCount(
|
||||
(msg.unreadNotificationCount as number) ?? 0,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -217,6 +227,15 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
break;
|
||||
}
|
||||
|
||||
case "notification_created": {
|
||||
const notification = deserializeNotification(
|
||||
msg.notification as RawNotification,
|
||||
);
|
||||
setLastNotification(notification);
|
||||
setUnreadNotificationCount((prev) => prev + 1);
|
||||
break;
|
||||
}
|
||||
|
||||
case "error":
|
||||
// On error, revert any pending optimistic update for the affected dump
|
||||
// (the revert timeout will handle it)
|
||||
@@ -309,6 +328,17 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId }));
|
||||
}, []);
|
||||
|
||||
const injectDump = useCallback((dump: Dump) => {
|
||||
setRecentDumps((prev) => {
|
||||
if (prev.some((d) => d.id === dump.id)) return prev;
|
||||
return [dump, ...prev];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearUnreadNotifications = useCallback(() => {
|
||||
setUnreadNotificationCount(0);
|
||||
}, []);
|
||||
|
||||
const value: WSContextValue = {
|
||||
onlineUsers,
|
||||
voteCounts,
|
||||
@@ -320,8 +350,12 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
lastPlaylistEvent,
|
||||
deletedPlaylistIds,
|
||||
lastCommentEvent,
|
||||
unreadNotificationCount,
|
||||
lastNotification,
|
||||
castVote,
|
||||
removeVote,
|
||||
injectDump,
|
||||
clearUnreadNotifications,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigationType } from "react-router";
|
||||
|
||||
const TTL = 10 * 60 * 1000; // 10 minutes
|
||||
@@ -13,7 +13,12 @@ interface FeedCacheEntry<T> {
|
||||
|
||||
export interface FeedCacheResult<T> {
|
||||
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
|
||||
saveState: (items: T[], page: number, hasMore: boolean, scrollY: number) => void;
|
||||
saveState: (
|
||||
items: T[],
|
||||
page: number,
|
||||
hasMore: boolean,
|
||||
scrollY: number,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useFeedCache<T>(
|
||||
@@ -22,9 +27,8 @@ export function useFeedCache<T>(
|
||||
): FeedCacheResult<T> {
|
||||
const navType = useNavigationType();
|
||||
|
||||
// Read ONCE on mount. Empty deps is intentional — nav type and cache are only
|
||||
// relevant at the moment the component first mounts.
|
||||
const cached = useMemo<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
|
||||
// useState initializer runs exactly once on mount — correct for a cache read.
|
||||
const [cached] = useState<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
|
||||
if (navType !== "POP") return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
@@ -35,15 +39,20 @@ export function useFeedCache<T>(
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
});
|
||||
|
||||
const saveState = useCallback(
|
||||
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ items, page, hasMore, scrollY, savedAt: Date.now() }),
|
||||
JSON.stringify({
|
||||
items,
|
||||
page,
|
||||
hasMore,
|
||||
scrollY,
|
||||
savedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// QuotaExceededError or SecurityError — degrade silently
|
||||
|
||||
4
src/hooks/useFollows.ts
Normal file
4
src/hooks/useFollows.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useContext } from "react";
|
||||
import { FollowContext } from "../contexts/FollowContext.ts";
|
||||
|
||||
export const useFollows = () => useContext(FollowContext);
|
||||
78
src/model.ts
78
src/model.ts
@@ -46,6 +46,7 @@ export interface User {
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
avatarMime?: string;
|
||||
invitedByUsername?: string;
|
||||
}
|
||||
|
||||
// Public user profile (no passwordHash)
|
||||
@@ -55,6 +56,7 @@ export interface PublicUser {
|
||||
isAdmin: boolean;
|
||||
createdAt: Date;
|
||||
avatarMime?: string;
|
||||
invitedByUsername?: string;
|
||||
}
|
||||
|
||||
// Wire types — createdAt arrives as an ISO string from API/WS/localStorage
|
||||
@@ -139,6 +141,7 @@ export interface Playlist {
|
||||
createdAt: Date;
|
||||
imageMime?: string;
|
||||
dumpCount?: number;
|
||||
ownerUsername?: string;
|
||||
}
|
||||
|
||||
export interface PlaylistWithDumps extends Playlist {
|
||||
@@ -318,3 +321,78 @@ export interface ActionResultFailure {
|
||||
}
|
||||
|
||||
export type ActionResult = ActionResultSuccess | ActionResultFailure;
|
||||
|
||||
/**
|
||||
* Follows
|
||||
*/
|
||||
|
||||
export interface FollowStatus {
|
||||
followedUserIds: string[];
|
||||
followedPlaylistIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifications
|
||||
*/
|
||||
|
||||
export type NotificationType =
|
||||
| "playlist_followed"
|
||||
| "user_followed"
|
||||
| "user_dump_posted"
|
||||
| "playlist_dump_added"
|
||||
| "dump_upvoted";
|
||||
|
||||
export interface PlaylistFollowedData {
|
||||
followerId: string;
|
||||
followerUsername: string;
|
||||
playlistId: string;
|
||||
playlistTitle: string;
|
||||
}
|
||||
|
||||
export interface UserFollowedData {
|
||||
followerId: string;
|
||||
followerUsername: string;
|
||||
}
|
||||
|
||||
export interface UserDumpPostedData {
|
||||
dumperId: string;
|
||||
dumperUsername: string;
|
||||
dumpId: string;
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
export interface PlaylistDumpAddedData {
|
||||
dumpId: string;
|
||||
dumpTitle: string;
|
||||
playlistId: string;
|
||||
playlistTitle: string;
|
||||
}
|
||||
|
||||
export interface DumpUpvotedData {
|
||||
voterId: string;
|
||||
voterUsername: string;
|
||||
dumpId: string;
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
export type NotificationData =
|
||||
| PlaylistFollowedData
|
||||
| UserFollowedData
|
||||
| UserDumpPostedData
|
||||
| PlaylistDumpAddedData
|
||||
| DumpUpvotedData;
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
data: NotificationData;
|
||||
read: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type RawNotification = WithStringDate<Notification>;
|
||||
|
||||
export function deserializeNotification(raw: RawNotification): Notification {
|
||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||
}
|
||||
|
||||
@@ -43,7 +43,14 @@ export function Dump() {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
|
||||
const { user, token } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote, lastDumpEvent, lastCommentEvent } = useWS();
|
||||
const {
|
||||
voteCounts,
|
||||
myVotes,
|
||||
castVote,
|
||||
removeVote,
|
||||
lastDumpEvent,
|
||||
lastCommentEvent,
|
||||
} = useWS();
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
@@ -114,7 +121,9 @@ export function Dump() {
|
||||
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
|
||||
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
|
||||
setComments((prev) => {
|
||||
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) return prev;
|
||||
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, lastCommentEvent.comment!];
|
||||
});
|
||||
} else if (
|
||||
|
||||
@@ -78,7 +78,11 @@ export function DumpEdit() {
|
||||
});
|
||||
} else {
|
||||
const body: UpdateDumpRequest = state.dump.kind === "url"
|
||||
? { url: url.trim() || undefined, comment: comment.trim() || undefined, isPrivate }
|
||||
? {
|
||||
url: url.trim() || undefined,
|
||||
comment: comment.trim() || undefined,
|
||||
isPrivate,
|
||||
}
|
||||
: { comment: comment.trim() || undefined, isPrivate };
|
||||
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
||||
method: "PUT",
|
||||
@@ -263,20 +267,22 @@ export function DumpEdit() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="toggle-row">
|
||||
<span className="toggle-label">Public</span>
|
||||
<span className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isPrivate}
|
||||
onChange={(e) => setIsPrivate(!e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
{isPrivate && (
|
||||
<span className="toggle-hint">Only visible to you</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="dump-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={!isPrivate ? "active" : ""}
|
||||
onClick={() => setIsPrivate(false)}
|
||||
>
|
||||
Public
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={isPrivate ? "active" : ""}
|
||||
onClick={() => setIsPrivate(true)}
|
||||
>
|
||||
Private
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
@@ -7,10 +13,15 @@ import { AppHeader } from "../components/AppHeader.tsx";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import { deserializeDump, type Dump, type PaginatedData, type RawDump } from "../model.ts";
|
||||
import {
|
||||
deserializeDump,
|
||||
type Dump,
|
||||
type PaginatedData,
|
||||
type RawDump,
|
||||
type User,
|
||||
} from "../model.ts";
|
||||
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
@@ -24,15 +35,88 @@ const hydrateDump = (raw: Dump): Dump =>
|
||||
type DumpsState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean };
|
||||
| {
|
||||
status: "loaded";
|
||||
dumps: Dump[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
type SortMode = "new" | "hot";
|
||||
type FeedTab = "hot" | "new" | "followed";
|
||||
type FollowedSection = "users" | "playlists";
|
||||
|
||||
function hotScore(dump: Dump): number {
|
||||
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
|
||||
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
|
||||
}
|
||||
|
||||
// ── FollowedSubFeed ──────────────────────────────────────────────────────────
|
||||
|
||||
interface FollowedSubFeedProps {
|
||||
state: DumpsState;
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
user: User | null;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
deletedDumpIds: Set<string>;
|
||||
emptyMessage: string;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
function FollowedSubFeed({
|
||||
state,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
user,
|
||||
castVote,
|
||||
removeVote,
|
||||
deletedDumpIds,
|
||||
emptyMessage,
|
||||
onLoadMore,
|
||||
}: FollowedSubFeedProps) {
|
||||
const hasMore = state.status === "loaded" && state.hasMore &&
|
||||
!state.loadingMore;
|
||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <p className="index-status">Loading…</p>;
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return <p className="index-status index-status--error">{state.error}</p>;
|
||||
}
|
||||
|
||||
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
|
||||
|
||||
if (visible.length === 0) {
|
||||
return <p className="index-status">{emptyMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="dump-feed">
|
||||
{visible.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<div ref={sentinelRef} />
|
||||
{state.loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Index ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Index() {
|
||||
const location = useLocation();
|
||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
||||
@@ -49,22 +133,70 @@ export function Index() {
|
||||
removeVote,
|
||||
} = useWS();
|
||||
|
||||
const { cached, saveState } = useFeedCache<Dump>(`feed:index:${user?.id ?? "guest"}`, hydrateDump);
|
||||
|
||||
// Main feed
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:index:${user?.id ?? "guest"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
|
||||
cached
|
||||
? { status: "loaded", dumps: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false }
|
||||
? {
|
||||
status: "loaded",
|
||||
dumps: cached.items,
|
||||
hasMore: cached.hasMore,
|
||||
page: cached.page,
|
||||
loadingMore: false,
|
||||
}
|
||||
: { status: "loading" }
|
||||
);
|
||||
const [sort, setSort] = useState<SortMode>("hot");
|
||||
const mainFetchDone = useRef(false);
|
||||
|
||||
// Followed feeds
|
||||
const { cached: cachedFollowedUsers, saveState: saveFollowedUsers } =
|
||||
useFeedCache<Dump>(
|
||||
`feed:followed-users:${user?.id ?? "guest"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const { cached: cachedFollowedPlaylists, saveState: saveFollowedPlaylists } =
|
||||
useFeedCache<Dump>(
|
||||
`feed:followed-playlists:${user?.id ?? "guest"}`,
|
||||
hydrateDump,
|
||||
);
|
||||
|
||||
const [followedUsersDumps, setFollowedUsersDumps] = useState<DumpsState>({
|
||||
status: "loading",
|
||||
});
|
||||
const [followedPlaylistsDumps, setFollowedPlaylistsDumps] = useState<
|
||||
DumpsState
|
||||
>({ status: "loading" });
|
||||
|
||||
const [tab, setTab] = useState<FeedTab>("hot");
|
||||
const [followedSection, setFollowedSection] = useState<FollowedSection>(
|
||||
"users",
|
||||
);
|
||||
|
||||
// When the logo is clicked it navigates to / with state { tab: "hot" }, producing
|
||||
// a new location.key even if already on /. React to that to reset the active tab.
|
||||
useEffect(() => {
|
||||
const st = location.state as { tab?: string } | null;
|
||||
if (st?.tab === "hot" || st?.tab === "new" || st?.tab === "followed") {
|
||||
setTab(st.tab as FeedTab);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
// ── Main feed fetch ──
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return; // restored from cache, skip fetch
|
||||
if (mainFetchDone.current || cached) return;
|
||||
mainFetchDone.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
@@ -82,13 +214,96 @@ export function Index() {
|
||||
});
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [cached, token]);
|
||||
|
||||
// ── Followed feeds fetch (lazy, on first tab open) ──
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== "followed" || !user || !token) return;
|
||||
|
||||
if (followedUsersDumps.status === "loading") {
|
||||
if (cachedFollowedUsers) {
|
||||
setFollowedUsersDumps({
|
||||
status: "loaded",
|
||||
dumps: cachedFollowedUsers.items,
|
||||
hasMore: cachedFollowedUsers.hasMore,
|
||||
page: cachedFollowedUsers.page,
|
||||
loadingMore: false,
|
||||
});
|
||||
} else {
|
||||
fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${PAGE_SIZE}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setFollowedUsersDumps({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setFollowedUsersDumps({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (followedPlaylistsDumps.status === "loading") {
|
||||
if (cachedFollowedPlaylists) {
|
||||
setFollowedPlaylistsDumps({
|
||||
status: "loaded",
|
||||
dumps: cachedFollowedPlaylists.items,
|
||||
hasMore: cachedFollowedPlaylists.hasMore,
|
||||
page: cachedFollowedPlaylists.page,
|
||||
loadingMore: false,
|
||||
});
|
||||
} else {
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/playlists?page=1&limit=${PAGE_SIZE}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setFollowedPlaylistsDumps({
|
||||
status: "loaded",
|
||||
dumps: items.map(deserializeDump),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setFollowedPlaylistsDumps({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
tab,
|
||||
user?.id,
|
||||
token,
|
||||
cachedFollowedUsers,
|
||||
cachedFollowedPlaylists,
|
||||
followedUsersDumps.status,
|
||||
followedPlaylistsDumps.status,
|
||||
]);
|
||||
|
||||
// ── Load-more callbacks ──
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
dumpsState.status !== "loaded" ||
|
||||
!dumpsState.hasMore ||
|
||||
dumpsState.status !== "loaded" || !dumpsState.hasMore ||
|
||||
dumpsState.loadingMore
|
||||
) return;
|
||||
const nextPage = dumpsState.page + 1;
|
||||
@@ -120,12 +335,92 @@ export function Index() {
|
||||
);
|
||||
}, [dumpsState, token]);
|
||||
|
||||
const loadMoreFollowedUsers = useCallback(() => {
|
||||
if (
|
||||
followedUsersDumps.status !== "loaded" ||
|
||||
!followedUsersDumps.hasMore ||
|
||||
followedUsersDumps.loadingMore ||
|
||||
!token
|
||||
) return;
|
||||
const nextPage = followedUsersDumps.page + 1;
|
||||
setFollowedUsersDumps((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setFollowedUsersDumps((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setFollowedUsersDumps((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [followedUsersDumps, token]);
|
||||
|
||||
const loadMoreFollowedPlaylists = useCallback(() => {
|
||||
if (
|
||||
followedPlaylistsDumps.status !== "loaded" ||
|
||||
!followedPlaylistsDumps.hasMore ||
|
||||
followedPlaylistsDumps.loadingMore ||
|
||||
!token
|
||||
) return;
|
||||
const nextPage = followedPlaylistsDumps.page + 1;
|
||||
setFollowedPlaylistsDumps((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setFollowedPlaylistsDumps((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setFollowedPlaylistsDumps((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [followedPlaylistsDumps, token]);
|
||||
|
||||
// ── Scroll save effects ──
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
dumpsState.status === "loaded" && dumpsState.hasMore && !dumpsState.loadingMore,
|
||||
dumpsState.status === "loaded" && dumpsState.hasMore &&
|
||||
!dumpsState.loadingMore,
|
||||
);
|
||||
|
||||
// Save scroll position + loaded state to sessionStorage on scroll
|
||||
useEffect(() => {
|
||||
if (dumpsState.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -133,25 +428,80 @@ export function Index() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (dumpsState.status === "loaded") {
|
||||
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, window.scrollY);
|
||||
saveState(
|
||||
dumpsState.dumps,
|
||||
dumpsState.page,
|
||||
dumpsState.hasMore,
|
||||
globalThis.scrollY,
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [dumpsState, saveState]);
|
||||
|
||||
// Restore scroll position after cache restoration
|
||||
useEffect(() => {
|
||||
if (followedUsersDumps.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const onScroll = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (followedUsersDumps.status === "loaded") {
|
||||
saveFollowedUsers(
|
||||
followedUsersDumps.dumps,
|
||||
followedUsersDumps.page,
|
||||
followedUsersDumps.hasMore,
|
||||
globalThis.scrollY,
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [followedUsersDumps, saveFollowedUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (followedPlaylistsDumps.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const onScroll = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (followedPlaylistsDumps.status === "loaded") {
|
||||
saveFollowedPlaylists(
|
||||
followedPlaylistsDumps.dumps,
|
||||
followedPlaylistsDumps.page,
|
||||
followedPlaylistsDumps.hasMore,
|
||||
globalThis.scrollY,
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [followedPlaylistsDumps, saveFollowedPlaylists]);
|
||||
|
||||
// ── Scroll restoration ──
|
||||
|
||||
const scrollRestored = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (cached?.scrollY == null || scrollRestored.current) return;
|
||||
if (dumpsState.status === "loaded") {
|
||||
window.scrollTo(0, cached.scrollY);
|
||||
globalThis.scrollTo(0, cached.scrollY);
|
||||
scrollRestored.current = true;
|
||||
}
|
||||
// cached is stable (read once), safe to omit
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dumpsState.status]);
|
||||
}, [dumpsState.status, cached]);
|
||||
|
||||
// ── Derived values ──
|
||||
|
||||
const loading = dumpsState.status === "loading";
|
||||
const error = dumpsState.status === "error" ? dumpsState.error : null;
|
||||
@@ -163,11 +513,13 @@ export function Index() {
|
||||
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
|
||||
|
||||
const sortedDumps = [...combined].sort(
|
||||
sort === "hot"
|
||||
? (a, b) => hotScore(b) - hotScore(a)
|
||||
: (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
||||
tab === "new"
|
||||
? (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
||||
: (a, b) => hotScore(b) - hotScore(a),
|
||||
);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
const presenceRow = (
|
||||
<div className="index-presence">
|
||||
{onlineUsers.map((u) => (
|
||||
@@ -188,22 +540,31 @@ export function Index() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const sortButtons = !loading && !error && combined.length > 0 && (
|
||||
const tabBar = (
|
||||
<div className="feed-sort">
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${sort === "hot" ? " active" : ""}`}
|
||||
onClick={() => setSort("hot")}
|
||||
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
|
||||
onClick={() => setTab("hot")}
|
||||
>
|
||||
Hot
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${sort === "new" ? " active" : ""}`}
|
||||
onClick={() => setSort("new")}
|
||||
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
|
||||
onClick={() => setTab("new")}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
|
||||
onClick={() => setTab("followed")}
|
||||
>
|
||||
Followed
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -213,43 +574,102 @@ export function Index() {
|
||||
centerSlot={
|
||||
<div className="header-center-slot">
|
||||
{presenceRow}
|
||||
{sortButtons}
|
||||
{tabBar}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Shown only on narrow viewports */}
|
||||
<div className="index-below-header">
|
||||
{sortButtons}
|
||||
{tabBar}
|
||||
{presenceRow}
|
||||
</div>
|
||||
|
||||
{loading && <p className="index-status">Loading…</p>}
|
||||
{error && <p className="index-status index-status--error">{error}</p>}
|
||||
{/* Hot / New feed */}
|
||||
{tab !== "followed" && (
|
||||
<>
|
||||
{loading && <p className="index-status">Loading…</p>}
|
||||
{error && <p className="index-status index-status--error">{error}</p>}
|
||||
|
||||
{!loading && !error && combined.length === 0 && (
|
||||
<p className="index-status">No dumps yet. Be the first!</p>
|
||||
{!loading && !error && combined.length === 0 && (
|
||||
<p className="index-status">No dumps yet. Be the first!</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && combined.length > 0 && (
|
||||
<ul className="dump-feed">
|
||||
{sortedDumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && combined.length > 0 && (
|
||||
<ul className="dump-feed">
|
||||
{sortedDumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
{/* Followed feed */}
|
||||
{tab === "followed" && user && (
|
||||
<div className="followed-feed">
|
||||
<div className="feed-sort followed-sub-nav">
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${
|
||||
followedSection === "users" ? " active" : ""
|
||||
}`}
|
||||
onClick={() => setFollowedSection("users")}
|
||||
>
|
||||
From people
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${
|
||||
followedSection === "playlists" ? " active" : ""
|
||||
}`}
|
||||
onClick={() => setFollowedSection("playlists")}
|
||||
>
|
||||
From playlists
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{followedSection === "users" && (
|
||||
<FollowedSubFeed
|
||||
state={followedUsersDumps}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={user?.id === dump.userId}
|
||||
deletedDumpIds={deletedDumpIds}
|
||||
emptyMessage="Follow some users to see their dumps here."
|
||||
onLoadMore={loadMoreFollowedUsers}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
{followedSection === "playlists" && (
|
||||
<FollowedSubFeed
|
||||
state={followedPlaylistsDumps}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
user={user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
deletedDumpIds={deletedDumpIds}
|
||||
emptyMessage="Follow some public playlists to see their dumps here."
|
||||
onLoadMore={loadMoreFollowedPlaylists}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist, type PaginatedData } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; playlists: Playlist[]; hasMore: boolean; page: number; loadingMore: boolean };
|
||||
|
||||
export function MyPlaylists() {
|
||||
const { user, authFetch, token } = useAuth();
|
||||
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
fetch(`${API_URL}/api/users/${user.username}/playlists?page=1&limit=${PAGE_SIZE}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("Failed to load");
|
||||
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||
setState({
|
||||
status: "loaded",
|
||||
playlists: items.map(deserializePlaylist),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load playlists",
|
||||
})
|
||||
);
|
||||
}, [user?.username]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (state.status !== "loaded" || !state.hasMore || state.loadingMore || !user) return;
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${user.username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
playlists: [...s.playlists, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s)
|
||||
);
|
||||
}, [state, user, token]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||
);
|
||||
|
||||
// Real-time WS updates
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || !user) return;
|
||||
const ev = lastPlaylistEvent;
|
||||
|
||||
if (ev.type === "created" && ev.playlist?.userId === user.id) {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlists.some((p) => p.id === ev.playlist!.id)) return s;
|
||||
return { ...s, playlists: [ev.playlist!, ...s.playlists] };
|
||||
});
|
||||
} else if (ev.type === "updated" && ev.playlist?.userId === user.id) {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
playlists: s.playlists.map((p) =>
|
||||
p.id === ev.playlist!.id ? ev.playlist! : p
|
||||
),
|
||||
}
|
||||
: s
|
||||
);
|
||||
} else if (ev.type === "deleted") {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
playlists: s.playlists.filter((p) => p.id !== ev.playlistId),
|
||||
}
|
||||
: s
|
||||
);
|
||||
}
|
||||
}, [lastPlaylistEvent, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deletedPlaylistIds.size) return;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
playlists: s.playlists.filter((p) => !deletedPlaylistIds.has(p.id)),
|
||||
}
|
||||
: s
|
||||
);
|
||||
}, [deletedPlaylistIds]);
|
||||
|
||||
const handleDelete = async (playlistId: string) => {
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, playlists: s.playlists.filter((p) => p.id !== playlistId) }
|
||||
: s
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="my-playlists-header">
|
||||
<h1 className="my-playlists-title">My Playlists</h1>
|
||||
<NewPlaylistForm
|
||||
toggleClassName="btn-primary"
|
||||
onCreated={(p) =>
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlists.some((pl) => pl.id === p.id)) return s;
|
||||
return { ...s, playlists: [p, ...s.playlists] };
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && <p className="page-loading">Loading…</p>}
|
||||
{state.status === "error" && <p className="form-error">{state.error}</p>}
|
||||
{state.status === "loaded" && (
|
||||
state.playlists.length === 0
|
||||
? <p className="empty-state">No playlists yet. Create one!</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{state.playlists.map((p) => (
|
||||
<PlaylistCard
|
||||
key={p.id}
|
||||
playlist={p}
|
||||
onDelete={() => setConfirmDeleteId(p.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
{state.status === "loaded" && state.loadingMore && (
|
||||
<p className="feed-loading-more">Loading more…</p>
|
||||
)}
|
||||
|
||||
{confirmDeleteId && (
|
||||
<ConfirmModal
|
||||
message="Delete this playlist? This cannot be undone."
|
||||
confirmLabel="Delete playlist"
|
||||
onConfirm={() => {
|
||||
handleDelete(confirmDeleteId);
|
||||
setConfirmDeleteId(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDeleteId(null)}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
340
src/pages/Notifications.tsx
Normal file
340
src/pages/Notifications.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import type {
|
||||
DumpUpvotedData,
|
||||
Notification,
|
||||
NotificationData,
|
||||
PaginatedData,
|
||||
PlaylistDumpAddedData,
|
||||
PlaylistFollowedData,
|
||||
RawNotification,
|
||||
UserDumpPostedData,
|
||||
UserFollowedData,
|
||||
} from "../model.ts";
|
||||
import { deserializeNotification } from "../model.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
items: Notification[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist";
|
||||
|
||||
function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||
switch (type) {
|
||||
case "dump_upvoted":
|
||||
return "upvote";
|
||||
case "playlist_followed":
|
||||
return "follow";
|
||||
case "user_followed":
|
||||
return "follow";
|
||||
case "user_dump_posted":
|
||||
return "dump";
|
||||
case "playlist_dump_added":
|
||||
return "playlist";
|
||||
}
|
||||
}
|
||||
|
||||
function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||
const kind = notifIconKind(type);
|
||||
const glyphs: Record<NotifIconKind, string> = {
|
||||
upvote: "▲",
|
||||
follow: "►",
|
||||
dump: "🚚",
|
||||
playlist: "📜",
|
||||
};
|
||||
return (
|
||||
<span className={`notif-icon notif-icon--${kind}`}>
|
||||
{glyphs[kind]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function notificationContent(n: Notification): React.ReactNode {
|
||||
const data = n.data as NotificationData;
|
||||
switch (n.type) {
|
||||
case "user_followed": {
|
||||
const d = data as UserFollowedData;
|
||||
return (
|
||||
<>
|
||||
<Link to={`/users/${d.followerUsername}`} className="notif-link">
|
||||
{d.followerUsername}
|
||||
</Link>
|
||||
{" started following you"}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "playlist_followed": {
|
||||
const d = data as PlaylistFollowedData;
|
||||
return (
|
||||
<>
|
||||
<Link to={`/users/${d.followerUsername}`} className="notif-link">
|
||||
{d.followerUsername}
|
||||
</Link>
|
||||
{" followed your playlist "}
|
||||
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
|
||||
{d.playlistTitle}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "user_dump_posted": {
|
||||
const d = data as UserDumpPostedData;
|
||||
return (
|
||||
<>
|
||||
<Link to={`/users/${d.dumperUsername}`} className="notif-link">
|
||||
{d.dumperUsername}
|
||||
</Link>
|
||||
{" posted "}
|
||||
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
|
||||
{d.dumpTitle}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "playlist_dump_added": {
|
||||
const d = data as PlaylistDumpAddedData;
|
||||
return (
|
||||
<>
|
||||
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
|
||||
{d.dumpTitle}
|
||||
</Link>
|
||||
{" was added to "}
|
||||
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
|
||||
{d.playlistTitle}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "dump_upvoted": {
|
||||
const d = data as DumpUpvotedData;
|
||||
return (
|
||||
<>
|
||||
<Link to={`/users/${d.voterUsername}`} className="notif-link">
|
||||
{d.voterUsername}
|
||||
</Link>
|
||||
{" upvoted "}
|
||||
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
|
||||
{d.dumpTitle}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return "New notification";
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(date: Date): string {
|
||||
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (secs < 60) return "just now";
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function startOfDay(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
}
|
||||
|
||||
function groupByDate(
|
||||
items: Notification[],
|
||||
): { label: string; items: Notification[] }[] {
|
||||
const todayTs = startOfDay(new Date());
|
||||
const yesterdayTs = todayTs - 86_400_000;
|
||||
|
||||
const buckets: Record<string, Notification[]> = {};
|
||||
for (const n of items) {
|
||||
const ts = startOfDay(n.createdAt);
|
||||
const key = ts >= todayTs
|
||||
? "Today"
|
||||
: ts >= yesterdayTs
|
||||
? "Yesterday"
|
||||
: "Earlier";
|
||||
(buckets[key] ??= []).push(n);
|
||||
}
|
||||
|
||||
return (["Today", "Yesterday", "Earlier"] as const)
|
||||
.filter((k) => buckets[k]?.length)
|
||||
.map((label) => ({ label, items: buckets[label] }));
|
||||
}
|
||||
|
||||
export function Notifications() {
|
||||
const { authFetch } = useAuth();
|
||||
const { clearUnreadNotifications, lastNotification } = useWS();
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Fetch with original read state so unread items are highlighted
|
||||
// 2. Only after displaying, mark all read on the server
|
||||
authFetch(`${API_URL}/api/notifications?page=1&limit=${PAGE_SIZE}`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("Failed to load");
|
||||
const data = body.data as PaginatedData<RawNotification>;
|
||||
setState({
|
||||
status: "loaded",
|
||||
items: data.items.map(deserializeNotification),
|
||||
hasMore: data.hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
// Mark read server-side after we've shown the unread state
|
||||
return authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||
method: "POST",
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
clearUnreadNotifications();
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, items: s.items.map((n) => ({ ...n, read: true })) }
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof Error && err.message === "Failed to load") {
|
||||
setState({ status: "error", error: err.message });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastNotification) return;
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.items.some((n) => n.id === lastNotification.id)) return s;
|
||||
// Keep as unread so it gets highlighted when it arrives
|
||||
return { ...s, items: [lastNotification, ...s.items] };
|
||||
});
|
||||
}, [lastNotification]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
|
||||
return;
|
||||
}
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
authFetch(
|
||||
`${API_URL}/api/notifications?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const data = body.data as PaginatedData<RawNotification>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
items: [...s.items, ...data.items.map(deserializeNotification)],
|
||||
hasMore: data.hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const totalUnread = state.status === "loaded"
|
||||
? state.items.filter((n) => !n.read).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="notifications-page">
|
||||
<div className="notifications-header">
|
||||
<h1 className="notifications-title">
|
||||
<span className="notifications-title-bell">🔔</span>
|
||||
Notifications
|
||||
</h1>
|
||||
{state.status === "loaded" && totalUnread > 0 && (
|
||||
<span className="notifications-unread-pill">
|
||||
{totalUnread} new
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && <p className="page-loading">Loading…</p>}
|
||||
{state.status === "error" && <p className="form-error">{state.error}
|
||||
</p>}
|
||||
|
||||
{state.status === "loaded" && state.items.length === 0 && (
|
||||
<div className="notifications-empty">
|
||||
<span className="notifications-empty-icon">🔕</span>
|
||||
<p>Nothing here yet.</p>
|
||||
<p className="notifications-empty-hint">
|
||||
You'll be notified when someone follows your playlists, upvotes
|
||||
your dumps, or posts new content.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && state.items.length > 0 &&
|
||||
groupByDate(state.items).map(({ label, items }) => (
|
||||
<section key={label} className="notif-group">
|
||||
<h2 className="notif-group-label">{label}</h2>
|
||||
<ul className="notification-list">
|
||||
{items.map((n) => (
|
||||
<li
|
||||
key={n.id}
|
||||
className={`notification-item${
|
||||
!n.read ? " notification-item--unread" : ""
|
||||
}`}
|
||||
>
|
||||
<NotifIcon type={n.type} />
|
||||
<div className="notification-body">
|
||||
<span className="notification-content">
|
||||
{notificationContent(n)}
|
||||
</span>
|
||||
<time
|
||||
className="notification-time"
|
||||
dateTime={n.createdAt.toISOString()}
|
||||
>
|
||||
{timeAgo(n.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
{!n.read && (
|
||||
<span className="notif-dot" aria-hidden="true" />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{state.status === "loaded" && state.hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
className="load-more-btn"
|
||||
onClick={loadMore}
|
||||
disabled={state.loadingMore}
|
||||
>
|
||||
{state.loadingMore ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { PlaylistWithDumps, RawPlaylistWithDumps } from "../model.ts";
|
||||
import { deserializePlaylistWithDumps } from "../model.ts";
|
||||
@@ -12,6 +12,7 @@ import { PageError } from "../components/PageError.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||
import { Markdown } from "../components/Markdown.tsx";
|
||||
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
@@ -356,7 +357,6 @@ export function PlaylistDetail() {
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
setEditSaving(true);
|
||||
@@ -392,7 +392,9 @@ export function PlaylistDetail() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!playlistId) return;
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE" });
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
@@ -460,15 +462,58 @@ export function PlaylistDetail() {
|
||||
<div className="playlist-detail-content">
|
||||
{editOpen
|
||||
? (
|
||||
<input
|
||||
type="text"
|
||||
className="playlist-edit-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="playlist-detail-title-row">
|
||||
<input
|
||||
type="text"
|
||||
className="playlist-edit-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={editSaving}
|
||||
onClick={handleEditSave}
|
||||
>
|
||||
{editSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="form-cancel"
|
||||
onClick={() => setEditOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: <h1 className="playlist-detail-title">{playlist.title}</h1>}
|
||||
: (
|
||||
<div className="playlist-detail-title-row">
|
||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
||||
{!isOwner && (
|
||||
<FollowPlaylistButton
|
||||
targetPlaylistId={playlist.id}
|
||||
isPublic={playlist.isPublic}
|
||||
/>
|
||||
)}
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-edit-btn"
|
||||
onClick={openEdit}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editOpen
|
||||
? (
|
||||
@@ -516,6 +561,14 @@ export function PlaylistDetail() {
|
||||
>
|
||||
{playlist.isPublic ? "public" : "private"}
|
||||
</span>
|
||||
{playlist.ownerUsername && (
|
||||
<Link
|
||||
to={`/users/${playlist.ownerUsername}`}
|
||||
className="playlist-detail-owner"
|
||||
>
|
||||
@{playlist.ownerUsername}
|
||||
</Link>
|
||||
)}
|
||||
<time
|
||||
dateTime={playlist.createdAt.toISOString()}
|
||||
title={playlist.createdAt.toLocaleString()}
|
||||
@@ -527,47 +580,6 @@ export function PlaylistDetail() {
|
||||
</div>
|
||||
{editError && <p className="form-error">{editError}</p>}
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<div className="playlist-header-actions">
|
||||
{editOpen
|
||||
? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={editSaving}
|
||||
onClick={handleEditSave}
|
||||
>
|
||||
{editSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => setEditOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-edit-btn"
|
||||
onClick={openEdit}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
260
src/pages/UserDumps.tsx
Normal file
260
src/pages/UserDumps.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
||||
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const hydrateDump = (raw: Dump): Dump =>
|
||||
deserializeDump(raw as unknown as RawDump);
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
profileUser: PublicUser;
|
||||
dumps: Dump[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
export function UserDumps() {
|
||||
const { username } = useParams();
|
||||
const { user: me, token } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:user-dumps-full:${username ?? ""}`,
|
||||
hydrateDump,
|
||||
);
|
||||
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setState({ status: "loading" });
|
||||
|
||||
if (cached) {
|
||||
fetch(`${API_URL}/api/users/${username}`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("User not found");
|
||||
setState({
|
||||
status: "loaded",
|
||||
profileUser: deserializePublicUser(body.data),
|
||||
dumps: cached.items,
|
||||
hasMore: cached.hasMore,
|
||||
page: cached.page,
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeaders: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
Promise.all([
|
||||
fetch(`${API_URL}/api/users/${username}`),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
|
||||
{ headers: authHeaders },
|
||||
),
|
||||
])
|
||||
.then(([userRes, dumpsRes]) =>
|
||||
Promise.all([userRes.json(), dumpsRes.json()])
|
||||
)
|
||||
.then(([userBody, dumpsBody]) => {
|
||||
if (!userBody.success) throw new Error("User not found");
|
||||
const { items, hasMore } = dumpsBody.success
|
||||
? dumpsBody.data as PaginatedData<RawDump>
|
||||
: { items: [], hasMore: false };
|
||||
setState({
|
||||
status: "loaded",
|
||||
profileUser: deserializePublicUser(userBody.data),
|
||||
dumps: items.map(deserializeDump),
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
}, [username]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
|
||||
!username
|
||||
) return;
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [state, username, token]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const onScroll = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
saveState(state.dumps, state.page, state.hasMore, globalThis.scrollY);
|
||||
}, 100);
|
||||
};
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [state, saveState]);
|
||||
|
||||
const scrollRestored = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (cached?.scrollY == null || scrollRestored.current) return;
|
||||
if (state.status === "loaded") {
|
||||
globalThis.scrollTo(0, cached.scrollY);
|
||||
scrollRestored.current = true;
|
||||
}
|
||||
}, [state.status, cached]);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading">Loading…</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<PageError
|
||||
message={state.error}
|
||||
actions={
|
||||
<Link to={`/users/${username}`} className="logout-btn">
|
||||
← Back to profile
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { profileUser, dumps, hasMore, loadingMore } = state;
|
||||
const isOwnProfile = me?.username === profileUser.username;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="profile-subpage-header">
|
||||
<Link
|
||||
to={`/users/${username}`}
|
||||
className="profile-subpage-back"
|
||||
>
|
||||
← {profileUser.username}
|
||||
</Link>
|
||||
<div className="profile-subpage-title-row">
|
||||
<Avatar
|
||||
userId={profileUser.id}
|
||||
username={profileUser.username}
|
||||
hasAvatar={!!profileUser.avatarMime}
|
||||
size={36}
|
||||
/>
|
||||
<h1 className="profile-subpage-title">Dumps</h1>
|
||||
{isOwnProfile && (
|
||||
<button
|
||||
type="button"
|
||||
className="new-playlist-toggle"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
+ New dump
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createModalOpen && (
|
||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||
)}
|
||||
|
||||
{dumps.length === 0
|
||||
? <p className="empty-state">Nothing here yet.</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{dumps.map((dump) => (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!me}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwner={isOwnProfile}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
{!hasMore && dumps.length > 0 && (
|
||||
<p className="index-status">All {dumps.length} dumps loaded.</p>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
494
src/pages/UserPlaylists.tsx
Normal file
494
src/pages/UserPlaylists.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type {
|
||||
PaginatedData,
|
||||
Playlist,
|
||||
PublicUser,
|
||||
RawPlaylist,
|
||||
} from "../model.ts";
|
||||
import { deserializePlaylist, deserializePublicUser } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const hydratePlaylist = (raw: Playlist): Playlist =>
|
||||
deserializePlaylist(raw as unknown as RawPlaylist);
|
||||
|
||||
interface PlaylistFeed {
|
||||
items: Playlist[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
profileUser: PublicUser;
|
||||
created: PlaylistFeed;
|
||||
followed: PlaylistFeed;
|
||||
};
|
||||
|
||||
function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
|
||||
return { items, hasMore, page: 1, loadingMore: false };
|
||||
}
|
||||
|
||||
export function UserPlaylists() {
|
||||
const { username } = useParams();
|
||||
const { user: me, authFetch, token } = useAuth();
|
||||
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
||||
|
||||
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
|
||||
Playlist
|
||||
>(
|
||||
`feed:user-playlists:${username ?? ""}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
const { cached: cachedFollowed, saveState: saveFollowed } = useFeedCache<
|
||||
Playlist
|
||||
>(
|
||||
`feed:user-followed-playlists:${username ?? ""}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setState({ status: "loading" });
|
||||
|
||||
const authHeaders: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
|
||||
if (cachedCreated && cachedFollowed) {
|
||||
fetch(`${API_URL}/api/users/${username}`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("User not found");
|
||||
setState({
|
||||
status: "loaded",
|
||||
profileUser: deserializePublicUser(body.data),
|
||||
created: {
|
||||
items: cachedCreated.items,
|
||||
hasMore: cachedCreated.hasMore,
|
||||
page: cachedCreated.page,
|
||||
loadingMore: false,
|
||||
},
|
||||
followed: {
|
||||
items: cachedFollowed.items,
|
||||
hasMore: cachedFollowed.hasMore,
|
||||
page: cachedFollowed.page,
|
||||
loadingMore: false,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
fetch(`${API_URL}/api/users/${username}`),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
|
||||
{ headers: authHeaders },
|
||||
),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${PAGE_SIZE}`,
|
||||
),
|
||||
])
|
||||
.then(([userRes, createdRes, followedRes]) =>
|
||||
Promise.all([userRes.json(), createdRes.json(), followedRes.json()])
|
||||
)
|
||||
.then(([userBody, createdBody, followedBody]) => {
|
||||
if (!userBody.success) throw new Error("User not found");
|
||||
const createdData = createdBody.success
|
||||
? createdBody.data as PaginatedData<RawPlaylist>
|
||||
: { items: [], hasMore: false };
|
||||
const followedData = followedBody.success
|
||||
? followedBody.data as PaginatedData<RawPlaylist>
|
||||
: { items: [], hasMore: false };
|
||||
setState({
|
||||
status: "loaded",
|
||||
profileUser: deserializePublicUser(userBody.data),
|
||||
created: initialFeed(
|
||||
createdData.items.map(deserializePlaylist),
|
||||
createdData.hasMore,
|
||||
),
|
||||
followed: initialFeed(
|
||||
followedData.items.map(deserializePlaylist),
|
||||
followedData.hasMore,
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
}, [username]);
|
||||
|
||||
const loadMoreCreated = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.created.hasMore ||
|
||||
state.created.loadingMore || !username
|
||||
) return;
|
||||
const nextPage = state.created.page + 1;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, created: { ...s.created, loadingMore: true } }
|
||||
: s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
created: {
|
||||
items: [...s.created.items, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, created: { ...s.created, loadingMore: false } }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}, [state, username, token]);
|
||||
|
||||
const loadMoreFollowed = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.followed.hasMore ||
|
||||
state.followed.loadingMore || !username
|
||||
) return;
|
||||
const nextPage = state.followed.page + 1;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, followed: { ...s.followed, loadingMore: true } }
|
||||
: s
|
||||
);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
followed: {
|
||||
items: [...s.followed.items, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, followed: { ...s.followed, loadingMore: false } }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}, [state, username]);
|
||||
|
||||
const createdSentinelRef = useInfiniteScroll(
|
||||
loadMoreCreated,
|
||||
state.status === "loaded" && state.created.hasMore &&
|
||||
!state.created.loadingMore,
|
||||
);
|
||||
const followedSentinelRef = useInfiniteScroll(
|
||||
loadMoreFollowed,
|
||||
state.status === "loaded" && state.followed.hasMore &&
|
||||
!state.followed.loadingMore,
|
||||
);
|
||||
|
||||
// Real-time WS playlist updates
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || state.status !== "loaded") return;
|
||||
const ev = lastPlaylistEvent;
|
||||
const isOwnProfile = me?.username === state.profileUser.username;
|
||||
|
||||
if (ev.type === "created" && ev.playlist?.userId === state.profileUser.id) {
|
||||
if (ev.playlist.isPublic || isOwnProfile) {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.created.items.some((p) => p.id === ev.playlist!.id)) return s;
|
||||
return {
|
||||
...s,
|
||||
created: {
|
||||
...s.created,
|
||||
items: [ev.playlist!, ...s.created.items],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (ev.type === "updated") {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const updatedCreated = ev.playlist?.userId === state.profileUser.id
|
||||
? s.created.items
|
||||
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
|
||||
.filter((p) => p.isPublic || isOwnProfile)
|
||||
: s.created.items;
|
||||
const updatedFollowed = s.followed.items.map((p) =>
|
||||
p.id === ev.playlist?.id ? ev.playlist! : p
|
||||
).filter((p) => p.isPublic);
|
||||
return {
|
||||
...s,
|
||||
created: { ...s.created, items: updatedCreated },
|
||||
followed: { ...s.followed, items: updatedFollowed },
|
||||
};
|
||||
});
|
||||
} else if (ev.type === "deleted") {
|
||||
setState((s) =>
|
||||
s.status !== "loaded" ? s : {
|
||||
...s,
|
||||
created: {
|
||||
...s.created,
|
||||
items: s.created.items.filter((p) => p.id !== ev.playlistId),
|
||||
},
|
||||
followed: {
|
||||
...s.followed,
|
||||
items: s.followed.items.filter((p) => p.id !== ev.playlistId),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [lastPlaylistEvent, me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deletedPlaylistIds.size || state.status !== "loaded") return;
|
||||
setState((s) =>
|
||||
s.status !== "loaded" ? s : {
|
||||
...s,
|
||||
created: {
|
||||
...s.created,
|
||||
items: s.created.items.filter((p) => !deletedPlaylistIds.has(p.id)),
|
||||
},
|
||||
followed: {
|
||||
...s.followed,
|
||||
items: s.followed.items.filter((p) => !deletedPlaylistIds.has(p.id)),
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [deletedPlaylistIds]);
|
||||
|
||||
// Scroll save
|
||||
useEffect(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const onScroll = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
const y = globalThis.scrollY;
|
||||
saveCreated(
|
||||
state.created.items,
|
||||
state.created.page,
|
||||
state.created.hasMore,
|
||||
y,
|
||||
);
|
||||
saveFollowed(
|
||||
state.followed.items,
|
||||
state.followed.page,
|
||||
state.followed.hasMore,
|
||||
y,
|
||||
);
|
||||
}, 100);
|
||||
};
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [state, saveCreated, saveFollowed]);
|
||||
|
||||
const scrollRestored = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (cachedCreated?.scrollY == null || scrollRestored.current) return;
|
||||
if (state.status === "loaded") {
|
||||
globalThis.scrollTo(0, cachedCreated.scrollY);
|
||||
scrollRestored.current = true;
|
||||
}
|
||||
}, [state.status, cachedCreated]);
|
||||
|
||||
const handleDelete = async (playlistId: string) => {
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
created: {
|
||||
...s.created,
|
||||
items: s.created.items.filter((p) => p.id !== playlistId),
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading">Loading…</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<PageError
|
||||
message={state.error}
|
||||
actions={
|
||||
<Link to={`/users/${username}`} className="logout-btn">
|
||||
← Back to profile
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { profileUser, created, followed } = state;
|
||||
const isOwnProfile = me?.username === profileUser.username;
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="profile-subpage-header">
|
||||
<Link to={`/users/${username}`} className="profile-subpage-back">
|
||||
← {profileUser.username}
|
||||
</Link>
|
||||
<div className="profile-subpage-title-row">
|
||||
<Avatar
|
||||
userId={profileUser.id}
|
||||
username={profileUser.username}
|
||||
hasAvatar={!!profileUser.avatarMime}
|
||||
size={36}
|
||||
/>
|
||||
<h1 className="profile-subpage-title">Playlists</h1>
|
||||
{isOwnProfile && (
|
||||
<NewPlaylistForm
|
||||
toggleClassName="btn-primary"
|
||||
onCreated={(p) =>
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.created.items.some((pl) => pl.id === p.id)) return s;
|
||||
return {
|
||||
...s,
|
||||
created: { ...s.created, items: [p, ...s.created.items] },
|
||||
};
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="profile-section">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
Created ({created.items.length}
|
||||
{created.hasMore ? "+" : ""})
|
||||
</h2>
|
||||
</div>
|
||||
{created.items.length === 0
|
||||
? <p className="empty-state">No playlists yet.</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{created.items.map((p) => (
|
||||
<PlaylistCard
|
||||
key={p.id}
|
||||
playlist={p}
|
||||
isOwner={isOwnProfile}
|
||||
onDelete={isOwnProfile
|
||||
? () => setConfirmDeleteId(p.id)
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div ref={createdSentinelRef} />
|
||||
{created.loadingMore && (
|
||||
<p className="feed-loading-more">Loading more…</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="profile-section">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
Followed ({followed.items.length}
|
||||
{followed.hasMore ? "+" : ""})
|
||||
</h2>
|
||||
</div>
|
||||
{followed.items.length === 0
|
||||
? <p className="empty-state">No followed playlists yet.</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{followed.items.map((p) => (
|
||||
<PlaylistCard key={p.id} playlist={p} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div ref={followedSentinelRef} />
|
||||
{followed.loadingMore && (
|
||||
<p className="feed-loading-more">Loading more…</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{confirmDeleteId && (
|
||||
<ConfirmModal
|
||||
message="Delete this playlist? This cannot be undone."
|
||||
confirmLabel="Delete playlist"
|
||||
onConfirm={() => {
|
||||
handleDelete(confirmDeleteId);
|
||||
setConfirmDeleteId(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDeleteId(null)}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
|
||||
@@ -19,15 +19,66 @@ import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
||||
import { FollowUserButton } from "../components/FollowButton.tsx";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const hydrateDump = (raw: Dump): Dump => deserializeDump(raw as unknown as RawDump);
|
||||
function InviteButton() {
|
||||
const { authFetch } = useAuth();
|
||||
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function generate() {
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
|
||||
const body = await res.json();
|
||||
if (body.success) {
|
||||
const url =
|
||||
`${globalThis.location.origin}/register?token=${body.data.token}`;
|
||||
setInviteUrl(url);
|
||||
} else {
|
||||
setError("Failed to generate invite");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to generate invite");
|
||||
}
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!inviteUrl) return;
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
if (inviteUrl) {
|
||||
return (
|
||||
<div className="invite-result">
|
||||
<span className="invite-url">{inviteUrl}</span>
|
||||
<button type="button" className="invite-copy-btn" onClick={copy}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="invite-generate">
|
||||
<button type="button" className="invite-btn" onClick={generate}>
|
||||
+ Invite someone
|
||||
</button>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hydrateDump = (raw: Dump): Dump =>
|
||||
deserializeDump(raw as unknown as RawDump);
|
||||
const hydratePlaylist = (raw: Playlist): Playlist =>
|
||||
deserializePlaylist(raw as unknown as RawPlaylist);
|
||||
|
||||
@@ -75,7 +126,9 @@ export function UserPublicProfile() {
|
||||
`feed:profile-votes:${username ?? ""}`,
|
||||
hydrateDump,
|
||||
);
|
||||
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<Playlist>(
|
||||
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
|
||||
Playlist
|
||||
>(
|
||||
`feed:profile-playlists:${username ?? ""}`,
|
||||
hydratePlaylist,
|
||||
);
|
||||
@@ -104,31 +157,64 @@ export function UserPublicProfile() {
|
||||
setState({
|
||||
status: "loaded",
|
||||
user: deserializePublicUser(body.data),
|
||||
dumps: { items: cachedDumps.items, hasMore: cachedDumps.hasMore, page: cachedDumps.page, loadingMore: false },
|
||||
votes: { items: cachedVotes.items, hasMore: cachedVotes.hasMore, page: cachedVotes.page, loadingMore: false },
|
||||
playlists: { items: cachedPlaylists.items, hasMore: cachedPlaylists.hasMore, page: cachedPlaylists.page, loadingMore: false },
|
||||
dumps: {
|
||||
items: cachedDumps.items,
|
||||
hasMore: cachedDumps.hasMore,
|
||||
page: cachedDumps.page,
|
||||
loadingMore: false,
|
||||
},
|
||||
votes: {
|
||||
items: cachedVotes.items,
|
||||
hasMore: cachedVotes.hasMore,
|
||||
page: cachedVotes.page,
|
||||
loadingMore: false,
|
||||
},
|
||||
playlists: {
|
||||
items: cachedPlaylists.items,
|
||||
hasMore: cachedPlaylists.hasMore,
|
||||
page: cachedPlaylists.page,
|
||||
loadingMore: false,
|
||||
},
|
||||
});
|
||||
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" })
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load profile",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
const authHeaders: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
|
||||
fetch(`${API_URL}/api/users/${username}`),
|
||||
fetch(`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
||||
fetch(`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
||||
fetch(`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
|
||||
{ headers: authHeaders },
|
||||
),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
|
||||
{ headers: authHeaders },
|
||||
),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
|
||||
{ headers: authHeaders },
|
||||
),
|
||||
]);
|
||||
|
||||
if (!userRes.ok) {
|
||||
throw new Error(
|
||||
userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`,
|
||||
userRes.status === 404
|
||||
? "User not found"
|
||||
: `HTTP ${userRes.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,7 +240,10 @@ export function UserPublicProfile() {
|
||||
setState({
|
||||
status: "loaded",
|
||||
user: deserializePublicUser(userBody.data),
|
||||
dumps: initialList(dumpsData.items.map(deserializeDump), dumpsData.hasMore),
|
||||
dumps: initialList(
|
||||
dumpsData.items.map(deserializeDump),
|
||||
dumpsData.hasMore,
|
||||
),
|
||||
votes: initialList(voteItems, votesData.hasMore),
|
||||
playlists: initialList(
|
||||
playlistsData.items.map(deserializePlaylist),
|
||||
@@ -189,7 +278,10 @@ export function UserPublicProfile() {
|
||||
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
|
||||
);
|
||||
if (toAdd.length === 0) return s;
|
||||
return { ...s, votes: { ...s.votes, items: [...toAdd, ...s.votes.items] } };
|
||||
return {
|
||||
...s,
|
||||
votes: { ...s.votes, items: [...toAdd, ...s.votes.items] },
|
||||
};
|
||||
});
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
}, [myVotes, me, profileUserId]);
|
||||
@@ -219,10 +311,16 @@ export function UserPublicProfile() {
|
||||
if (!body.success) return;
|
||||
const dump = deserializeDump(body.data);
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || s.votes.items.some((d) => d.id === dumpId)) {
|
||||
if (
|
||||
s.status !== "loaded" ||
|
||||
s.votes.items.some((d) => d.id === dumpId)
|
||||
) {
|
||||
return s;
|
||||
}
|
||||
return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] } };
|
||||
return {
|
||||
...s,
|
||||
votes: { ...s.votes, items: [dump, ...s.votes.items] },
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -243,7 +341,10 @@ export function UserPublicProfile() {
|
||||
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: { ...s.playlists, items: [ev.playlist!, ...s.playlists.items] },
|
||||
playlists: {
|
||||
...s.playlists,
|
||||
items: [ev.playlist!, ...s.playlists.items],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -278,7 +379,9 @@ export function UserPublicProfile() {
|
||||
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const filtered = s.playlists.items.filter((p) => !deletedPlaylistIds.has(p.id));
|
||||
const filtered = s.playlists.items.filter((p) =>
|
||||
!deletedPlaylistIds.has(p.id)
|
||||
);
|
||||
if (filtered.length === s.playlists.items.length) return s;
|
||||
return { ...s, playlists: { ...s.playlists, items: filtered } };
|
||||
});
|
||||
@@ -292,14 +395,22 @@ export function UserPublicProfile() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
const y = window.scrollY;
|
||||
const y = globalThis.scrollY;
|
||||
saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y);
|
||||
saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y);
|
||||
savePlaylists(state.playlists.items, state.playlists.page, state.playlists.hasMore, y);
|
||||
savePlaylists(
|
||||
state.playlists.items,
|
||||
state.playlists.page,
|
||||
state.playlists.hasMore,
|
||||
y,
|
||||
);
|
||||
}, 100);
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [state, saveDumps, saveVotes, savePlaylists]);
|
||||
|
||||
// Restore scroll position after cache restoration
|
||||
@@ -307,94 +418,10 @@ export function UserPublicProfile() {
|
||||
useLayoutEffect(() => {
|
||||
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
|
||||
if (state.status === "loaded") {
|
||||
window.scrollTo(0, cachedDumps.scrollY);
|
||||
globalThis.scrollTo(0, cachedDumps.scrollY);
|
||||
scrollRestored.current = true;
|
||||
}
|
||||
// cachedDumps is stable (read once), safe to omit
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.status]);
|
||||
|
||||
const loadMoreDumps = useCallback(() => {
|
||||
if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore || !username) return;
|
||||
const nextPage = state.dumps.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: true } } : s);
|
||||
fetch(`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
dumps: {
|
||||
items: [...s.dumps.items, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() => setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: false } } : s));
|
||||
}, [state, username, token]);
|
||||
|
||||
const loadMoreVotes = useCallback(() => {
|
||||
if (state.status !== "loaded" || !state.votes.hasMore || state.votes.loadingMore || !username) return;
|
||||
const nextPage = state.votes.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: true } } : s);
|
||||
fetch(`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
votes: {
|
||||
items: [...s.votes.items, ...items.map(deserializeDump)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() => setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: false } } : s));
|
||||
}, [state, username, token]);
|
||||
|
||||
const loadMorePlaylists = useCallback(() => {
|
||||
if (state.status !== "loaded" || !state.playlists.hasMore || state.playlists.loadingMore || !username) return;
|
||||
const nextPage = state.playlists.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: true } } : s);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
playlists: {
|
||||
items: [...s.playlists.items, ...items.map(deserializePlaylist)],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
},
|
||||
}
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch(() => setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: false } } : s));
|
||||
}, [state, username, token]);
|
||||
}, [state.status, cachedDumps]);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -432,7 +459,10 @@ export function UserPublicProfile() {
|
||||
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? { ...prev, user: { ...prev.user, avatarMime: body.data?.avatarMime } }
|
||||
? {
|
||||
...prev,
|
||||
user: { ...prev.user, avatarMime: body.data?.avatarMime },
|
||||
}
|
||||
: prev
|
||||
);
|
||||
} catch {
|
||||
@@ -504,11 +534,37 @@ export function UserPublicProfile() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="profile-username">{profileUser.username}</h1>
|
||||
{profileUser.invitedByUsername
|
||||
? (
|
||||
<p className="profile-invited-by">
|
||||
invited by{" "}
|
||||
<Link
|
||||
to={`/users/${profileUser.invitedByUsername}`}
|
||||
className="profile-invited-by-link"
|
||||
>
|
||||
@{profileUser.invitedByUsername}
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="profile-invited-by profile-invited-by--founding">
|
||||
O.G.
|
||||
</p>
|
||||
)}
|
||||
{avatarError && <p className="form-error">{avatarError}</p>}
|
||||
{!isOwnProfile && (
|
||||
<FollowUserButton
|
||||
targetUserId={profileUser.id}
|
||||
targetUsername={profileUser.username}
|
||||
/>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<button type="button" className="logout-btn" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
<div className="profile-own-actions">
|
||||
<InviteButton />
|
||||
<button type="button" className="logout-btn" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -523,9 +579,7 @@ export function UserPublicProfile() {
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwnProfile={isOwnProfile}
|
||||
hasMore={dumps.hasMore}
|
||||
loadingMore={dumps.loadingMore}
|
||||
onLoadMore={loadMoreDumps}
|
||||
viewAllHref={`/users/${profileUser.username}/dumps`}
|
||||
/>
|
||||
|
||||
<UpvotedDumpList
|
||||
@@ -537,16 +591,15 @@ export function UserPublicProfile() {
|
||||
canVote={!!me}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
hasMore={votes.hasMore}
|
||||
loadingMore={votes.loadingMore}
|
||||
onLoadMore={loadMoreVotes}
|
||||
viewAllHref={`/users/${profileUser.username}/upvoted`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="profile-section" id="playlists">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})
|
||||
Playlists ({playlists.items.length}
|
||||
{playlists.hasMore ? "+" : ""})
|
||||
</h2>
|
||||
{isOwnProfile && (
|
||||
<NewPlaylistForm
|
||||
@@ -556,7 +609,10 @@ export function UserPublicProfile() {
|
||||
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: { ...s.playlists, items: [p, ...s.playlists.items] },
|
||||
playlists: {
|
||||
...s.playlists,
|
||||
items: [p, ...s.playlists.items],
|
||||
},
|
||||
};
|
||||
})}
|
||||
/>
|
||||
@@ -567,38 +623,23 @@ export function UserPublicProfile() {
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{playlists.items.map((p) => (
|
||||
<PlaylistCard key={p.id} playlist={p} />
|
||||
<PlaylistCard key={p.id} playlist={p} isOwner={isOwnProfile} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<PlaylistSentinel
|
||||
hasMore={playlists.hasMore}
|
||||
loadingMore={playlists.loadingMore}
|
||||
onLoadMore={loadMorePlaylists}
|
||||
/>
|
||||
{playlists.items.length > 0 && (
|
||||
<Link
|
||||
to={`/users/${profileUser.username}/playlists`}
|
||||
className="profile-view-all"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sentinel wrapper (keeps hooks at top level) ──────────────────────────────
|
||||
|
||||
function PlaylistSentinel(
|
||||
{ hasMore, loadingMore, onLoadMore }: {
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
},
|
||||
) {
|
||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
||||
return (
|
||||
<>
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plain dump list ──────────────────────────────────────────────────────────
|
||||
|
||||
function DumpList(
|
||||
@@ -611,9 +652,7 @@ function DumpList(
|
||||
castVote,
|
||||
removeVote,
|
||||
isOwnProfile,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
viewAllHref,
|
||||
}: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
@@ -623,13 +662,10 @@ function DumpList(
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
isOwnProfile?: boolean;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
viewAllHref: string;
|
||||
},
|
||||
) {
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
||||
return (
|
||||
<section className="profile-section">
|
||||
<div className="profile-section-header">
|
||||
@@ -665,8 +701,9 @@ function DumpList(
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
{dumps.length > 0 && (
|
||||
<Link to={viewAllHref} className="profile-view-all">View all →</Link>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -683,9 +720,7 @@ function UpvotedDumpList(
|
||||
canVote,
|
||||
castVote,
|
||||
removeVote,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
viewAllHref,
|
||||
}: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
@@ -695,15 +730,14 @@ function UpvotedDumpList(
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
viewAllHref: string;
|
||||
},
|
||||
) {
|
||||
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
|
||||
const [fading, setFading] = useState<
|
||||
Record<string, "cooldown" | "dismissing">
|
||||
>({});
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
const prevVotedIds = useRef<Set<string> | null>(null);
|
||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
||||
|
||||
useEffect(() => () => {
|
||||
cancels.current.forEach((c) => c());
|
||||
@@ -809,8 +843,9 @@ function UpvotedDumpList(
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
{visibleDumps.length > 0 && (
|
||||
<Link to={viewAllHref} className="profile-view-all">View all →</Link>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SubmitEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { deserializeAuthResponse } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
|
||||
type UserRegisterState =
|
||||
type TokenState =
|
||||
| { status: "checking" }
|
||||
| { status: "invalid" }
|
||||
| { status: "valid" };
|
||||
|
||||
type FormState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "error"; error: string };
|
||||
@@ -15,13 +20,29 @@ type UserRegisterState =
|
||||
export function UserRegister() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get("token") ?? "";
|
||||
|
||||
const [state, setState] = useState<UserRegisterState>({ status: "idle" });
|
||||
const [tokenState, setTokenState] = useState<TokenState>({
|
||||
status: "checking",
|
||||
});
|
||||
const [formState, setFormState] = useState<FormState>({ status: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setTokenState({ status: "invalid" });
|
||||
return;
|
||||
}
|
||||
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
|
||||
.then((r) => {
|
||||
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
|
||||
})
|
||||
.catch(() => setTokenState({ status: "invalid" }));
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
setState({ status: "submitting" });
|
||||
setFormState({ status: "submitting" });
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("username");
|
||||
@@ -31,34 +52,56 @@ export function UserRegister() {
|
||||
const res = await fetch(`${API_URL}/api/users/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify({ username, password, inviteToken: token }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const apiResponse = await res.json();
|
||||
|
||||
if (apiResponse.success) {
|
||||
login(deserializeAuthResponse(apiResponse.data));
|
||||
navigate("/");
|
||||
} else {
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
setFormState({
|
||||
status: "error",
|
||||
error: apiResponse.error?.message ?? "Registration failed.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setState({
|
||||
setFormState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Registration failed.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (tokenState.status === "checking") {
|
||||
return (
|
||||
<PageShell centered>
|
||||
<p className="page-loading">Checking invite…</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenState.status === "invalid") {
|
||||
return (
|
||||
<PageShell centered>
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-card-title">Invalid invite</h1>
|
||||
<p className="auth-card-footer">
|
||||
This invite link is missing, expired, or already used.
|
||||
</p>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell centered>
|
||||
<div className="auth-card">
|
||||
<h1 className="auth-card-title">Register</h1>
|
||||
|
||||
{state.status === "error" && (
|
||||
<div className="error-banner">{state.error}</div>
|
||||
{formState.status === "error" && (
|
||||
<div className="error-banner">{formState.error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
@@ -67,7 +110,7 @@ export function UserRegister() {
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
disabled={formState.status === "submitting"}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
@@ -75,14 +118,14 @@ export function UserRegister() {
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
disabled={formState.status === "submitting"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={state.status === "submitting"}
|
||||
disabled={formState.status === "submitting"}
|
||||
>
|
||||
{state.status === "submitting" ? "Registering…" : "Register"}
|
||||
{formState.status === "submitting" ? "Registering…" : "Register"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
392
src/pages/UserUpvoted.tsx
Normal file
392
src/pages/UserUpvoted.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
||||
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const hydrateDump = (raw: Dump): Dump =>
|
||||
deserializeDump(raw as unknown as RawDump);
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "loaded";
|
||||
profileUser: PublicUser;
|
||||
votes: Dump[];
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
export function UserUpvoted() {
|
||||
const { username } = useParams();
|
||||
const { user: me, token } = useAuth();
|
||||
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
|
||||
const { cached, saveState } = useFeedCache<Dump>(
|
||||
`feed:user-upvoted-full:${username ?? ""}`,
|
||||
hydrateDump,
|
||||
);
|
||||
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
||||
const [fading, setFading] = useState<
|
||||
Record<string, "cooldown" | "dismissing">
|
||||
>({});
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
const prevVotedIds = useRef<Set<string> | null>(null);
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
cancels.current.forEach((c) => c());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setState({ status: "loading" });
|
||||
setVotedIds(new Set());
|
||||
prevVotedIds.current = null;
|
||||
prevMyVotesRef.current = null;
|
||||
|
||||
if (cached) {
|
||||
fetch(`${API_URL}/api/users/${username}`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("User not found");
|
||||
const voteIds = new Set(cached.items.map((d) => d.id));
|
||||
setState({
|
||||
status: "loaded",
|
||||
profileUser: deserializePublicUser(body.data),
|
||||
votes: cached.items,
|
||||
hasMore: cached.hasMore,
|
||||
page: cached.page,
|
||||
loadingMore: false,
|
||||
});
|
||||
setVotedIds(voteIds);
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeaders: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
Promise.all([
|
||||
fetch(`${API_URL}/api/users/${username}`),
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
|
||||
{ headers: authHeaders },
|
||||
),
|
||||
])
|
||||
.then(([userRes, votesRes]) =>
|
||||
Promise.all([userRes.json(), votesRes.json()])
|
||||
)
|
||||
.then(([userBody, votesBody]) => {
|
||||
if (!userBody.success) throw new Error("User not found");
|
||||
const { items, hasMore } = votesBody.success
|
||||
? votesBody.data as PaginatedData<RawDump>
|
||||
: { items: [], hasMore: false };
|
||||
const voteItems = items.map(deserializeDump);
|
||||
setState({
|
||||
status: "loaded",
|
||||
profileUser: deserializePublicUser(userBody.data),
|
||||
votes: voteItems,
|
||||
hasMore,
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
setVotedIds(new Set(voteItems.map((d) => d.id)));
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
})
|
||||
);
|
||||
}, [username]);
|
||||
|
||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||
|
||||
// Own profile: keep votedIds in sync with myVotes
|
||||
useEffect(() => {
|
||||
if (!profileUserId || me?.id !== profileUserId) return;
|
||||
setVotedIds(new Set(myVotes));
|
||||
if (prevMyVotesRef.current === null) {
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
return;
|
||||
}
|
||||
const prev = prevMyVotesRef.current;
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const voteIdSet = new Set(s.votes.map((d) => d.id));
|
||||
const toAdd = [...myVotes].filter((id) =>
|
||||
!prev.has(id) && !voteIdSet.has(id)
|
||||
);
|
||||
if (toAdd.length === 0) return s;
|
||||
// Newly voted items will arrive via lastVoteEvent fetch below
|
||||
return s;
|
||||
});
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
}, [myVotes, me, profileUserId]);
|
||||
|
||||
// WS vote events
|
||||
useEffect(() => {
|
||||
if (!lastVoteEvent || !profileUserId) return;
|
||||
const { dumpId, voterId, action } = lastVoteEvent;
|
||||
if (voterId !== profileUserId) return;
|
||||
|
||||
if (action === "remove") {
|
||||
setVotedIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
} else {
|
||||
setVotedIds((prev) => new Set([...prev, dumpId]));
|
||||
fetch(`${API_URL}/api/dumps/${dumpId}`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) return;
|
||||
const dump = deserializeDump(body.data);
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || s.votes.some((d) => d.id === dumpId)) {
|
||||
return s;
|
||||
}
|
||||
return { ...s, votes: [dump, ...s.votes] };
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [lastVoteEvent, profileUserId]);
|
||||
|
||||
// Fade animation when items leave votedIds
|
||||
useEffect(() => {
|
||||
if (prevVotedIds.current === null) {
|
||||
prevVotedIds.current = new Set(votedIds);
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = prevVotedIds.current;
|
||||
|
||||
for (const id of prev) {
|
||||
if (!votedIds.has(id) && !cancels.current.has(id)) {
|
||||
let dead = false;
|
||||
let kill = () => {};
|
||||
kill = () => {
|
||||
dead = true;
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
setFading((f) => ({ ...f, [id]: "cooldown" }));
|
||||
|
||||
const t1 = setTimeout(() => {
|
||||
if (dead) return;
|
||||
setFading((f) => ({ ...f, [id]: "dismissing" }));
|
||||
const t2 = setTimeout(() => {
|
||||
if (!dead) kill();
|
||||
}, 350);
|
||||
kill = () => {
|
||||
dead = true;
|
||||
clearTimeout(t2);
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
}, 2000);
|
||||
|
||||
kill = () => {
|
||||
dead = true;
|
||||
clearTimeout(t1);
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of votedIds) {
|
||||
if (!prev.has(id) && cancels.current.has(id)) {
|
||||
cancels.current.get(id)!();
|
||||
}
|
||||
}
|
||||
|
||||
prevVotedIds.current = new Set(votedIds);
|
||||
}, [votedIds]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
|
||||
!username
|
||||
) return;
|
||||
const nextPage = state.page + 1;
|
||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||
fetch(
|
||||
`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||
const newItems = items.map(deserializeDump);
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
votes: [...s.votes, ...newItems],
|
||||
hasMore,
|
||||
page: nextPage,
|
||||
loadingMore: false,
|
||||
}
|
||||
: s
|
||||
);
|
||||
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
|
||||
})
|
||||
.catch(() =>
|
||||
setState((s) =>
|
||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||
)
|
||||
);
|
||||
}, [state, username, token]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const onScroll = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
saveState(state.votes, state.page, state.hasMore, globalThis.scrollY);
|
||||
}, 100);
|
||||
};
|
||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
globalThis.removeEventListener("scroll", onScroll);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [state, saveState]);
|
||||
|
||||
const scrollRestored = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (cached?.scrollY == null || scrollRestored.current) return;
|
||||
if (state.status === "loaded") {
|
||||
globalThis.scrollTo(0, cached.scrollY);
|
||||
scrollRestored.current = true;
|
||||
}
|
||||
}, [state.status, cached]);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading">Loading…</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<PageError
|
||||
message={state.error}
|
||||
actions={
|
||||
<Link to={`/users/${username}`} className="logout-btn">
|
||||
← Back to profile
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { profileUser, votes, hasMore, loadingMore } = state;
|
||||
const visibleDumps = votes.filter((d) =>
|
||||
votedIds.has(d.id) || d.id in fading
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="profile-subpage-header">
|
||||
<Link to={`/users/${username}`} className="profile-subpage-back">
|
||||
← {profileUser.username}
|
||||
</Link>
|
||||
<div className="profile-subpage-title-row">
|
||||
<Avatar
|
||||
userId={profileUser.id}
|
||||
username={profileUser.username}
|
||||
hasAvatar={!!profileUser.avatarMime}
|
||||
size={36}
|
||||
/>
|
||||
<h1 className="profile-subpage-title">Upvoted</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleDumps.length === 0
|
||||
? <p className="empty-state">Nothing here yet.</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{visibleDumps.map((dump) => {
|
||||
const phase = fading[dump.id];
|
||||
const extraCls = phase === "cooldown"
|
||||
? "dump-card--fading"
|
||||
: phase === "dismissing"
|
||||
? "dump-card--dismissing"
|
||||
: undefined;
|
||||
return (
|
||||
<DumpCard
|
||||
key={dump.id}
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!me}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
className={extraCls}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||
{!hasMore && visibleDumps.length > 0 && (
|
||||
<p className="index-status">All {votes.length} upvoted dumps loaded.</p>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
43
src/utils/visited.ts
Normal file
43
src/utils/visited.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
const DUMP_KEY = "visited_dumps";
|
||||
const PLAYLIST_KEY = "visited_playlists";
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
function loadSet(key: string): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveSet(key: string, set: Set<string>): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify([...set]));
|
||||
} catch { /* quota exceeded — ignore */ }
|
||||
}
|
||||
|
||||
export function isDumpVisited(id: string): boolean {
|
||||
return loadSet(DUMP_KEY).has(id);
|
||||
}
|
||||
|
||||
export function isPlaylistVisited(id: string): boolean {
|
||||
return loadSet(PLAYLIST_KEY).has(id);
|
||||
}
|
||||
|
||||
export function markDumpVisited(id: string): void {
|
||||
const set = loadSet(DUMP_KEY);
|
||||
set.add(id);
|
||||
saveSet(DUMP_KEY, set);
|
||||
}
|
||||
|
||||
export function markPlaylistVisited(id: string): void {
|
||||
const set = loadSet(PLAYLIST_KEY);
|
||||
set.add(id);
|
||||
saveSet(PLAYLIST_KEY, set);
|
||||
}
|
||||
|
||||
/** Only items newer than MAX_AGE_MS are eligible to show the unread dot. */
|
||||
export function isRecent(createdAt: Date): boolean {
|
||||
return Date.now() - createdAt.getTime() < MAX_AGE_MS;
|
||||
}
|
||||
Reference in New Issue
Block a user