Compare commits

..

No commits in common. "9efcfff4844c775f98682bd2ceba77880b01e3d6" and "5ec899c9cb88c154663e97fcf10b09daefe3f4cd" have entirely different histories.

12 changed files with 102 additions and 262 deletions

View file

@ -1140,39 +1140,13 @@ export default function DownloadsPage() {
<td> <td>
{j.movieTitle} {j.movieTitle}
{j.movieYear ? ` (${j.movieYear})` : ""} {j.movieYear ? ` (${j.movieYear})` : ""}
{j.episodes && j.episodes.length > 0 ? ( {(() => {
<ul className="downloads-episode-list">
{j.episodes.map((ep) => (
<li key={ep.episodeNumber}>
<span>
E{String(ep.episodeNumber).padStart(2, "0")}
{ep.episodeTitle ? ` · ${ep.episodeTitle}` : ""}
{!ep.importedPath
? " · niet geïmporteerd"
: ep.subtitlePath
? " · sub OK"
: " · sub mist"}
</span>
{ep.importedPath && !ep.subtitlePath && (
<button
type="button"
onClick={() => void openSubtitlePicker(j, ep.episodeNumber)}
>
Sub
</button>
)}
</li>
))}
</ul>
) : (
(() => {
const sub = subtitleStatus(j); const sub = subtitleStatus(j);
if (!sub) return null; if (!sub || (j.episodes && j.episodes.length > 0)) return null;
if (sub.kind === "ok") return <div className="ok-text">{sub.text}</div>; if (sub.kind === "ok") return <div className="ok-text">{sub.text}</div>;
if (sub.kind === "pending") return <div className="muted">{sub.text}</div>; if (sub.kind === "pending") return <div className="muted">{sub.text}</div>;
return <div className="danger-text">{sub.text}</div>; return <div className="danger-text">{sub.text}</div>;
})() })()}
)}
</td> </td>
<td className="muted">{j.sourceTitle}</td> <td className="muted">{j.sourceTitle}</td>
<td>{j.nodeName}</td> <td>{j.nodeName}</td>

View file

@ -166,7 +166,6 @@ export default function ViewersPage() {
const [iptvLocalErr, setIptvLocalErr] = useState<Record<string, string | null>>({}); const [iptvLocalErr, setIptvLocalErr] = useState<Record<string, string | null>>({});
const [addonInstallUrls, setAddonInstallUrls] = useState<Record<string, string | null>>({}); const [addonInstallUrls, setAddonInstallUrls] = useState<Record<string, string | null>>({});
const [claimCodes, setClaimCodes] = useState<Record<string, string>>({}); const [claimCodes, setClaimCodes] = useState<Record<string, string>>({});
const [newPasswords, setNewPasswords] = useState<Record<string, string>>({});
const load = useCallback(() => { const load = useCallback(() => {
fetch("/api/v1/admin/viewers", { credentials: "include" }) fetch("/api/v1/admin/viewers", { credentials: "include" })
@ -585,7 +584,6 @@ export default function ViewersPage() {
async function unlockLogin(v: Viewer) { async function unlockLogin(v: Viewer) {
setActionId(`unlock-${v.id}`); setActionId(`unlock-${v.id}`);
setMsg(null);
setErr(null); setErr(null);
try { try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/unlock-login`, { const res = await fetch(`/api/v1/admin/viewers/${v.id}/unlock-login`, {
@ -604,35 +602,6 @@ export default function ViewersPage() {
} }
} }
async function changeViewerPassword(v: Viewer) {
const next = (newPasswords[v.id] ?? "").trim();
if (next.length < 8) {
setErr("Nieuw wachtwoord minstens 8 tekens");
return;
}
setActionId(`pw-${v.id}`);
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: next }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Wachtwoord wijzigen mislukt");
return;
}
setNewPasswords((prev) => ({ ...prev, [v.id]: "" }));
setMsg(`Wachtwoord bijgewerkt voor ${v.email}`);
load();
} finally {
setActionId(null);
}
}
async function deleteViewer(v: Viewer) { async function deleteViewer(v: Viewer) {
const label = v.name || v.email; const label = v.name || v.email;
if ( if (
@ -948,13 +917,8 @@ export default function ViewersPage() {
<p className="muted detail-copy"> <p className="muted detail-copy">
Bepaalt of deze gebruiker kan inloggen en de app-inhoud mag gebruiken. Bepaalt of deze gebruiker kan inloggen en de app-inhoud mag gebruiken.
</p> </p>
<div {(selected.loginLocked || (selected.loginFailedCount ?? 0) > 0) && (
className={ <div className="login-lock-box">
selected.loginLocked || (selected.loginFailedCount ?? 0) > 0
? "login-lock-box"
: "login-lock-box idle"
}
>
<p> <p>
{selected.loginLocked {selected.loginLocked
? `Login vergrendeld tot ${ ? `Login vergrendeld tot ${
@ -962,58 +926,19 @@ export default function ViewersPage() {
? new Date(selected.loginLockedUntil).toLocaleString("nl-NL") ? new Date(selected.loginLockedUntil).toLocaleString("nl-NL")
: "…" : "…"
} (3 foute pogingen).` } (3 foute pogingen).`
: (selected.loginFailedCount ?? 0) > 0 : `${selected.loginFailedCount} foute wachtwoordpoging${
? `${selected.loginFailedCount} foute wachtwoordpoging${
(selected.loginFailedCount ?? 0) === 1 ? "" : "en" (selected.loginFailedCount ?? 0) === 1 ? "" : "en"
} (lock na 3).` } (lock na 3).`}
: "Geen login-blokkade actief. Ontgrendelen wist foute-pogingen-teller."}
</p> </p>
<button <button
type="button" type="button"
disabled={actionId === `unlock-${selected.id}`} disabled={actionId === `unlock-${selected.id}`}
onClick={() => void unlockLogin(selected)} onClick={() => void unlockLogin(selected)}
> >
{actionId === `unlock-${selected.id}` Login ontgrendelen
? "Ontgrendelen…"
: "Login ontgrendelen"}
</button>
</div>
<div className="form-group password-change">
<label className="field">
Nieuw wachtwoord
<input
type="password"
autoComplete="new-password"
placeholder="Minstens 8 tekens"
value={newPasswords[selected.id] ?? ""}
onChange={(e) =>
setNewPasswords((prev) => ({
...prev,
[selected.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void changeViewerPassword(selected);
}
}}
/>
</label>
<button
type="button"
className="btn-primary"
disabled={
actionId === `pw-${selected.id}` ||
!(newPasswords[selected.id] ?? "").trim()
}
onClick={() => void changeViewerPassword(selected)}
>
{actionId === `pw-${selected.id}`
? "Opslaan…"
: "Wachtwoord wijzigen"}
</button> </button>
</div> </div>
)}
<div className="toggle-rows"> <div className="toggle-rows">
<div className="toggle-row"> <div className="toggle-row">
<div> <div>
@ -1668,11 +1593,6 @@ export default function ViewersPage() {
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(248, 113, 113, 0.35); border: 1px solid rgba(248, 113, 113, 0.35);
background: rgba(248, 113, 113, 0.08); background: rgba(248, 113, 113, 0.08);
margin-bottom: 0.85rem;
}
.login-lock-box.idle {
border-color: var(--border-dim);
background: var(--bg-elevated);
} }
.login-lock-box p { .login-lock-box p {
margin: 0; margin: 0;
@ -1680,16 +1600,6 @@ export default function ViewersPage() {
min-width: 180px; min-width: 180px;
font-size: 0.88rem; font-size: 0.88rem;
} }
.password-change {
margin-bottom: 0.85rem;
padding: 0.75rem 0.9rem;
border-radius: 10px;
border: 1px solid var(--border-dim);
background: var(--bg-elevated);
}
.password-change .btn-primary {
justify-self: start;
}
.toggle-rows { .toggle-rows {
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster" applicationId = "nl.vonas.mediacluster"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 21 versionCode = 20
versionName = "1.0.20" versionName = "1.0.19"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -885,21 +885,14 @@ private fun JSONArray?.toProfiles(): List<ViewerProfile> {
} }
} }
private fun JSONObject.toProfile(): ViewerProfile { private fun JSONObject.toProfile(): ViewerProfile = ViewerProfile(
val hasCustom = optBoolean("hasCustomAvatar")
val rawUrl = optString("avatarUrl").takeIf { it.isNotBlank() }
return ViewerProfile(
id = getString("id"), id = getString("id"),
name = optString("name"), name = optString("name"),
avatarId = optString("avatarId").ifBlank { "fox" }, avatarId = optString("avatarId").ifBlank { "fox" },
isOwner = optBoolean("isOwner"), isOwner = optBoolean("isOwner"),
isKids = optBoolean("isKids"), isKids = optBoolean("isKids"),
hasPin = optBoolean("hasPin"), hasPin = optBoolean("hasPin"),
showAvatar = when { showAvatar = if (has("showAvatar")) optBoolean("showAvatar") else true,
!has("showAvatar") || isNull("showAvatar") -> true hasCustomAvatar = optBoolean("hasCustomAvatar"),
else -> optBoolean("showAvatar", true) avatarUrl = optString("avatarUrl").ifBlank { null },
}, )
hasCustomAvatar = hasCustom,
avatarUrl = if (hasCustom) rawUrl else null,
)
}

View file

@ -86,12 +86,13 @@ class SessionStore(private val context: Context) {
if (showAvatar != null) { if (showAvatar != null) {
prefs[profileShowAvatarKey] = if (showAvatar) "1" else "0" prefs[profileShowAvatarKey] = if (showAvatar) "1" else "0"
} }
// Altijd syncen: null/blank = geen custom foto (emoji tonen) if (avatarUrl != null) {
if (avatarUrl.isNullOrBlank()) prefs.remove(profileAvatarUrlKey) if (avatarUrl.isBlank()) prefs.remove(profileAvatarUrlKey)
else prefs[profileAvatarUrlKey] = avatarUrl else prefs[profileAvatarUrlKey] = avatarUrl
} }
} }
} }
}
suspend fun setApiBase(url: String) { suspend fun setApiBase(url: String) {
context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') } context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') }

View file

@ -1350,10 +1350,7 @@ private fun SettingsScreen(
.background(Color(0xFF2E2E3A)), .background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (profileShowAvatar) { if (profileShowAvatar && !profileAvatarUrl.isNullOrBlank()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 22.sp)
if (!profileAvatarUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(LocalContext.current)
.data(profileAvatarUrl) .data(profileAvatarUrl)
@ -1367,15 +1364,8 @@ private fun SettingsScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
}
}
} else { } else {
Text( Text(avatarEmoji(profileAvatarId ?: "fox"), fontSize = 22.sp)
(profileName?.firstOrNull()?.uppercaseChar() ?: '?').toString(),
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
} }
} }
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {

View file

@ -20,8 +20,8 @@ android {
applicationId = "nl.vonas.mediacluster.tv" applicationId = "nl.vonas.mediacluster.tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 107 versionCode = 106
versionName = "0.14.44" versionName = "0.14.43"
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"") buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") abiFilters += listOf("arm64-v8a", "armeabi-v7a")

View file

@ -875,21 +875,14 @@ private fun JSONArray?.toProfiles(): List<ViewerProfile> {
} }
} }
private fun JSONObject.toProfile(): ViewerProfile { private fun JSONObject.toProfile(): ViewerProfile = ViewerProfile(
val hasCustom = optBoolean("hasCustomAvatar")
val rawUrl = optString("avatarUrl").takeIf { it.isNotBlank() }
return ViewerProfile(
id = getString("id"), id = getString("id"),
name = optString("name"), name = optString("name"),
avatarId = optString("avatarId").ifBlank { "fox" }, avatarId = optString("avatarId").ifBlank { "fox" },
isOwner = optBoolean("isOwner"), isOwner = optBoolean("isOwner"),
isKids = optBoolean("isKids"), isKids = optBoolean("isKids"),
hasPin = optBoolean("hasPin"), hasPin = optBoolean("hasPin"),
showAvatar = when { showAvatar = if (has("showAvatar")) optBoolean("showAvatar") else true,
!has("showAvatar") || isNull("showAvatar") -> true hasCustomAvatar = optBoolean("hasCustomAvatar"),
else -> optBoolean("showAvatar", true) avatarUrl = optString("avatarUrl").ifBlank { null },
}, )
hasCustomAvatar = hasCustom,
avatarUrl = if (hasCustom) rawUrl else null,
)
}

View file

@ -105,12 +105,13 @@ class SessionStore(private val context: Context) {
if (showAvatar != null) { if (showAvatar != null) {
prefs[profileShowAvatarKey] = if (showAvatar) "1" else "0" prefs[profileShowAvatarKey] = if (showAvatar) "1" else "0"
} }
// Altijd syncen: null/blank = geen custom foto (emoji tonen) if (avatarUrl != null) {
if (avatarUrl.isNullOrBlank()) prefs.remove(profileAvatarUrlKey) if (avatarUrl.isBlank()) prefs.remove(profileAvatarUrlKey)
else prefs[profileAvatarUrlKey] = avatarUrl else prefs[profileAvatarUrlKey] = avatarUrl
} }
} }
} }
}
suspend fun setApiBase(url: String) { suspend fun setApiBase(url: String) {
context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') } context.dataStore.edit { it[apiKey] = url.trim().trimEnd('/') }

View file

@ -784,7 +784,6 @@ private fun MainHub(
MainTab.Settings -> SettingsScreen( MainTab.Settings -> SettingsScreen(
api = api, api = api,
profileAvatarId = profileAvatarId, profileAvatarId = profileAvatarId,
profileShowAvatar = profileShowAvatar,
profileName = profileName, profileName = profileName,
isOwner = isOwner, isOwner = isOwner,
onBack = { onBack = {
@ -1645,7 +1644,6 @@ private fun ShelfBlock(
private fun SettingsScreen( private fun SettingsScreen(
api: ApiClient, api: ApiClient,
profileAvatarId: String? = null, profileAvatarId: String? = null,
profileShowAvatar: Boolean = true,
profileName: String? = null, profileName: String? = null,
isOwner: Boolean = false, isOwner: Boolean = false,
onBack: () -> Unit, onBack: () -> Unit,
@ -1739,21 +1737,12 @@ private fun SettingsScreen(
.background(Color(0xFF2E2E3A)), .background(Color(0xFF2E2E3A)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (profileShowAvatar) {
Text( Text(
avatarEmoji(profileAvatarId ?: "fox"), avatarEmoji(profileAvatarId ?: "fox"),
fontSize = 30.sp, fontSize = 30.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} else {
Text(
(profileName?.firstOrNull()?.uppercaseChar() ?: '?').toString(),
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
)
}
} }
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(

View file

@ -321,14 +321,6 @@ fun TopNavBar(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
if (profileShowAvatar) { if (profileShowAvatar) {
Box(
Modifier.size(22.dp),
contentAlignment = Alignment.Center,
) {
// Emoji altijd als basis; custom foto eroverheen indien beschikbaar
if (profileAvatarEmoji != null) {
Text(profileAvatarEmoji, fontSize = 15.sp)
}
if (!profileAvatarUrl.isNullOrBlank()) { if (!profileAvatarUrl.isNullOrBlank()) {
val ctx = LocalContext.current val ctx = LocalContext.current
AsyncImage( AsyncImage(
@ -346,7 +338,8 @@ fun TopNavBar(
.clip(RoundedCornerShape(50)), .clip(RoundedCornerShape(50)),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} } else if (profileAvatarEmoji != null) {
Text(profileAvatarEmoji, fontSize = 15.sp)
} }
} }
if (!profileLabel.isNullOrBlank()) { if (!profileLabel.isNullOrBlank()) {

View file

@ -252,7 +252,7 @@ export class ViewerService {
} }
patch.passwordHash = await hashPassword(data.password); patch.passwordHash = await hashPassword(data.password);
} }
const updated = await prisma.viewerUser.update({ return prisma.viewerUser.update({
where: { id }, where: { id },
data: patch, data: patch,
select: { select: {
@ -266,10 +266,6 @@ export class ViewerService {
createdAt: true, createdAt: true,
}, },
}); });
if (patch.passwordHash) {
await this.clearLoginLock(updated.email.toLowerCase());
}
return updated;
} }
async revokeDevice(deviceId: string) { async revokeDevice(deviceId: string) {