Implement a large number of changes, including overhauled styling and a settings page
This commit is contained in:
+77
-38
@@ -2,22 +2,32 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>DeepFake</title>
|
||||
<title>FakeGPT</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<div id="auth" class="card">
|
||||
<section id="auth" class="card auth-card auth-card-compact">
|
||||
<div class="auth-hero auth-hero-compact">
|
||||
<div class="brand">
|
||||
<div class="logo">DF</div>
|
||||
<div class="logo" data-theme-logo>FG</div>
|
||||
<div>
|
||||
<p class="eyebrow">Best AI chat in the big 2026</p>
|
||||
<h1>Welcome to DeepFake</h1>
|
||||
<p class="subtitle">Log in to continue your conversation or create an account to get started.</p>
|
||||
<p class="eyebrow">Private chat</p>
|
||||
<h1 data-theme-name>FakeGPT</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-grid">
|
||||
<article class="hero-panel">
|
||||
<p>Log in to continue your chats. Personas, shared presets, and theme changes are available after sign-in.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-panel">
|
||||
<div class="auth-toggle">
|
||||
<button class="toggle active" data-target="login">Login</button>
|
||||
<button class="toggle" data-target="register">Register</button>
|
||||
@@ -31,8 +41,7 @@
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input id="loginPassword" type="password" placeholder="Enter your password"
|
||||
autocomplete="current-password"/>
|
||||
<input id="loginPassword" type="password" placeholder="Enter your password" autocomplete="current-password"/>
|
||||
</label>
|
||||
<button id="loginSubmit" type="submit" class="primary-action">Sign in</button>
|
||||
</form>
|
||||
@@ -46,8 +55,7 @@
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Create password</span>
|
||||
<input id="registerPassword" type="password" placeholder="Create a password"
|
||||
autocomplete="new-password"/>
|
||||
<input id="registerPassword" type="password" placeholder="Create a password" autocomplete="new-password"/>
|
||||
</label>
|
||||
<div class="consent-container">
|
||||
<input type="checkbox" id="consentCheckbox" required/>
|
||||
@@ -57,67 +65,88 @@
|
||||
<a href="/privacy.html" target="_blank">Privacy Policy</a>.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button id="registerSubmit" type="submit" class="primary-action">Create account</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p id="authMsg" class="status"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="chat" class="card" style="display:none;">
|
||||
<div class="chat-layout">
|
||||
<section id="chat" class="card chat-card chat-focus-card" style="display:none;">
|
||||
<div class="chat-layout chat-layout-focus">
|
||||
<aside class="chat-sidebar">
|
||||
<div class="sidebar-top">
|
||||
<div class="sidebar-header">
|
||||
<div>
|
||||
<p class="eyebrow">Conversations</p>
|
||||
<h2>Chats</h2>
|
||||
<p class="eyebrow">Chats</p>
|
||||
<h2>Saved threads</h2>
|
||||
</div>
|
||||
<button id="newChat" class="secondary-action">New</button>
|
||||
<button id="newChat" class="secondary-action" type="button">New</button>
|
||||
</div>
|
||||
<label class="field compact-field">
|
||||
<span>Search</span>
|
||||
<input id="chatSearch" type="search" placeholder="Filter chat titles"/>
|
||||
</label>
|
||||
</div>
|
||||
<div id="chatList" class="chat-list"></div>
|
||||
<div class="sidebar-footer sidebar-footer-stack">
|
||||
<a href="/market" class="secondary-action">Persona Market</a>
|
||||
<a href="/settings" class="secondary-action">Settings</a>
|
||||
<div class="session-chip">
|
||||
<span class="session-chip-label">Signed in as</span>
|
||||
<strong id="sessionUser">-</strong>
|
||||
</div>
|
||||
<button id="logout" type="button">Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="chat-main">
|
||||
<div class="chat-header">
|
||||
|
||||
<section class="chat-main chat-main-focus">
|
||||
<header class="chat-header chat-header-focus">
|
||||
<div class="chat-title-block">
|
||||
<div class="chat-title">
|
||||
<p class="eyebrow">Conversation</p>
|
||||
<h2 id="activeChatTitle">New chat</h2>
|
||||
</div>
|
||||
<div class="chat-toolbar">
|
||||
<button id="renameChat" class="icon-button" type="button">Rename</button>
|
||||
<button id="clearChat" class="icon-button" type="button">Clear</button>
|
||||
<button id="exportChat" class="icon-button" type="button">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-header-actions">
|
||||
<div class="model-select">
|
||||
<label for="model">Model:</label>
|
||||
<label for="model">Model</label>
|
||||
<select id="model">
|
||||
<option disabled selected>Loading models...</option>
|
||||
</select>
|
||||
</div>
|
||||
<a href="/market.html" class="secondary-action market-link">Persona Market</a>
|
||||
<div class="persona-menu">
|
||||
<button id="personaMenuButton" class="secondary-action" type="button"
|
||||
aria-expanded="false" aria-controls="personaPopover">
|
||||
<button id="personaMenuButton" class="secondary-action" type="button" aria-expanded="false" aria-controls="personaPopover">
|
||||
Personas
|
||||
</button>
|
||||
<div id="personaPopover" class="persona-popover hidden" role="dialog">
|
||||
<div class="persona-section">
|
||||
<div class="persona-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">Roleplay</p>
|
||||
<h2>AI Persona</h2>
|
||||
<p class="persona-guidance">Controls how the assistant sounds and behaves.</p>
|
||||
<p class="eyebrow">Assistant</p>
|
||||
<h2>AI persona</h2>
|
||||
<p class="persona-guidance">Changes the assistant's tone and character.</p>
|
||||
</div>
|
||||
<div class="persona-section-actions">
|
||||
<button id="newPersona" class="secondary-action" type="button">New AI</button>
|
||||
<button id="clearPersona" class="secondary-action" type="button">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="activePersonaStatus" class="status persona-status">No persona equipped.</p>
|
||||
<p id="activePersonaStatus" class="status persona-status">No AI persona equipped.</p>
|
||||
<div id="personaList" class="persona-list"></div>
|
||||
</div>
|
||||
<div class="persona-section">
|
||||
<div class="persona-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">Roleplay</p>
|
||||
<h2>Your Persona</h2>
|
||||
<p class="persona-guidance">Defines how the assistant should portray you.</p>
|
||||
<p class="eyebrow">User</p>
|
||||
<h2>Your persona</h2>
|
||||
<p class="persona-guidance">Adds context for how the assistant should read you.</p>
|
||||
</div>
|
||||
<div class="persona-section-actions">
|
||||
<button id="newUserPersona" class="secondary-action" type="button">New you</button>
|
||||
@@ -130,20 +159,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="messages" class="messages-panel messages-panel-focus"></div>
|
||||
|
||||
<div id="emptyState" class="empty-state">
|
||||
<p class="eyebrow">No messages yet</p>
|
||||
<h3>Write something when you're ready.</h3>
|
||||
<p>This can stay a plain chat. Personas and shared presets are optional.</p>
|
||||
</div>
|
||||
|
||||
<div class="input-shell input-shell-focus">
|
||||
<div class="input-meta">
|
||||
<p class="composer-hint">Enter sends. Shift+Enter adds a new line.</p>
|
||||
</div>
|
||||
<div id="messages"></div>
|
||||
<div class="input-container">
|
||||
<textarea id="msgInput" placeholder="Type your message..."></textarea>
|
||||
<button id="send"></button>
|
||||
<button id="send" type="button" aria-label="Send message"></button>
|
||||
</div>
|
||||
<div class="logout-container">
|
||||
<button id="deleteChat" class="danger">Delete Chat</button>
|
||||
<button id="logout">Logout</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="personaModal" class="modal-overlay hidden">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
@@ -158,7 +197,7 @@
|
||||
<option value="user">Your persona</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="persona-helper">AI personas shape how the assistant speaks. Your persona describes how you should be portrayed.</p>
|
||||
<p class="persona-helper">AI personas shape the assistant. Your persona gives the model user-side context.</p>
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input id="personaName" placeholder="Persona name" required/>
|
||||
@@ -177,8 +216,7 @@
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Details</span>
|
||||
<textarea id="personaDetails" rows="3"
|
||||
placeholder="Personality traits, voice, or quirks"></textarea>
|
||||
<textarea id="personaDetails" rows="3" placeholder="Personality traits, voice, quirks, goals"></textarea>
|
||||
</label>
|
||||
<div class="persona-actions">
|
||||
<button id="personaSave" type="submit" class="primary-action">Save persona</button>
|
||||
@@ -186,6 +224,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" defer></script>
|
||||
<script src="script.js" defer></script>
|
||||
|
||||
+19
-13
@@ -2,41 +2,47 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Persona Market</title>
|
||||
<title>FakeGPT Market</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<div class="market-shell card">
|
||||
<header class="market-header">
|
||||
<div class="market-shell card market-shell-simple">
|
||||
<header class="market-header market-header-simple">
|
||||
<div>
|
||||
<p class="eyebrow">Community</p>
|
||||
<h1>Persona Market</h1>
|
||||
<p class="subtitle">Browse shared personas and choose who should embody them.</p>
|
||||
<div class="market-search">
|
||||
<label for="marketSearch">Search personas</label>
|
||||
<input id="marketSearch" type="search" placeholder="Search by name, creator, or details"/>
|
||||
</div>
|
||||
<p class="eyebrow">Shared personas</p>
|
||||
<h1><span data-theme-name>FakeGPT</span> Market</h1>
|
||||
<p class="subtitle">A plain list of personas other people decided to share.</p>
|
||||
</div>
|
||||
<div class="market-header-actions">
|
||||
<button id="refreshMarket" class="secondary-action" type="button">Refresh listings</button>
|
||||
<button id="refreshMarket" class="secondary-action" type="button">Refresh</button>
|
||||
<a href="/settings" class="secondary-action">Settings</a>
|
||||
<a href="/" class="secondary-action">Back to chat</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="market-search">
|
||||
<label for="marketSearch">Search personas</label>
|
||||
<input id="marketSearch" type="search" placeholder="Search by name, creator, or description"/>
|
||||
</div>
|
||||
|
||||
<p id="marketStatus" class="status persona-status">Loading market personas...</p>
|
||||
|
||||
<div class="persona-market-columns market-page-columns">
|
||||
<div class="persona-market-column">
|
||||
<h3>AI personas</h3>
|
||||
<p class="persona-guidance">These shape how the assistant speaks and behaves.</p>
|
||||
<div id="marketPersonaList" class="persona-list"></div>
|
||||
</div>
|
||||
<div class="persona-market-column">
|
||||
<h3>User personas</h3>
|
||||
<p class="persona-guidance">These describe how the assistant should portray you.</p>
|
||||
<div id="marketUserPersonaList" class="persona-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="marketPreviewModal" class="modal-overlay hidden">
|
||||
<div class="modal-card market-preview-card">
|
||||
<div class="modal-header">
|
||||
|
||||
+104
-172
@@ -1,114 +1,117 @@
|
||||
const marketPersonaList = document.getElementById("marketPersonaList");
|
||||
const marketUserPersonaList = document.getElementById("marketUserPersonaList");
|
||||
const refreshMarketBtn = document.getElementById("refreshMarket");
|
||||
const marketStatus = document.getElementById("marketStatus");
|
||||
const marketSearchInput = document.getElementById("marketSearch");
|
||||
const marketPreviewModal = document.getElementById("marketPreviewModal");
|
||||
const marketPreviewName = document.getElementById("marketPreviewName");
|
||||
const marketPreviewMeta = document.getElementById("marketPreviewMeta");
|
||||
const marketPreviewPronouns = document.getElementById("marketPreviewPronouns");
|
||||
const marketPreviewAppearance = document.getElementById("marketPreviewAppearance");
|
||||
const marketPreviewBackground = document.getElementById("marketPreviewBackground");
|
||||
const marketPreviewDetails = document.getElementById("marketPreviewDetails");
|
||||
const marketPreviewConfirm = document.getElementById("marketPreviewConfirm");
|
||||
const marketPreviewCancel = document.getElementById("marketPreviewCancel");
|
||||
const marketPreviewClose = document.getElementById("marketPreviewClose");
|
||||
let allMarketPersonas = [];
|
||||
let pendingMarketPersona = null;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const marketPersonaList = $("marketPersonaList"), marketUserPersonaList = $("marketUserPersonaList");
|
||||
const refreshMarketBtn = $("refreshMarket"), marketStatus = $("marketStatus"), marketSearchInput = $("marketSearch");
|
||||
const marketPreviewModal = $("marketPreviewModal"), marketPreviewName = $("marketPreviewName"), marketPreviewMeta = $("marketPreviewMeta");
|
||||
const marketPreviewPronouns = $("marketPreviewPronouns"), marketPreviewAppearance = $("marketPreviewAppearance"), marketPreviewBackground = $("marketPreviewBackground"), marketPreviewDetails = $("marketPreviewDetails");
|
||||
const marketPreviewConfirm = $("marketPreviewConfirm"), marketPreviewCancel = $("marketPreviewCancel"), marketPreviewClose = $("marketPreviewClose");
|
||||
const themeNameTargets = document.querySelectorAll("[data-theme-name]");
|
||||
let allMarketPersonas = [], pendingMarketPersona = null;
|
||||
|
||||
const themes = {
|
||||
"fakegpt": {name: "FakeGPT"},
|
||||
"fraud": {name: "Fraud"},
|
||||
"germini": {name: "Germini"},
|
||||
"slopilot": {name: "Slopilot"},
|
||||
"beta-ai": {name: "Beta AI"},
|
||||
"confusity": {name: "Confusity"}
|
||||
};
|
||||
|
||||
async function request(url, data, method = "POST") {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
const res = await fetch(url, {method, headers: {"Content-Type": "application/json"}, body: data ? JSON.stringify(data) : undefined});
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
return {error: "Network error — please try again."};
|
||||
} catch {
|
||||
return {error: "Network error - please try again."};
|
||||
}
|
||||
}
|
||||
|
||||
async function post(url, data) {
|
||||
return request(url, data, "POST");
|
||||
}
|
||||
const get = (url) => request(url, null, "GET");
|
||||
const post = (url, data) => request(url, data, "POST");
|
||||
|
||||
async function get(url) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
return {error: "Network error — please try again."};
|
||||
}
|
||||
function applyTheme(themeKey) {
|
||||
const nextTheme = themes[themeKey] ? themeKey : "fakegpt";
|
||||
document.body.dataset.theme = nextTheme;
|
||||
document.title = `${themes[nextTheme].name} Market`;
|
||||
themeNameTargets.forEach((target) => { target.textContent = themes[nextTheme].name; });
|
||||
}
|
||||
|
||||
function setMarketStatus(message, state = "") {
|
||||
if (!marketStatus) return;
|
||||
marketStatus.textContent = message;
|
||||
marketStatus.className = state ? `status persona-status ${state}` : "status persona-status";
|
||||
}
|
||||
|
||||
function buildPersonaMeta(persona) {
|
||||
const pronouns = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: —";
|
||||
const usageCount = Number(persona.usage_count || 0);
|
||||
const usageLabel = `Used ${usageCount} ${usageCount === 1 ? "time" : "times"}`;
|
||||
return `By ${persona.creator_username} • ${pronouns} • ${usageLabel}`;
|
||||
}
|
||||
|
||||
function renderMarketList(personaItems, listElement, personaType) {
|
||||
if (!listElement) return;
|
||||
listElement.innerHTML = "";
|
||||
if (!personaItems.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "status persona-status";
|
||||
empty.textContent = personaType === "assistant"
|
||||
? "No AI personas listed yet. Publish the first one!"
|
||||
: "No user personas listed yet. Publish the first one!";
|
||||
listElement.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
personaItems.forEach(persona => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "persona-item";
|
||||
|
||||
const titleRow = document.createElement("div");
|
||||
titleRow.className = "persona-title-row";
|
||||
|
||||
const title = document.createElement("h4");
|
||||
title.textContent = persona.name;
|
||||
|
||||
const roleTag = document.createElement("span");
|
||||
roleTag.className = `persona-role-tag ${personaType === "assistant" ? "ai" : "user"}`;
|
||||
roleTag.textContent = personaType === "assistant" ? "AI" : "You";
|
||||
|
||||
titleRow.appendChild(title);
|
||||
titleRow.appendChild(roleTag);
|
||||
|
||||
const meta = document.createElement("p");
|
||||
meta.textContent = buildPersonaMeta(persona);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "persona-item-actions";
|
||||
|
||||
const getBtn = document.createElement("button");
|
||||
getBtn.type = "button";
|
||||
getBtn.textContent = "View details";
|
||||
getBtn.addEventListener("click", () => openMarketPreview(persona));
|
||||
|
||||
actions.appendChild(getBtn);
|
||||
item.appendChild(titleRow);
|
||||
item.appendChild(meta);
|
||||
item.appendChild(actions);
|
||||
listElement.appendChild(item);
|
||||
});
|
||||
const pronouns = persona.pronouns ? `Pronouns: ${persona.pronouns}` : "Pronouns: n/a";
|
||||
return `${persona.creator_username} | ${pronouns}`;
|
||||
}
|
||||
|
||||
function formatPersonaField(value) {
|
||||
return value && value.trim() ? value.trim() : "Not provided.";
|
||||
}
|
||||
|
||||
function closeMarketPreview() {
|
||||
marketPreviewModal.classList.add("hidden");
|
||||
pendingMarketPersona = null;
|
||||
}
|
||||
|
||||
function renderMarketList(items, listElement, type) {
|
||||
listElement.innerHTML = "";
|
||||
if (!items.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "status persona-status";
|
||||
empty.textContent = type === "assistant" ? "No AI personas found." : "No user personas found.";
|
||||
listElement.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((persona) => {
|
||||
const item = document.createElement("div");
|
||||
const titleRow = document.createElement("div");
|
||||
const title = document.createElement("h4");
|
||||
const tag = document.createElement("span");
|
||||
const meta = document.createElement("p");
|
||||
const actions = document.createElement("div");
|
||||
const detailsBtn = document.createElement("button");
|
||||
|
||||
item.className = "persona-item";
|
||||
titleRow.className = "persona-title-row";
|
||||
actions.className = "persona-item-actions";
|
||||
tag.className = `persona-role-tag ${type === "assistant" ? "ai" : "user"}`;
|
||||
tag.textContent = type === "assistant" ? "AI" : "You";
|
||||
title.textContent = persona.name;
|
||||
meta.textContent = buildPersonaMeta(persona);
|
||||
detailsBtn.type = "button";
|
||||
detailsBtn.textContent = "View";
|
||||
detailsBtn.addEventListener("click", () => openMarketPreview(persona));
|
||||
|
||||
titleRow.append(title, tag);
|
||||
actions.appendChild(detailsBtn);
|
||||
item.append(titleRow, meta, actions);
|
||||
listElement.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function filterMarketPersonas() {
|
||||
const query = marketSearchInput.value.trim().toLowerCase();
|
||||
if (!query) return allMarketPersonas;
|
||||
return allMarketPersonas.filter((persona) =>
|
||||
[persona.name, persona.creator_username, persona.pronouns, persona.appearance, persona.background, persona.details]
|
||||
.some((field) => field && field.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
function applyMarketFilter() {
|
||||
const filtered = filterMarketPersonas();
|
||||
renderMarketList(filtered.filter((persona) => persona.persona_type === "assistant"), marketPersonaList, "assistant");
|
||||
renderMarketList(filtered.filter((persona) => persona.persona_type === "user"), marketUserPersonaList, "user");
|
||||
if (marketSearchInput.value.trim() && !filtered.length) {
|
||||
setMarketStatus("No personas match your search.", "error");
|
||||
return;
|
||||
}
|
||||
setMarketStatus(`${filtered.length} persona${filtered.length === 1 ? "" : "s"} shown.`);
|
||||
}
|
||||
|
||||
function openMarketPreview(persona) {
|
||||
if (!marketPreviewModal) return;
|
||||
pendingMarketPersona = persona;
|
||||
marketPreviewName.textContent = persona.name;
|
||||
marketPreviewMeta.textContent = buildPersonaMeta(persona);
|
||||
@@ -116,110 +119,39 @@ function openMarketPreview(persona) {
|
||||
marketPreviewAppearance.textContent = formatPersonaField(persona.appearance);
|
||||
marketPreviewBackground.textContent = formatPersonaField(persona.background);
|
||||
marketPreviewDetails.textContent = formatPersonaField(persona.details);
|
||||
marketPreviewConfirm.textContent =
|
||||
persona.persona_type === "assistant" ? "Get & equip as AI" : "Get & equip as You";
|
||||
marketPreviewConfirm.textContent = persona.persona_type === "assistant" ? "Collect as AI persona" : "Collect as user persona";
|
||||
marketPreviewModal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function closeMarketPreview() {
|
||||
if (!marketPreviewModal) return;
|
||||
marketPreviewModal.classList.add("hidden");
|
||||
pendingMarketPersona = null;
|
||||
}
|
||||
|
||||
function filterMarketPersonas() {
|
||||
const query = marketSearchInput?.value.trim().toLowerCase() || "";
|
||||
if (!query) return allMarketPersonas;
|
||||
return allMarketPersonas.filter(persona => {
|
||||
const fields = [
|
||||
persona.name,
|
||||
persona.creator_username,
|
||||
persona.pronouns,
|
||||
persona.appearance,
|
||||
persona.background,
|
||||
persona.details
|
||||
];
|
||||
return fields.some(field => field && field.toLowerCase().includes(query));
|
||||
});
|
||||
}
|
||||
|
||||
function applyMarketFilter() {
|
||||
const filtered = filterMarketPersonas();
|
||||
const assistantMarket = filtered.filter(persona => persona.persona_type === "assistant");
|
||||
const userMarket = filtered.filter(persona => persona.persona_type === "user");
|
||||
renderMarketList(assistantMarket, marketPersonaList, "assistant");
|
||||
renderMarketList(userMarket, marketUserPersonaList, "user");
|
||||
if (marketSearchInput?.value.trim() && !filtered.length) {
|
||||
setMarketStatus("No personas match your search.", "error");
|
||||
return;
|
||||
}
|
||||
setMarketStatus("Choose an AI persona or a user persona to equip.");
|
||||
}
|
||||
|
||||
async function loadMarketPersonas() {
|
||||
setMarketStatus("Loading market personas...");
|
||||
const res = await get("/personas/market");
|
||||
if (res.error) {
|
||||
setMarketStatus(res.error, "error");
|
||||
return;
|
||||
}
|
||||
if (res.error) return setMarketStatus(res.error, "error");
|
||||
allMarketPersonas = res.personas || [];
|
||||
applyMarketFilter();
|
||||
}
|
||||
|
||||
async function collectMarketPersona(marketId, personaType) {
|
||||
setMarketStatus(
|
||||
personaType === "assistant"
|
||||
? "Equipping the AI persona..."
|
||||
: "Equipping the user persona..."
|
||||
);
|
||||
setMarketStatus(personaType === "assistant" ? "Collecting AI persona..." : "Collecting user persona...");
|
||||
const res = await post(`/personas/market/${marketId}/collect`, {equip: true});
|
||||
if (res.error) {
|
||||
setMarketStatus(res.error, "error");
|
||||
return;
|
||||
}
|
||||
if (res.error) return setMarketStatus(res.error, "error");
|
||||
await loadMarketPersonas();
|
||||
setMarketStatus(
|
||||
personaType === "assistant"
|
||||
? "AI persona equipped. Head back to chat to use it."
|
||||
: "User persona equipped. Head back to chat to use it.",
|
||||
"success"
|
||||
);
|
||||
setMarketStatus(personaType === "assistant" ? "AI persona collected." : "User persona collected.", "success");
|
||||
}
|
||||
|
||||
if (refreshMarketBtn) {
|
||||
refreshMarketBtn.addEventListener("click", () => loadMarketPersonas());
|
||||
}
|
||||
|
||||
if (marketSearchInput) {
|
||||
marketSearchInput.addEventListener("input", () => applyMarketFilter());
|
||||
}
|
||||
|
||||
if (marketPreviewCancel) {
|
||||
marketPreviewCancel.addEventListener("click", closeMarketPreview);
|
||||
}
|
||||
|
||||
if (marketPreviewClose) {
|
||||
marketPreviewClose.addEventListener("click", closeMarketPreview);
|
||||
}
|
||||
|
||||
if (marketPreviewModal) {
|
||||
marketPreviewModal.addEventListener("click", (event) => {
|
||||
if (event.target === marketPreviewModal) {
|
||||
closeMarketPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (marketPreviewConfirm) {
|
||||
marketPreviewConfirm.addEventListener("click", async () => {
|
||||
refreshMarketBtn.addEventListener("click", loadMarketPersonas);
|
||||
marketSearchInput.addEventListener("input", applyMarketFilter);
|
||||
marketPreviewCancel.addEventListener("click", closeMarketPreview);
|
||||
marketPreviewClose.addEventListener("click", closeMarketPreview);
|
||||
marketPreviewModal.addEventListener("click", (event) => { if (event.target === marketPreviewModal) closeMarketPreview(); });
|
||||
marketPreviewConfirm.addEventListener("click", async () => {
|
||||
if (!pendingMarketPersona) return;
|
||||
const {id, persona_type} = pendingMarketPersona;
|
||||
closeMarketPreview();
|
||||
await collectMarketPersona(id, persona_type);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt");
|
||||
loadMarketPersonas();
|
||||
});
|
||||
|
||||
+306
-603
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Settings</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<div class="settings-shell card">
|
||||
<header class="market-header market-header-simple">
|
||||
<div>
|
||||
<p class="eyebrow">Settings</p>
|
||||
<h1>Preferences</h1>
|
||||
<p class="subtitle">Manage account details and appearance.</p>
|
||||
</div>
|
||||
<div class="market-header-actions">
|
||||
<a href="/" class="secondary-action">Back to chat</a>
|
||||
<a href="/market" class="secondary-action">Persona Market</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p id="settingsNotice" class="status">Loading settings...</p>
|
||||
|
||||
<section class="settings-layout">
|
||||
<aside class="settings-nav" aria-label="Settings sections">
|
||||
<button class="settings-nav-item active" type="button" data-settings-view="personal">Personal</button>
|
||||
<button class="settings-nav-item" type="button" data-settings-view="design">Design</button>
|
||||
</aside>
|
||||
|
||||
<div class="settings-content">
|
||||
<section class="settings-view active" data-settings-panel="personal">
|
||||
<div class="settings-panel">
|
||||
<div class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">Profile</p>
|
||||
<h2>Username</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form id="usernameForm" class="settings-form-stack">
|
||||
<label class="field">
|
||||
<span>Current username</span>
|
||||
<input id="currentUsername" readonly/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>New username</span>
|
||||
<input id="newUsername" autocomplete="username"/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Current password</span>
|
||||
<input id="usernamePassword" type="password" autocomplete="current-password"/>
|
||||
</label>
|
||||
<button id="saveUsername" type="submit">Save username</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-panel">
|
||||
<div class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">Security</p>
|
||||
<h2>Password</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form id="passwordForm" class="settings-form-stack">
|
||||
<label class="field">
|
||||
<span>Current password</span>
|
||||
<input id="currentPassword" type="password" autocomplete="current-password"/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>New password</span>
|
||||
<input id="newPassword" type="password" autocomplete="new-password"/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Repeat new password</span>
|
||||
<input id="repeatPassword" type="password" autocomplete="new-password"/>
|
||||
</label>
|
||||
<button id="savePassword" type="submit">Save password</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-view" data-settings-panel="design">
|
||||
<div class="settings-panel settings-panel-compact">
|
||||
<div class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">Appearance</p>
|
||||
<h2>Theme</h2>
|
||||
<p class="subtitle">Themes are only changed here.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-theme-row">
|
||||
<label class="field">
|
||||
<span>Theme</span>
|
||||
<select id="settingsThemeSelect">
|
||||
<option value="fakegpt">FakeGPT</option>
|
||||
<option value="fraud">Fraud</option>
|
||||
<option value="germini">Germini</option>
|
||||
<option value="slopilot">Slopilot</option>
|
||||
<option value="beta-ai">Beta AI</option>
|
||||
<option value="confusity">Confusity</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="settings.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,122 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const settingsNotice = $("settingsNotice");
|
||||
const settingsThemeSelect = $("settingsThemeSelect");
|
||||
const settingsThemeName = $("settingsThemeName");
|
||||
const currentUsernameInput = $("currentUsername");
|
||||
const newUsernameInput = $("newUsername");
|
||||
const usernamePasswordInput = $("usernamePassword");
|
||||
const currentPasswordInput = $("currentPassword");
|
||||
const newPasswordInput = $("newPassword");
|
||||
const repeatPasswordInput = $("repeatPassword");
|
||||
const usernameForm = $("usernameForm");
|
||||
const passwordForm = $("passwordForm");
|
||||
const settingsNavItems = document.querySelectorAll("[data-settings-view]");
|
||||
const settingsPanels = document.querySelectorAll("[data-settings-panel]");
|
||||
|
||||
const themes = {
|
||||
"fakegpt": {name: "FakeGPT"},
|
||||
"fraud": {name: "Fraud"},
|
||||
"germini": {name: "Germini"},
|
||||
"slopilot": {name: "Slopilot"},
|
||||
"beta-ai": {name: "Beta AI"},
|
||||
"confusity": {name: "Confusity"}
|
||||
};
|
||||
|
||||
async function request(url, data, method = "POST") {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
return await res.json();
|
||||
} catch {
|
||||
return {error: "Network error - please try again."};
|
||||
}
|
||||
}
|
||||
|
||||
function setNotice(message, state = "") {
|
||||
settingsNotice.textContent = message;
|
||||
settingsNotice.className = state ? `status ${state}` : "status";
|
||||
}
|
||||
|
||||
function applyTheme(themeKey, persist = true) {
|
||||
const nextTheme = themes[themeKey] ? themeKey : "fakegpt";
|
||||
document.body.dataset.theme = nextTheme;
|
||||
document.title = "Settings";
|
||||
settingsThemeSelect.value = nextTheme;
|
||||
if (settingsThemeName) settingsThemeName.textContent = themes[nextTheme].name;
|
||||
if (persist) localStorage.setItem("krishd-theme", nextTheme);
|
||||
}
|
||||
|
||||
function setSettingsView(view) {
|
||||
settingsNavItems.forEach((item) => item.classList.toggle("active", item.dataset.settingsView === view));
|
||||
settingsPanels.forEach((panel) => panel.classList.toggle("active", panel.dataset.settingsPanel === view));
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
const res = await request("/settings/profile", null, "GET");
|
||||
if (res.error) {
|
||||
setNotice(res.error, "error");
|
||||
return;
|
||||
}
|
||||
currentUsernameInput.value = res.username || "";
|
||||
newUsernameInput.value = res.username || "";
|
||||
setNotice("Settings loaded.");
|
||||
}
|
||||
|
||||
usernameForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const username = newUsernameInput.value.trim();
|
||||
const password = usernamePasswordInput.value;
|
||||
if (!username || !password) {
|
||||
setNotice("New username and current password are required.", "error");
|
||||
return;
|
||||
}
|
||||
const res = await request("/settings/username", {username, password}, "PUT");
|
||||
if (res.error) {
|
||||
setNotice(res.error, "error");
|
||||
return;
|
||||
}
|
||||
currentUsernameInput.value = res.username;
|
||||
newUsernameInput.value = res.username;
|
||||
usernamePasswordInput.value = "";
|
||||
setNotice("Username updated.", "success");
|
||||
});
|
||||
|
||||
passwordForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const currentPassword = currentPasswordInput.value;
|
||||
const newPassword = newPasswordInput.value;
|
||||
const repeatPassword = repeatPasswordInput.value;
|
||||
if (!currentPassword || !newPassword || !repeatPassword) {
|
||||
setNotice("Fill in all password fields.", "error");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== repeatPassword) {
|
||||
setNotice("New passwords do not match.", "error");
|
||||
return;
|
||||
}
|
||||
const res = await request("/settings/password", {currentPassword, newPassword}, "PUT");
|
||||
if (res.error) {
|
||||
setNotice(res.error, "error");
|
||||
return;
|
||||
}
|
||||
currentPasswordInput.value = "";
|
||||
newPasswordInput.value = "";
|
||||
repeatPasswordInput.value = "";
|
||||
setNotice("Password updated.", "success");
|
||||
});
|
||||
|
||||
settingsThemeSelect.addEventListener("change", (event) => {
|
||||
applyTheme(event.target.value);
|
||||
setNotice("Theme updated.", "success");
|
||||
});
|
||||
|
||||
settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView)));
|
||||
|
||||
window.addEventListener("load", async () => {
|
||||
applyTheme(localStorage.getItem("krishd-theme") || "fakegpt", false);
|
||||
setSettingsView("personal");
|
||||
await loadProfile();
|
||||
});
|
||||
+1668
-903
File diff suppressed because it is too large
Load Diff
@@ -298,6 +298,8 @@ db.prepare(`
|
||||
|
||||
const insertUserStmt = db.prepare("INSERT INTO users (username, password) VALUES (?, ?)");
|
||||
const getUserStmt = db.prepare("SELECT username, password FROM users WHERE username = ?");
|
||||
const updateUsernameStmt = db.prepare("UPDATE users SET username = ? WHERE username = ?");
|
||||
const updatePasswordStmt = db.prepare("UPDATE users SET password = ? WHERE username = ?");
|
||||
const getLegacyChatsStmt = db.prepare(`
|
||||
SELECT role, content
|
||||
FROM chats
|
||||
@@ -529,6 +531,71 @@ const listPublishedPersonaIdsStmt = db.prepare(`
|
||||
WHERE creator_username = ?
|
||||
`);
|
||||
|
||||
const getChatMessageCountForUserStmt = db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM chat_messages cm
|
||||
JOIN chat_sessions cs ON cs.id = cm.chat_id
|
||||
WHERE cs.username = ?
|
||||
`);
|
||||
|
||||
const getPersonaCountForUserStmt = db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM personas
|
||||
WHERE username = ?
|
||||
`);
|
||||
|
||||
const getPublishedPersonaCountForUserStmt = db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM persona_market
|
||||
WHERE creator_username = ?
|
||||
`);
|
||||
|
||||
const updateLegacyChatsUsernameStmt = db.prepare(`
|
||||
UPDATE chats
|
||||
SET username = ?
|
||||
WHERE username = ?
|
||||
`);
|
||||
|
||||
const updateChatSessionsUsernameStmt = db.prepare(`
|
||||
UPDATE chat_sessions
|
||||
SET username = ?
|
||||
WHERE username = ?
|
||||
`);
|
||||
|
||||
const updatePersonasUsernameStmt = db.prepare(`
|
||||
UPDATE personas
|
||||
SET username = ?
|
||||
WHERE username = ?
|
||||
`);
|
||||
|
||||
const updatePersonasSourceCreatorStmt = db.prepare(`
|
||||
UPDATE personas
|
||||
SET source_creator_username = ?
|
||||
WHERE source_creator_username = ?
|
||||
`);
|
||||
|
||||
const updateUserSettingsUsernameStmt = db.prepare(`
|
||||
UPDATE user_settings
|
||||
SET username = ?
|
||||
WHERE username = ?
|
||||
`);
|
||||
|
||||
const updatePersonaMarketCreatorStmt = db.prepare(`
|
||||
UPDATE persona_market
|
||||
SET creator_username = ?
|
||||
WHERE creator_username = ?
|
||||
`);
|
||||
|
||||
const renameUserTransaction = db.transaction((currentUsername, nextUsername) => {
|
||||
updateUsernameStmt.run(nextUsername, currentUsername);
|
||||
updateLegacyChatsUsernameStmt.run(nextUsername, currentUsername);
|
||||
updateChatSessionsUsernameStmt.run(nextUsername, currentUsername);
|
||||
updatePersonasUsernameStmt.run(nextUsername, currentUsername);
|
||||
updatePersonasSourceCreatorStmt.run(nextUsername, currentUsername);
|
||||
updateUserSettingsUsernameStmt.run(nextUsername, currentUsername);
|
||||
updatePersonaMarketCreatorStmt.run(nextUsername, currentUsername);
|
||||
});
|
||||
|
||||
const deleteMarketPersonaByPersonaIdStmt = db.prepare(`
|
||||
DELETE FROM persona_market
|
||||
WHERE persona_id = ?
|
||||
@@ -640,6 +707,76 @@ app.post("/logout", (req, res) => {
|
||||
req.session.destroy(() => res.json({message: "Logged out"}));
|
||||
});
|
||||
|
||||
const requireLogin = (req, res, next) => {
|
||||
if (!req.session.user) return res.status(403).json({error: "Not logged in"});
|
||||
next();
|
||||
};
|
||||
|
||||
app.get("/settings/profile", requireLogin, (req, res) => {
|
||||
const user = getUserStmt.get(req.session.user);
|
||||
if (!user) return res.status(404).json({error: "User not found"});
|
||||
res.json({username: user.username});
|
||||
});
|
||||
|
||||
app.put("/settings/username", requireLogin, async (req, res) => {
|
||||
const currentUsername = req.session.user;
|
||||
const nextUsername = (req.body?.username || "").trim();
|
||||
const password = req.body?.password || "";
|
||||
|
||||
if (!nextUsername) {
|
||||
return res.status(400).json({error: "New username is required"});
|
||||
}
|
||||
if (nextUsername.length < 3) {
|
||||
return res.status(400).json({error: "Username must be at least 3 characters"});
|
||||
}
|
||||
if (nextUsername === currentUsername) {
|
||||
return res.status(400).json({error: "That is already your username"});
|
||||
}
|
||||
|
||||
const currentUser = getUserStmt.get(currentUsername);
|
||||
if (!currentUser) {
|
||||
return res.status(404).json({error: "User not found"});
|
||||
}
|
||||
const valid = await bcrypt.compare(password, currentUser.password);
|
||||
if (!valid) {
|
||||
return res.status(403).json({error: "Current password is incorrect"});
|
||||
}
|
||||
|
||||
if (getUserStmt.get(nextUsername)) {
|
||||
return res.status(400).json({error: "Username already exists"});
|
||||
}
|
||||
|
||||
renameUserTransaction(currentUsername, nextUsername);
|
||||
req.session.user = nextUsername;
|
||||
res.json({username: nextUsername});
|
||||
});
|
||||
|
||||
app.put("/settings/password", requireLogin, async (req, res) => {
|
||||
const username = req.session.user;
|
||||
const currentPassword = req.body?.currentPassword || "";
|
||||
const nextPassword = req.body?.newPassword || "";
|
||||
|
||||
if (!currentPassword || !nextPassword) {
|
||||
return res.status(400).json({error: "Current and new password are required"});
|
||||
}
|
||||
if (nextPassword.length < 6) {
|
||||
return res.status(400).json({error: "New password must be at least 6 characters"});
|
||||
}
|
||||
|
||||
const user = getUserStmt.get(username);
|
||||
if (!user) {
|
||||
return res.status(404).json({error: "User not found"});
|
||||
}
|
||||
const valid = await bcrypt.compare(currentPassword, user.password);
|
||||
if (!valid) {
|
||||
return res.status(403).json({error: "Current password is incorrect"});
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(nextPassword, 10);
|
||||
updatePasswordStmt.run(hashed, username);
|
||||
res.json({message: "Password updated"});
|
||||
});
|
||||
|
||||
const normalizePersonaField = (value) => {
|
||||
const trimmed = (value || "").trim();
|
||||
return trimmed ? trimmed : null;
|
||||
@@ -683,11 +820,6 @@ const buildUserPersonaPrompt = (persona) => {
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const requireLogin = (req, res, next) => {
|
||||
if (!req.session.user) return res.status(403).json({error: "Not logged in"});
|
||||
next();
|
||||
};
|
||||
|
||||
app.get("/models", async (req, res) => {
|
||||
try {
|
||||
if (modelsCache && (Date.now() - modelsCacheAt) < MODELS_TTL_MS) {
|
||||
@@ -898,6 +1030,26 @@ app.get("/personas/market", requireLogin, (req, res) => {
|
||||
res.json({personas});
|
||||
});
|
||||
|
||||
app.get("/dashboard/summary", requireLogin, (req, res) => {
|
||||
const user = req.session.user;
|
||||
const chats = countChatSessionsStmt.get(user).count;
|
||||
const messages = getChatMessageCountForUserStmt.get(user).count;
|
||||
const personas = getPersonaCountForUserStmt.get(user).count;
|
||||
const published = getPublishedPersonaCountForUserStmt.get(user).count;
|
||||
const marketPersonas = listMarketPersonasStmt.all().length;
|
||||
|
||||
res.json({
|
||||
username: user,
|
||||
stats: {
|
||||
chats,
|
||||
messages,
|
||||
personas,
|
||||
published,
|
||||
marketPersonas
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/personas/:id/publish", requireLogin, (req, res) => {
|
||||
const user = req.session.user;
|
||||
const personaId = Number(req.params.id);
|
||||
@@ -1090,6 +1242,14 @@ app.get("/chat/history", requireLogin, (req, res) => {
|
||||
res.json({history: chats});
|
||||
});
|
||||
|
||||
app.get("/market", (req, res) => {
|
||||
res.sendFile(path.resolve("public/market.html"));
|
||||
});
|
||||
|
||||
app.get("/settings", (req, res) => {
|
||||
res.sendFile(path.resolve("public/settings.html"));
|
||||
});
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.sendFile(path.resolve("public/index.html"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user