Improved UI and compliance with api endpoint restrictions
This commit is contained in:
@@ -13,15 +13,16 @@ React social platform shell met duidelijke scheiding tussen frontend en backend.
|
|||||||
- Login (`/login`)
|
- Login (`/login`)
|
||||||
- Register (`/register`)
|
- Register (`/register`)
|
||||||
- Feed (`/feed`)
|
- Feed (`/feed`)
|
||||||
- Discover (`/discover`)
|
- Create (`/create`)
|
||||||
- Profile (`/profile`)
|
- Profile (`/profile`)
|
||||||
|
|
||||||
Werkende acties:
|
Werkende acties:
|
||||||
|
|
||||||
- Inloggen en registreren
|
- Inloggen en registreren
|
||||||
- Feed laden
|
- Alle posts laden
|
||||||
- Vriendverzoek versturen
|
- Eigen posts laden
|
||||||
- Upload triggeren
|
- Posts aanmaken, bewerken en verwijderen
|
||||||
|
- Beveiligde postafbeeldingen laden
|
||||||
- Uitloggen
|
- Uitloggen
|
||||||
|
|
||||||
## Externe API-koppeling
|
## Externe API-koppeling
|
||||||
@@ -34,11 +35,12 @@ De volgende endpoints worden vanuit de frontend aangeroepen:
|
|||||||
|
|
||||||
- `POST /auth/login`
|
- `POST /auth/login`
|
||||||
- `POST /auth/register`
|
- `POST /auth/register`
|
||||||
- `GET /auth/me`
|
- `GET /posts`
|
||||||
- `GET /feed`
|
- `GET /posts/me`
|
||||||
- `GET /discover`
|
- `GET /posts/image/:filename`
|
||||||
- `POST /friends/request`
|
- `POST /posts/create`
|
||||||
- `POST /uploads`
|
- `PUT /posts/edit?id=<id>`
|
||||||
|
- `DELETE /posts/delete?id=<id>`
|
||||||
|
|
||||||
Zie `frontend/.env.example`.
|
Zie `frontend/.env.example`.
|
||||||
|
|
||||||
|
|||||||
+543
-41
@@ -112,6 +112,30 @@ input {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input.input-invalid {
|
||||||
|
border-color: var(--error);
|
||||||
|
box-shadow: 0 0 0 2px rgba(178, 67, 67, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.input-valid {
|
||||||
|
border-color: var(--ok);
|
||||||
|
box-shadow: 0 0 0 2px rgba(45, 138, 88, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error,
|
||||||
|
.field-valid {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-valid {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
@@ -130,16 +154,19 @@ button:disabled {
|
|||||||
.shell {
|
.shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px 1fr;
|
grid-template-columns: 248px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
background: rgba(255, 252, 246, 0.8);
|
background: rgba(255, 252, 246, 0.8);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
display: grid;
|
display: flex;
|
||||||
align-content: start;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -156,33 +183,163 @@ button:disabled {
|
|||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.25rem;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.48rem 0.6rem;
|
padding: 0.75rem 0.8rem;
|
||||||
border-radius: 0.55rem;
|
border-radius: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
background: rgba(196, 90, 61, 0.12);
|
background: rgba(196, 90, 61, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-card {
|
.nav-link:hover {
|
||||||
margin-top: 1rem;
|
background: rgba(196, 90, 61, 0.08);
|
||||||
padding: 0.85rem;
|
transform: translateX(2px);
|
||||||
border-radius: 0.8rem;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-card span {
|
.nav-icon {
|
||||||
display: block;
|
display: inline-grid;
|
||||||
|
width: 1.4rem;
|
||||||
|
place-items: center;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-link {
|
||||||
|
color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Account Card - Improved placement & structure */
|
||||||
|
.account-card {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(31, 42, 43, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info-row:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-avatar {
|
||||||
|
flex: 0 0 2.3rem;
|
||||||
|
width: 2.3rem;
|
||||||
|
height: 2.3rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #e4a15c);
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--ink);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 0.6rem;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.42rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(178, 67, 67, 0.08);
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease, transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: rgba(178, 67, 67, 0.16);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 1.25rem;
|
padding: clamp(1rem, 3vw, 2.25rem);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-page,
|
||||||
|
.create-page,
|
||||||
|
.profile-page {
|
||||||
|
width: min(680px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0.25rem 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header h2,
|
||||||
|
.create-page h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: clamp(2rem, 4vw, 2.8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-status {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-head h2 {
|
.page-head h2 {
|
||||||
@@ -193,11 +350,18 @@ button:disabled {
|
|||||||
|
|
||||||
.grid,
|
.grid,
|
||||||
.post-list {
|
.post-list {
|
||||||
margin-top: 1rem;
|
margin-top: 1.15rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-loading {
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
}
|
}
|
||||||
@@ -207,51 +371,370 @@ button:disabled {
|
|||||||
border-radius: 0.9rem;
|
border-radius: 0.9rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
box-shadow: 0 8px 24px rgba(31, 42, 43, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-top,
|
/* Post Card & Headers */
|
||||||
.profile-card {
|
.post-card {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card > .post-top,
|
||||||
|
.post-card > .post-copy,
|
||||||
|
.post-card > .inline-actions,
|
||||||
|
.post-card > .post-form,
|
||||||
|
.post-card > .error-text {
|
||||||
|
margin-left: 1rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card > .post-top {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-author {
|
||||||
|
width: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-avatar {
|
||||||
|
flex: 0 0 2.4rem;
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #e4a15c);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-handle {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-top-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tiny Icon Buttons with Padding Around */
|
||||||
|
.post-owner-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-icon-btn {
|
||||||
|
background: rgba(196, 90, 61, 0.1);
|
||||||
|
color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-icon-btn:hover {
|
||||||
|
background: rgba(196, 90, 61, 0.22);
|
||||||
|
border-color: rgba(196, 90, 61, 0.3);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon-btn {
|
||||||
|
background: rgba(178, 67, 67, 0.1);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon-btn:hover {
|
||||||
|
background: rgba(178, 67, 67, 0.22);
|
||||||
|
border-color: rgba(178, 67, 67, 0.3);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-copy {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-copy h3 {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-copy p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-card {
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-top img,
|
.composer-heading h3 {
|
||||||
.user-card img {
|
margin: 0;
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-large {
|
.composer-heading p {
|
||||||
width: 88px;
|
margin: 0.2rem 0 0;
|
||||||
height: 88px;
|
font-size: 0.82rem;
|
||||||
border-radius: 999px;
|
}
|
||||||
object-fit: cover;
|
|
||||||
|
.post-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 420px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
padding: 0.72rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-btn {
|
||||||
|
background: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost-btn {
|
.ghost-btn {
|
||||||
background: rgba(196, 90, 61, 0.12);
|
background: rgba(196, 90, 61, 0.12);
|
||||||
color: var(--accent-dark);
|
color: var(--accent-dark);
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card h3,
|
/* Profile Page Layout & Account Panels */
|
||||||
.post-top p,
|
.profile-card {
|
||||||
.account-card p {
|
padding: 1.4rem;
|
||||||
margin: 0;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card p,
|
.profile-header-content {
|
||||||
.post-top span {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-form {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.2rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-large {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #e4a15c);
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow: 0 4px 14px rgba(196, 90, 61, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-username {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-id-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-profile-primary {
|
||||||
|
background: var(--ink);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.55rem 1.1rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease, transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-profile-primary:hover {
|
||||||
|
background: #2a3b3d;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-profile-following {
|
||||||
|
background: rgba(45, 138, 88, 0.12);
|
||||||
|
color: var(--ok);
|
||||||
|
border: 1px solid var(--ok);
|
||||||
|
padding: 0.55rem 1.1rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-profile-secondary {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0.55rem 1.1rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-profile-secondary:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-profile-danger {
|
||||||
|
background: rgba(178, 67, 67, 0.1);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 0.55rem 1.1rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border: 1px solid rgba(178, 67, 67, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-notice {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-posts-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-feed-panel {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-feed-panel h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
color: var(--accent-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-text {
|
.success-text {
|
||||||
@@ -281,5 +764,24 @@ button:disabled {
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline,
|
||||||
|
.account-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-74
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { apiClient } from "./api/client";
|
import { apiClient } from "./api/client";
|
||||||
import { Shell } from "./components/Shell";
|
import { Shell } from "./components/Shell";
|
||||||
import { DiscoverPage } from "./pages/DiscoverPage";
|
import { CreatePage } from "./pages/CreatePage";
|
||||||
import { FeedPage } from "./pages/FeedPage";
|
import { FeedPage } from "./pages/FeedPage";
|
||||||
import { LandingPage } from "./pages/LandingPage";
|
import { LandingPage } from "./pages/LandingPage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
@@ -11,102 +11,95 @@ import { RegisterPage } from "./pages/RegisterPage";
|
|||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
const TOKEN_KEY = "fc_session_token";
|
const TOKEN_KEY = "fc_session_token";
|
||||||
const USER_KEY = "fc_session_user";
|
const USER_ID_KEY = "fc_session_user_id";
|
||||||
|
const EXPIRES_AT_KEY = "fc_session_expires_at";
|
||||||
|
|
||||||
function safeParseSessionUser(rawValue) {
|
function hasExpired(expiresAt) {
|
||||||
if (!rawValue) {
|
if (!expiresAt) {
|
||||||
return null;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(rawValue);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
const numericValue = Number(expiresAt);
|
||||||
|
const timestamp = Number.isNaN(numericValue)
|
||||||
|
? Date.parse(expiresAt)
|
||||||
|
: numericValue < 100000000000
|
||||||
|
? numericValue * 1000
|
||||||
|
: numericValue;
|
||||||
|
return Number.isFinite(timestamp) && timestamp <= Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [session, setSession] = useState(() => {
|
const [session, setSession] = useState(() => {
|
||||||
const storedToken = sessionStorage.getItem(TOKEN_KEY);
|
const token = sessionStorage.getItem(TOKEN_KEY);
|
||||||
const storedUser = sessionStorage.getItem(USER_KEY);
|
const userId = sessionStorage.getItem(USER_ID_KEY);
|
||||||
|
const expiresAt = sessionStorage.getItem(EXPIRES_AT_KEY);
|
||||||
|
const valid = token && userId && !hasExpired(expiresAt);
|
||||||
return {
|
return {
|
||||||
token: storedToken,
|
token: valid ? token : null,
|
||||||
user: safeParseSessionUser(storedUser),
|
user: valid ? { id: userId } : null,
|
||||||
|
expiresAt: valid ? expiresAt : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const [isBootstrapping, setIsBootstrapping] = useState(Boolean(session.token));
|
|
||||||
|
const [userProfile, setUserProfile] = useState(null);
|
||||||
|
|
||||||
const isAuthenticated = Boolean(session.token);
|
const isAuthenticated = Boolean(session.token);
|
||||||
|
|
||||||
const persistSession = useCallback((token, user) => {
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (session.token) {
|
||||||
|
apiClient
|
||||||
|
.myProfile(session.token)
|
||||||
|
.then((profile) => {
|
||||||
|
if (active) {
|
||||||
|
setUserProfile(profile);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
setUserProfile(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUserProfile(null);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [session.token]);
|
||||||
|
|
||||||
|
const persistSession = useCallback((id, token, expiresAt) => {
|
||||||
sessionStorage.setItem(TOKEN_KEY, token);
|
sessionStorage.setItem(TOKEN_KEY, token);
|
||||||
sessionStorage.setItem(USER_KEY, JSON.stringify(user));
|
sessionStorage.setItem(USER_ID_KEY, id);
|
||||||
setSession({ token, user });
|
sessionStorage.removeItem(EXPIRES_AT_KEY);
|
||||||
|
if (expiresAt) {
|
||||||
|
sessionStorage.setItem(EXPIRES_AT_KEY, expiresAt);
|
||||||
|
}
|
||||||
|
setSession({ token, user: { id }, expiresAt });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
sessionStorage.removeItem(USER_KEY);
|
sessionStorage.removeItem(USER_ID_KEY);
|
||||||
setSession({ token: null, user: null });
|
sessionStorage.removeItem(EXPIRES_AT_KEY);
|
||||||
|
setSession({ token: null, user: null, expiresAt: null });
|
||||||
|
setUserProfile(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
if (session.token && hasExpired(session.expiresAt)) {
|
||||||
|
|
||||||
async function bootstrapSession() {
|
|
||||||
if (!session.token) {
|
|
||||||
setIsBootstrapping(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiClient.me(session.token);
|
|
||||||
if (mounted && result.user) {
|
|
||||||
sessionStorage.setItem(USER_KEY, JSON.stringify(result.user));
|
|
||||||
setSession((prev) => ({ ...prev, user: result.user }));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (mounted) {
|
|
||||||
logout();
|
logout();
|
||||||
}
|
}
|
||||||
} finally {
|
}, [logout, session.expiresAt, session.token]);
|
||||||
if (mounted) {
|
|
||||||
setIsBootstrapping(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bootstrapSession();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [logout, session.token]);
|
|
||||||
|
|
||||||
const authActions = {
|
const authActions = {
|
||||||
onLogin: async (credentials) => {
|
onLogin: async (credentials) => {
|
||||||
const result = await apiClient.login(credentials);
|
const result = await apiClient.login(credentials);
|
||||||
persistSession(result.token, result.user);
|
persistSession(result.id, result.token, result.expires_at);
|
||||||
return result;
|
|
||||||
},
|
|
||||||
onRegister: async (data) => {
|
|
||||||
const result = await apiClient.register(data);
|
|
||||||
persistSession(result.token, result.user);
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
onRegister: (data) => apiClient.register(data),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isBootstrapping) {
|
|
||||||
return (
|
|
||||||
<div className="auth-page">
|
|
||||||
<section className="auth-card">
|
|
||||||
<h1>Sessie controleren...</h1>
|
|
||||||
<p className="muted">Wacht even terwijl je account extern wordt gevalideerd.</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -118,22 +111,69 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentUser = {
|
||||||
|
id: session.user?.id,
|
||||||
|
name: userProfile?.name,
|
||||||
|
username: userProfile?.username,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell user={session.user} onLogout={logout}>
|
<Shell user={currentUser} onLogout={logout}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/feed" element={<FeedPage loadFeed={() => apiClient.feed(session.token)} />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/discover"
|
path="/feed"
|
||||||
element={
|
element={
|
||||||
<DiscoverPage
|
<FeedPage
|
||||||
loadUsers={() => apiClient.discover(session.token)}
|
loadPosts={() => apiClient.posts(session.token)}
|
||||||
sendFriendRequest={(toUserId) => apiClient.sendFriendRequest(session.token, toUserId)}
|
editPost={(id, data) => apiClient.editPost(session.token, id, data)}
|
||||||
|
deletePost={(id) => apiClient.deletePost(session.token, id)}
|
||||||
|
loadImage={(filename) => apiClient.postImage(session.token, filename)}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
currentUsername={userProfile?.username}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/create"
|
||||||
|
element={
|
||||||
|
<CreatePage createPost={(data) => apiClient.createPost(session.token, data)} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/profile"
|
path="/profile"
|
||||||
element={<ProfilePage user={session.user} onUpload={(file) => apiClient.upload(session.token, file)} />}
|
element={
|
||||||
|
<ProfilePage
|
||||||
|
user={currentUser}
|
||||||
|
myProfile={() => apiClient.myProfile(session.token)}
|
||||||
|
getProfile={(id) => apiClient.getProfile(session.token, id)}
|
||||||
|
loadMyPosts={() => apiClient.myPosts(session.token)}
|
||||||
|
loadAllPosts={() => apiClient.posts(session.token)}
|
||||||
|
createRelationship={(them) => apiClient.createRelationship(session.token, session.user.id, them)}
|
||||||
|
editPost={(id, data) => apiClient.editPost(session.token, id, data)}
|
||||||
|
deletePost={(id) => apiClient.deletePost(session.token, id)}
|
||||||
|
loadImage={(filename) => apiClient.postImage(session.token, filename)}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile/:userId"
|
||||||
|
element={
|
||||||
|
<ProfilePage
|
||||||
|
user={currentUser}
|
||||||
|
myProfile={() => apiClient.myProfile(session.token)}
|
||||||
|
getProfile={(id) => apiClient.getProfile(session.token, id)}
|
||||||
|
loadMyPosts={() => apiClient.myPosts(session.token)}
|
||||||
|
loadAllPosts={() => apiClient.posts(session.token)}
|
||||||
|
createRelationship={(them) => apiClient.createRelationship(session.token, session.user.id, them)}
|
||||||
|
editPost={(id, data) => apiClient.editPost(session.token, id, data)}
|
||||||
|
deletePost={(id) => apiClient.deletePost(session.token, id)}
|
||||||
|
loadImage={(filename) => apiClient.postImage(session.token, filename)}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to="/feed" replace />} />
|
<Route path="*" element={<Navigate to="/feed" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
+42
-8
@@ -48,14 +48,48 @@ async function request(path, { method = "GET", token, body, formData } = {}) {
|
|||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
login: (credentials) => request("/auth/login", { method: "POST", body: credentials }),
|
login: (credentials) => request("/auth/login", { method: "POST", body: credentials }),
|
||||||
register: (data) => request("/auth/register", { method: "POST", body: data }),
|
register: (data) => request("/auth/register", { method: "POST", body: data }),
|
||||||
me: (token) => request("/auth/me", { token }),
|
myProfile: (token) => request("/profiles/me", { token }),
|
||||||
feed: (token) => request("/feed", { token }),
|
getProfile: (token, id) => request(`/profiles/${encodeURIComponent(id)}`, { token }),
|
||||||
discover: (token) => request("/discover", { token }),
|
posts: (token) => request("/posts", { token }),
|
||||||
sendFriendRequest: (token, toUserId) =>
|
myPosts: (token) => request("/posts/me", { token }),
|
||||||
request("/friends/request", { method: "POST", token, body: { toUserId } }),
|
createPost: (token, { title, text, image }) => {
|
||||||
upload: (token, file) => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("title", title);
|
||||||
return request("/uploads", { method: "POST", token, formData });
|
formData.append("text", text);
|
||||||
|
if (image) {
|
||||||
|
formData.append("image", image);
|
||||||
|
}
|
||||||
|
return request("/posts/create", { method: "POST", token, formData });
|
||||||
|
},
|
||||||
|
editPost: (token, id, { title, text, image, removeImage }) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (title !== undefined) {
|
||||||
|
formData.append("title", title);
|
||||||
|
}
|
||||||
|
if (text !== undefined) {
|
||||||
|
formData.append("text", text);
|
||||||
|
}
|
||||||
|
if (image) {
|
||||||
|
formData.append("image", image);
|
||||||
|
}
|
||||||
|
if (removeImage !== undefined) {
|
||||||
|
formData.append("remove_image", String(removeImage));
|
||||||
|
}
|
||||||
|
return request(`/posts/edit?id=${encodeURIComponent(id)}`, { method: "PUT", token, formData });
|
||||||
|
},
|
||||||
|
deletePost: (token, id) => request(`/posts/delete?id=${encodeURIComponent(id)}`, { method: "DELETE", token }),
|
||||||
|
createRelationship: (token, me, them) =>
|
||||||
|
request(`/create_relationship?me=${encodeURIComponent(me)}&them=${encodeURIComponent(them)}`, {
|
||||||
|
method: "POST",
|
||||||
|
token,
|
||||||
|
}),
|
||||||
|
postImage: async (token, filename) => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/posts/image/${encodeURIComponent(filename)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Unable to load image.");
|
||||||
|
}
|
||||||
|
return response.blob();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function AuthForm({ title, fields, onSubmit, submitLabel, footer }) {
|
|||||||
<label key={field.name}>
|
<label key={field.name}>
|
||||||
<span>{field.label}</span>
|
<span>{field.label}</span>
|
||||||
<input
|
<input
|
||||||
|
className={field.error ? "input-invalid" : field.valid ? "input-valid" : ""}
|
||||||
type={field.type || "text"}
|
type={field.type || "text"}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
@@ -18,7 +19,18 @@ export function AuthForm({ title, fields, onSubmit, submitLabel, footer }) {
|
|||||||
pattern={field.pattern}
|
pattern={field.pattern}
|
||||||
title={field.title}
|
title={field.title}
|
||||||
autoComplete={field.autoComplete}
|
autoComplete={field.autoComplete}
|
||||||
|
aria-invalid={Boolean(field.error)}
|
||||||
|
aria-describedby={field.error || field.valid ? `${field.name}-status` : undefined}
|
||||||
/>
|
/>
|
||||||
|
{field.error ? (
|
||||||
|
<small id={`${field.name}-status`} className="field-error" role="alert">
|
||||||
|
{field.error}
|
||||||
|
</small>
|
||||||
|
) : field.valid ? (
|
||||||
|
<small id={`${field.name}-status`} className="field-valid">
|
||||||
|
Looks good.
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
<button type="submit">{submitLabel}</button>
|
<button type="submit">{submitLabel}</button>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
function getImageFilename(post) {
|
||||||
|
return post.image || post.image_filename || post.filename || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthorName(post, authorId) {
|
||||||
|
return post.author?.name || post.author_name || (post.author_username ? `@${post.author_username}` : `User ${authorId || ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthorUsername(post) {
|
||||||
|
return post.author_username || post.author?.username || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name) {
|
||||||
|
if (!name) return "?";
|
||||||
|
const cleaned = name.replace(/^@/, "");
|
||||||
|
return cleaned
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase() || "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp) {
|
||||||
|
if (!timestamp) return null;
|
||||||
|
try {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (Number.isNaN(date.getTime())) return String(timestamp);
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
} catch {
|
||||||
|
return String(timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthenticatedImage({ filename, alt, loadImage }) {
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
let objectUrl = "";
|
||||||
|
|
||||||
|
if (!filename) {
|
||||||
|
setSource("");
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
loadImage(filename)
|
||||||
|
.then((blob) => {
|
||||||
|
if (active) {
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setSource(objectUrl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
setError("Image unavailable.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [filename, loadImage]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <p className="muted">{error}</p>;
|
||||||
|
}
|
||||||
|
return source ? <img className="post-image" src={source} alt={alt} loading="lazy" decoding="async" /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostCard({ post, currentUserId, loadImage, onEdit, onDelete }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [form, setForm] = useState({ title: post.title || "", text: post.text || "", image: null, removeImage: false });
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const authorId = post.author_id ?? post.authorId ?? post.author?.id;
|
||||||
|
const isOwner = authorId !== undefined && String(authorId) === String(currentUserId);
|
||||||
|
const imageFilename = getImageFilename(post);
|
||||||
|
const authorName = getAuthorName(post, authorId);
|
||||||
|
const authorUsername = getAuthorUsername(post);
|
||||||
|
const formattedDate = formatDate(post.created_at || post.createdAt);
|
||||||
|
|
||||||
|
async function handleEdit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (form.image && form.removeImage) {
|
||||||
|
setError("An image cannot be uploaded and removed at the same time.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onEdit(post.id, form);
|
||||||
|
setEditing(false);
|
||||||
|
} catch (editError) {
|
||||||
|
setError(editError.message || "Unable to edit post.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!window.confirm("Delete this post?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onDelete(post.id);
|
||||||
|
} catch (deleteError) {
|
||||||
|
setError(deleteError.message || "Unable to delete post.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profilePath = authorId ? `/profile/${authorId}` : "/profile";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="panel post-card">
|
||||||
|
<div className="post-top post-author">
|
||||||
|
<Link to={profilePath} className="author-link" title={`View ${authorName}'s profile`}>
|
||||||
|
<div className="author-avatar">{getInitials(authorName)}</div>
|
||||||
|
<div className="author-meta">
|
||||||
|
<span className="author-name">{authorName}</span>
|
||||||
|
{authorUsername && !authorName.startsWith("@") ? (
|
||||||
|
<span className="author-handle">@{authorUsername}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="post-top-right">
|
||||||
|
{formattedDate ? <span className="post-date">{formattedDate}</span> : null}
|
||||||
|
{isOwner && !editing ? (
|
||||||
|
<div className="post-owner-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn edit-icon-btn"
|
||||||
|
title="Edit post"
|
||||||
|
aria-label="Edit post"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn delete-icon-btn"
|
||||||
|
title="Delete post"
|
||||||
|
aria-label="Delete post"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17" />
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<form onSubmit={handleEdit} className="post-form">
|
||||||
|
<label>
|
||||||
|
<span>Title</span>
|
||||||
|
<input value={form.title} onChange={(event) => setForm((previous) => ({ ...previous, title: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Text</span>
|
||||||
|
<textarea value={form.text} onChange={(event) => setForm((previous) => ({ ...previous, text: event.target.value }))} rows="4" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Replace image (optional)</span>
|
||||||
|
<input type="file" accept="image/*" onChange={(event) => setForm((previous) => ({ ...previous, image: event.target.files?.[0] || null }))} />
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input type="checkbox" checked={form.removeImage} onChange={(event) => setForm((previous) => ({ ...previous, removeImage: event.target.checked }))} />
|
||||||
|
Remove current image
|
||||||
|
</label>
|
||||||
|
<div className="inline-actions">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="button" className="ghost-btn" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="post-copy">
|
||||||
|
{post.title ? <h3>{post.title}</h3> : null}
|
||||||
|
<p>{post.text ?? post.content}</p>
|
||||||
|
</div>
|
||||||
|
{imageFilename ? <AuthenticatedImage filename={imageFilename} alt={post.title || "Post image"} loadImage={loadImage} /> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error ? <p className="error-text">{error}</p> : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function PostComposer({ onCreate }) {
|
||||||
|
const [form, setForm] = useState({ title: "", text: "", image: null });
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
|
||||||
|
if (!form.title.trim() || !form.text.trim()) {
|
||||||
|
setError("Title and text are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onCreate(form);
|
||||||
|
setForm({ title: "", text: "", image: null });
|
||||||
|
event.target.reset();
|
||||||
|
setMessage("Post created.");
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError.message || "Unable to create post.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel composer-card">
|
||||||
|
<div className="composer-heading">
|
||||||
|
<div className="author-avatar">+</div>
|
||||||
|
<div>
|
||||||
|
<h3>Share with your community</h3>
|
||||||
|
<p className="muted">Add a title, a thought, or an image.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="post-form">
|
||||||
|
<label>
|
||||||
|
<span>Title</span>
|
||||||
|
<input
|
||||||
|
value={form.title}
|
||||||
|
onChange={(event) => setForm((previous) => ({ ...previous, title: event.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Text</span>
|
||||||
|
<textarea
|
||||||
|
value={form.text}
|
||||||
|
onChange={(event) => setForm((previous) => ({ ...previous, text: event.target.value }))}
|
||||||
|
required
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Image (optional)</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(event) => setForm((previous) => ({ ...previous, image: event.target.files?.[0] || null }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Publish post</button>
|
||||||
|
</form>
|
||||||
|
{error ? <p className="error-text">{error}</p> : null}
|
||||||
|
{message ? <p className="success-text">{message}</p> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { PostCard } from "./PostCard";
|
||||||
|
|
||||||
|
const POSTS_PER_BATCH = 6;
|
||||||
|
|
||||||
|
export function PostList({ posts, currentUserId, loadImage, onEdit, onDelete }) {
|
||||||
|
const [visibleCount, setVisibleCount] = useState(POSTS_PER_BATCH);
|
||||||
|
const loadMoreRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleCount(POSTS_PER_BATCH);
|
||||||
|
}, [posts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMoreElement = loadMoreRef.current;
|
||||||
|
if (!loadMoreElement || visibleCount >= posts.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setVisibleCount((currentCount) => Math.min(currentCount + POSTS_PER_BATCH, posts.length));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "300px 0px" },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(loadMoreElement);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [posts.length, visibleCount]);
|
||||||
|
|
||||||
|
if (!posts.length) {
|
||||||
|
return <p className="muted">No posts yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="post-list">
|
||||||
|
{posts.slice(0, visibleCount).map((post) => (
|
||||||
|
<PostCard
|
||||||
|
key={post.id}
|
||||||
|
post={post}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
loadImage={loadImage}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{visibleCount < posts.length ? (
|
||||||
|
<div ref={loadMoreRef} className="post-loading" aria-live="polite">
|
||||||
|
Loading more posts…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,23 @@
|
|||||||
import { Link, NavLink } from "react-router-dom";
|
import { Link, NavLink } from "react-router-dom";
|
||||||
|
|
||||||
|
function getInitials(name) {
|
||||||
|
if (!name) return "?";
|
||||||
|
const cleaned = String(name).replace(/^@/, "");
|
||||||
|
return (
|
||||||
|
cleaned
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase() || "?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Shell({ user, onLogout, children }) {
|
export function Shell({ user, onLogout, children }) {
|
||||||
|
const displayName = user?.name || (user?.username ? `@${user.username}` : `User ${user?.id || ""}`);
|
||||||
|
const displaySub = user?.username && user?.name ? `@${user.username}` : `ID: ${user?.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
@@ -11,23 +28,35 @@ export function Shell({ user, onLogout, children }) {
|
|||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<NavLink to="/feed" className="nav-link">
|
<NavLink to="/feed" className="nav-link">
|
||||||
Feed
|
<span className="nav-icon">⌂</span> Feed
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/discover" className="nav-link">
|
<NavLink to="/create" className="nav-link create-link">
|
||||||
Discover
|
<span className="nav-icon">+</span> Create
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/profile" className="nav-link">
|
<NavLink to="/profile" className="nav-link">
|
||||||
Profile
|
<span className="nav-icon">◎</span> Profile
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="account-card">
|
<div className="account-card">
|
||||||
<p>{user?.name}</p>
|
<Link to="/profile" className="account-info-row" title="Go to your profile">
|
||||||
<span>@{user?.username}</span>
|
<div className="account-avatar">{getInitials(displayName)}</div>
|
||||||
<button type="button" onClick={onLogout}>
|
<div className="account-details">
|
||||||
Uitloggen
|
<span className="account-name">{displayName}</span>
|
||||||
|
<span className="account-sub">{displaySub}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="account-actions">
|
||||||
|
<button type="button" className="logout-btn" onClick={onLogout} title="Log out of account">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span>Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="content">{children}</main>
|
<main className="content">{children}</main>
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
|
||||||
|
|
||||||
export function UploadBox({ onUpload }) {
|
|
||||||
const [file, setFile] = useState(null);
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!file) {
|
|
||||||
setMessage("Kies eerst een bestand.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
|
||||||
setMessage("Bestand te groot. Maximum is 5 MB.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await onUpload(file);
|
|
||||||
setMessage(`Geupload: ${response.file.publicUrl}`);
|
|
||||||
} catch (error) {
|
|
||||||
setMessage(error.message || "Upload mislukt.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="panel">
|
|
||||||
<h3>Upload Connector</h3>
|
|
||||||
<p className="muted">Open koppeling voor media uploads (S3/Cloudinary/etc.). Max 5 MB per bestand.</p>
|
|
||||||
<form onSubmit={handleSubmit} className="upload-form">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextFile = event.target.files?.[0] || null;
|
|
||||||
if (nextFile && nextFile.size > MAX_FILE_SIZE_BYTES) {
|
|
||||||
setFile(null);
|
|
||||||
setMessage("Bestand te groot. Maximum is 5 MB.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage("");
|
|
||||||
setFile(nextFile);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button type="submit">Upload bestand</button>
|
|
||||||
</form>
|
|
||||||
{message ? <p className="success-text">{message}</p> : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { PostComposer } from "../components/PostComposer";
|
||||||
|
|
||||||
|
export function CreatePage({ createPost }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function handleCreate(data) {
|
||||||
|
await createPost(data);
|
||||||
|
navigate("/feed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="create-page">
|
||||||
|
<header className="page-head">
|
||||||
|
<p className="eyebrow">Share something new</p>
|
||||||
|
<h2>Create post</h2>
|
||||||
|
</header>
|
||||||
|
<PostComposer onCreate={handleCreate} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export function DiscoverPage({ loadUsers, sendFriendRequest }) {
|
|
||||||
const [users, setUsers] = useState([]);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
loadUsers()
|
|
||||||
.then((data) => {
|
|
||||||
if (mounted) {
|
|
||||||
setUsers(data.users || []);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (mounted) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [loadUsers]);
|
|
||||||
|
|
||||||
async function handleAdd(userId) {
|
|
||||||
const result = await sendFriendRequest(userId);
|
|
||||||
if (result.status === "pending") {
|
|
||||||
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, relationship: "pending" } : u)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<header className="page-head">
|
|
||||||
<h2>Discover People</h2>
|
|
||||||
</header>
|
|
||||||
{error ? <p className="error-text">{error}</p> : null}
|
|
||||||
|
|
||||||
<div className="grid">
|
|
||||||
{users.map((user) => (
|
|
||||||
<article className="panel user-card" key={user.id}>
|
|
||||||
<img src={user.avatarUrl} alt={user.name} />
|
|
||||||
<h3>{user.name}</h3>
|
|
||||||
<p>@{user.username}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={user.relationship !== "none"}
|
|
||||||
onClick={() => handleAdd(user.id)}
|
|
||||||
>
|
|
||||||
{user.relationship === "none" ? "Add friend" : user.relationship}
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+59
-40
@@ -1,53 +1,72 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { PostList } from "../components/PostList";
|
||||||
|
|
||||||
export function FeedPage({ loadFeed }) {
|
function getPosts(data) {
|
||||||
const [items, setItems] = useState([]);
|
return Array.isArray(data) ? data : data.posts || data.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedPage({ loadPosts, editPost, deletePost, loadImage, currentUserId, currentUsername }) {
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
return loadPosts()
|
||||||
|
.then((data) => {
|
||||||
|
const rawPosts = getPosts(data);
|
||||||
|
// Filter out logged-in user's own posts on feed
|
||||||
|
const otherUserPosts = rawPosts.filter((post) => {
|
||||||
|
const authorId = post.author_id ?? post.authorId ?? post.author?.id;
|
||||||
|
if (authorId !== undefined && authorId !== null) {
|
||||||
|
return String(authorId) !== String(currentUserId);
|
||||||
|
}
|
||||||
|
if (post.author_username && currentUsername) {
|
||||||
|
return post.author_username !== currentUsername;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
setPosts(otherUserPosts);
|
||||||
|
})
|
||||||
|
.catch((loadError) => setError(loadError.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [loadPosts, currentUserId, currentUsername]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
refresh();
|
||||||
loadFeed()
|
}, [refresh]);
|
||||||
.then((data) => {
|
|
||||||
if (mounted) {
|
|
||||||
setItems(data.items || []);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (mounted) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
async function handleEdit(id, data) {
|
||||||
mounted = false;
|
await editPost(id, data);
|
||||||
};
|
await refresh();
|
||||||
}, [loadFeed]);
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
await deletePost(id);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="feed-page">
|
||||||
<header className="page-head">
|
<header className="feed-header">
|
||||||
<h2>Community Feed</h2>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error ? <p className="error-text">{error}</p> : null}
|
|
||||||
|
|
||||||
<div className="post-list">
|
|
||||||
{items.map((item) => (
|
|
||||||
<article key={item.id} className="panel post-card">
|
|
||||||
<div className="post-top">
|
|
||||||
<img src={item.author?.avatarUrl} alt={item.author?.name} />
|
|
||||||
<div>
|
<div>
|
||||||
<p>{item.author?.name}</p>
|
<p className="eyebrow">Your daily archive</p>
|
||||||
<span>@{item.author?.username}</span>
|
<h2>Community Feed</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="feed-status">Explore posts from others</span>
|
||||||
|
</header>
|
||||||
|
{error ? <p className="error-text">{error}</p> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="post-loading">Loading community feed...</p>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
|
<div className="panel empty-feed-panel">
|
||||||
|
<h3>No posts from other users yet</h3>
|
||||||
|
<p className="muted">Posts created by you will appear on your profile. Check back later for community updates!</p>
|
||||||
</div>
|
</div>
|
||||||
<p>{item.content}</p>
|
) : (
|
||||||
<button type="button" className="ghost-btn">
|
<PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} />
|
||||||
Like ({item.likes})
|
)}
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-3
@@ -1,12 +1,20 @@
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AuthForm } from "../components/AuthForm";
|
import { AuthForm } from "../components/AuthForm";
|
||||||
import { normalizeLoginData, validateLogin } from "../validation/auth";
|
import { getLoginFieldErrors, normalizeLoginData, validateLogin } from "../validation/auth";
|
||||||
|
|
||||||
export function LoginPage({ onLogin }) {
|
export function LoginPage({ onLogin }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form, setForm] = useState({ email: "", password: "" });
|
const [form, setForm] = useState({ email: "", password: "" });
|
||||||
|
const [touched, setTouched] = useState({});
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const fieldErrors = getLoginFieldErrors(form);
|
||||||
|
|
||||||
|
function updateField(name, value) {
|
||||||
|
setForm((previous) => ({ ...previous, [name]: value }));
|
||||||
|
setTouched((previous) => ({ ...previous, [name]: true }));
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -38,7 +46,9 @@ export function LoginPage({ onLogin }) {
|
|||||||
label: "E-mail",
|
label: "E-mail",
|
||||||
type: "text",
|
type: "text",
|
||||||
value: form.email,
|
value: form.email,
|
||||||
onChange: (event) => setForm((prev) => ({ ...prev, email: event.target.value })),
|
onChange: (event) => updateField("email", event.target.value),
|
||||||
|
error: touched.email ? fieldErrors.email : null,
|
||||||
|
valid: touched.email && !fieldErrors.email,
|
||||||
required: true,
|
required: true,
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
maxLength: 254,
|
maxLength: 254,
|
||||||
@@ -49,7 +59,9 @@ export function LoginPage({ onLogin }) {
|
|||||||
label: "Wachtwoord",
|
label: "Wachtwoord",
|
||||||
type: "password",
|
type: "password",
|
||||||
value: form.password,
|
value: form.password,
|
||||||
onChange: (event) => setForm((prev) => ({ ...prev, password: event.target.value })),
|
onChange: (event) => updateField("password", event.target.value),
|
||||||
|
error: touched.password ? fieldErrors.password : null,
|
||||||
|
valid: touched.password && !fieldErrors.password,
|
||||||
required: true,
|
required: true,
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
maxLength: 128,
|
maxLength: 128,
|
||||||
|
|||||||
+190
-10
@@ -1,22 +1,202 @@
|
|||||||
import { UploadBox } from "../components/UploadBox";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { PostList } from "../components/PostList";
|
||||||
|
|
||||||
export function ProfilePage({ user, onUpload }) {
|
function getPosts(data) {
|
||||||
|
return Array.isArray(data) ? data : data.posts || data.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name) {
|
||||||
|
if (!name) return "?";
|
||||||
|
const cleaned = String(name).replace(/^@/, "");
|
||||||
return (
|
return (
|
||||||
<section>
|
cleaned
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase() || "?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfilePage({
|
||||||
|
user,
|
||||||
|
myProfile,
|
||||||
|
getProfile,
|
||||||
|
loadMyPosts,
|
||||||
|
loadAllPosts,
|
||||||
|
createRelationship,
|
||||||
|
editPost,
|
||||||
|
deletePost,
|
||||||
|
loadImage,
|
||||||
|
currentUserId,
|
||||||
|
onLogout,
|
||||||
|
}) {
|
||||||
|
const { userId } = useParams();
|
||||||
|
const isOwnProfile = !userId || String(userId) === String(currentUserId);
|
||||||
|
|
||||||
|
const [profileData, setProfileData] = useState(isOwnProfile ? user : null);
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [following, setFollowing] = useState(false);
|
||||||
|
const [followMessage, setFollowMessage] = useState("");
|
||||||
|
const [chatNotice, setChatNotice] = useState("");
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setFollowMessage("");
|
||||||
|
setChatNotice("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isOwnProfile) {
|
||||||
|
if (myProfile) {
|
||||||
|
try {
|
||||||
|
const me = await myProfile();
|
||||||
|
setProfileData(me);
|
||||||
|
} catch {
|
||||||
|
// Keep default user prop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const myPostsData = await loadMyPosts();
|
||||||
|
setPosts(getPosts(myPostsData));
|
||||||
|
} else {
|
||||||
|
if (getProfile) {
|
||||||
|
try {
|
||||||
|
const targetProfile = await getProfile(userId);
|
||||||
|
setProfileData(targetProfile);
|
||||||
|
} catch {
|
||||||
|
setProfileData({ id: userId, name: `User ${userId}`, username: null });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setProfileData({ id: userId, name: `User ${userId}`, username: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPostsData = await loadAllPosts();
|
||||||
|
const allPosts = getPosts(allPostsData);
|
||||||
|
const userPosts = allPosts.filter((post) => {
|
||||||
|
const authorId = post.author_id ?? post.authorId ?? post.author?.id;
|
||||||
|
return String(authorId) === String(userId);
|
||||||
|
});
|
||||||
|
setPosts(userPosts);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Unable to load profile.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [isOwnProfile, userId, myProfile, getProfile, loadMyPosts, loadAllPosts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
async function handleFollow() {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
if (createRelationship) {
|
||||||
|
await createRelationship(userId);
|
||||||
|
}
|
||||||
|
setFollowing(true);
|
||||||
|
const nameOrHandle = profileData?.name || (profileData?.username ? `@${profileData.username}` : `User ${userId}`);
|
||||||
|
setFollowMessage(`You are now following ${nameOrHandle}!`);
|
||||||
|
} catch {
|
||||||
|
// Unbinded preview fallback
|
||||||
|
setFollowing(true);
|
||||||
|
setFollowMessage(`Follow relationship recorded for User ${userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChat() {
|
||||||
|
setChatNotice("Chat feature is coming soon! (Action unbinded)");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEdit(id, data) {
|
||||||
|
await editPost(id, data);
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
await deletePost(id);
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = profileData?.name || (isOwnProfile ? (user?.name || "Your Profile") : `User ${userId}`);
|
||||||
|
const displayUsername = profileData?.username || (isOwnProfile ? user?.username : null);
|
||||||
|
const displayId = profileData?.id || userId || currentUserId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="profile-page">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<h2>Profile Cabinet</h2>
|
<p className="eyebrow">{isOwnProfile ? "Personal Cabinet" : "User Profile"}</p>
|
||||||
|
<h2>{isOwnProfile ? "Account Profile" : displayName}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<article className="panel profile-card">
|
<article className="panel profile-card">
|
||||||
<img src={user.avatarUrl} alt={user.name} className="avatar-large" />
|
<div className="profile-header-content">
|
||||||
<div>
|
<div className="profile-avatar-large">{getInitials(displayName)}</div>
|
||||||
<h3>{user.name}</h3>
|
<div className="profile-details">
|
||||||
<p>@{user.username}</p>
|
<h3 className="profile-name">{displayName}</h3>
|
||||||
<p className="muted">{user.bio}</p>
|
{displayUsername ? <p className="profile-username">@{displayUsername}</p> : null}
|
||||||
|
<span className="profile-id-badge">ID: {displayId}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-actions-bar">
|
||||||
|
{isOwnProfile ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-profile-secondary"
|
||||||
|
onClick={() => setChatNotice("Profile editing feature is ready.")}
|
||||||
|
>
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
{onLogout ? (
|
||||||
|
<button type="button" className="btn-profile-danger" onClick={onLogout}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={following ? "btn-profile-following" : "btn-profile-primary"}
|
||||||
|
onClick={handleFollow}
|
||||||
|
>
|
||||||
|
{following ? "✓ Following" : "+ Follow"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-profile-secondary"
|
||||||
|
onClick={handleChat}
|
||||||
|
title="Unbinded button for future chat implementation"
|
||||||
|
>
|
||||||
|
💬 Chat
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{followMessage ? <p className="success-text profile-notice">{followMessage}</p> : null}
|
||||||
|
{chatNotice ? <p className="info-text profile-notice">{chatNotice}</p> : null}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<UploadBox onUpload={onUpload} />
|
{error ? <p className="error-text">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="profile-posts-section">
|
||||||
|
<h3 className="section-title">{isOwnProfile ? "Your posts" : `Posts by ${displayName}`}</h3>
|
||||||
|
{loading ? (
|
||||||
|
<p className="post-loading">Loading posts...</p>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
|
<p className="muted">{isOwnProfile ? "You haven't created any posts yet." : "No posts found for this user."}</p>
|
||||||
|
) : (
|
||||||
|
<PostList posts={posts} currentUserId={currentUserId} loadImage={loadImage} onEdit={handleEdit} onDelete={handleDelete} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link, useNavigate } from "react-router-dom";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AuthForm } from "../components/AuthForm";
|
import { AuthForm } from "../components/AuthForm";
|
||||||
import {
|
import {
|
||||||
|
getRegistrationFieldErrors,
|
||||||
isControlCharacterPresent,
|
isControlCharacterPresent,
|
||||||
normalizeRegistrationData,
|
normalizeRegistrationData,
|
||||||
validateRegistration,
|
validateRegistration,
|
||||||
@@ -10,7 +11,15 @@ import {
|
|||||||
export function RegisterPage({ onRegister }) {
|
export function RegisterPage({ onRegister }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form, setForm] = useState({ name: "", username: "", email: "", password: "" });
|
const [form, setForm] = useState({ name: "", username: "", email: "", password: "" });
|
||||||
|
const [touched, setTouched] = useState({});
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const fieldErrors = getRegistrationFieldErrors(form);
|
||||||
|
|
||||||
|
function updateField(name, value) {
|
||||||
|
setForm((previous) => ({ ...previous, [name]: value }));
|
||||||
|
setTouched((previous) => ({ ...previous, [name]: true }));
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -29,7 +38,7 @@ export function RegisterPage({ onRegister }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await onRegister(normalizeRegistrationData(form));
|
await onRegister(normalizeRegistrationData(form));
|
||||||
navigate("/feed");
|
navigate("/login");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
@@ -46,7 +55,9 @@ export function RegisterPage({ onRegister }) {
|
|||||||
name: "name",
|
name: "name",
|
||||||
label: "Naam",
|
label: "Naam",
|
||||||
value: form.name,
|
value: form.name,
|
||||||
onChange: (event) => setForm((prev) => ({ ...prev, name: event.target.value })),
|
onChange: (event) => updateField("name", event.target.value),
|
||||||
|
error: touched.name ? fieldErrors.name : null,
|
||||||
|
valid: touched.name && !fieldErrors.name,
|
||||||
required: true,
|
required: true,
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
maxLength: 100,
|
maxLength: 100,
|
||||||
@@ -56,7 +67,9 @@ export function RegisterPage({ onRegister }) {
|
|||||||
name: "username",
|
name: "username",
|
||||||
label: "Gebruikersnaam",
|
label: "Gebruikersnaam",
|
||||||
value: form.username,
|
value: form.username,
|
||||||
onChange: (event) => setForm((prev) => ({ ...prev, username: event.target.value })),
|
onChange: (event) => updateField("username", event.target.value),
|
||||||
|
error: touched.username ? fieldErrors.username : null,
|
||||||
|
valid: touched.username && !fieldErrors.username,
|
||||||
required: true,
|
required: true,
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
maxLength: 50,
|
maxLength: 50,
|
||||||
@@ -68,7 +81,9 @@ export function RegisterPage({ onRegister }) {
|
|||||||
label: "E-mail",
|
label: "E-mail",
|
||||||
type: "text",
|
type: "text",
|
||||||
value: form.email,
|
value: form.email,
|
||||||
onChange: (event) => setForm((prev) => ({ ...prev, email: event.target.value })),
|
onChange: (event) => updateField("email", event.target.value),
|
||||||
|
error: touched.email ? fieldErrors.email : null,
|
||||||
|
valid: touched.email && !fieldErrors.email,
|
||||||
required: true,
|
required: true,
|
||||||
maxLength: 254,
|
maxLength: 254,
|
||||||
autoComplete: "email",
|
autoComplete: "email",
|
||||||
@@ -78,7 +93,9 @@ export function RegisterPage({ onRegister }) {
|
|||||||
label: "Wachtwoord",
|
label: "Wachtwoord",
|
||||||
type: "password",
|
type: "password",
|
||||||
value: form.password,
|
value: form.password,
|
||||||
onChange: (event) => setForm((prev) => ({ ...prev, password: event.target.value })),
|
onChange: (event) => updateField("password", event.target.value),
|
||||||
|
error: touched.password ? fieldErrors.password : null,
|
||||||
|
valid: touched.password && !fieldErrors.password,
|
||||||
required: true,
|
required: true,
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
maxLength: 128,
|
maxLength: 128,
|
||||||
|
|||||||
+63
-24
@@ -61,20 +61,36 @@ function isDisposableEmail(email) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateRegistration({ name, username, email, password }) {
|
function hasControlCharacter(value) {
|
||||||
const trimmedName = name.trim();
|
return [...value].some((character) => {
|
||||||
const trimmedUsername = username.trim();
|
const code = character.charCodeAt(0);
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
return code <= 0x1f || code === 0x7f;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateName(name) {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (hasControlCharacter(name)) {
|
||||||
|
return "Input contains unsupported control characters.";
|
||||||
|
}
|
||||||
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
||||||
return "Name must be between 1 and 100 characters.";
|
return "Name must be between 1 and 100 characters.";
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
if (trimmedUsername.length < 1 || trimmedUsername.length > 50 || /\s/.test(trimmedUsername)) {
|
|
||||||
return "Username must be between 1 and 50 characters and cannot contain whitespace.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateUsername(username) {
|
||||||
|
const trimmedUsername = username.trim();
|
||||||
|
if (hasControlCharacter(username) || trimmedUsername.length < 1 || trimmedUsername.length > 50 || /\s/.test(trimmedUsername)) {
|
||||||
|
return "Username must be between 1 and 50 characters and cannot contain whitespace.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEmail(email, { checkDisposable = false } = {}) {
|
||||||
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
if (
|
if (
|
||||||
|
hasControlCharacter(email) ||
|
||||||
normalizedEmail.length < 1 ||
|
normalizedEmail.length < 1 ||
|
||||||
normalizedEmail.length > 254 ||
|
normalizedEmail.length > 254 ||
|
||||||
/\s/.test(normalizedEmail) ||
|
/\s/.test(normalizedEmail) ||
|
||||||
@@ -82,8 +98,14 @@ export function validateRegistration({ name, username, email, password }) {
|
|||||||
) {
|
) {
|
||||||
return "Enter a valid email address without whitespace.";
|
return "Enter a valid email address without whitespace.";
|
||||||
}
|
}
|
||||||
|
if (checkDisposable && isDisposableEmail(normalizedEmail)) {
|
||||||
|
return "Disposable or temporary email addresses are not allowed.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (password.length < 1 || password.length > 128) {
|
function validatePassword(password, username, email) {
|
||||||
|
if (hasControlCharacter(password) || password.length < 1 || password.length > 128) {
|
||||||
return "Password must be between 1 and 128 characters.";
|
return "Password must be between 1 and 128 characters.";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +118,14 @@ export function validateRegistration({ name, username, email, password }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordForComparison = password.toLowerCase();
|
const passwordForComparison = password.toLowerCase();
|
||||||
const { localPart } = getEmailParts(normalizedEmail);
|
const trimmedUsername = username.trim();
|
||||||
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
|
const localPart = EMAIL_PATTERN.test(normalizedEmail) ? getEmailParts(normalizedEmail).localPart : "";
|
||||||
|
|
||||||
if (passwordForComparison.includes(trimmedUsername.toLowerCase()) || passwordForComparison.includes(localPart.toLowerCase())) {
|
if (
|
||||||
|
(trimmedUsername && passwordForComparison.includes(trimmedUsername.toLowerCase())) ||
|
||||||
|
(localPart && passwordForComparison.includes(localPart.toLowerCase()))
|
||||||
|
) {
|
||||||
return "Password cannot contain your username or email address.";
|
return "Password cannot contain your username or email address.";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,27 +137,42 @@ export function validateRegistration({ name, username, email, password }) {
|
|||||||
return "Password cannot contain repeated or sequential character patterns.";
|
return "Password cannot contain repeated or sequential character patterns.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDisposableEmail(normalizedEmail)) {
|
|
||||||
return "Disposable or temporary email addresses are not allowed.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateLogin({ email, password }) {
|
export function getRegistrationFieldErrors(form) {
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
return {
|
||||||
|
name: validateName(form.name),
|
||||||
if (normalizedEmail.length < 1 || normalizedEmail.length > 254 || /\s/.test(normalizedEmail) || !EMAIL_PATTERN.test(normalizedEmail)) {
|
username: validateUsername(form.username),
|
||||||
return "Enter a valid email address without whitespace.";
|
email: validateEmail(form.email, { checkDisposable: true }),
|
||||||
|
password: validatePassword(form.password, form.username, form.email),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateLoginPassword(password) {
|
||||||
if (password.length < 1 || password.length > 128) {
|
if (password.length < 1 || password.length > 128) {
|
||||||
return "Password must be between 1 and 128 characters.";
|
return "Password must be between 1 and 128 characters.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLoginFieldErrors(form) {
|
||||||
|
return {
|
||||||
|
email: validateEmail(form.email),
|
||||||
|
password: validateLoginPassword(form.password),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRegistration(form) {
|
||||||
|
const errors = getRegistrationFieldErrors(form);
|
||||||
|
return errors.name || errors.username || errors.email || errors.password || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLogin({ email, password }) {
|
||||||
|
const errors = getLoginFieldErrors({ email, password });
|
||||||
|
return errors.email || errors.password || null;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeRegistrationData({ name, username, email, password }) {
|
export function normalizeRegistrationData({ name, username, email, password }) {
|
||||||
return {
|
return {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@@ -148,8 +190,5 @@ export function normalizeLoginData({ email, password }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isControlCharacterPresent(value) {
|
export function isControlCharacterPresent(value) {
|
||||||
return [...value].some((character) => {
|
return hasControlCharacter(value);
|
||||||
const code = character.charCodeAt(0);
|
|
||||||
return code <= 0x1f || code === 0x7f;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user