Add basic creator chat flow

This commit is contained in:
Thuvaraka Yogarajah 2026-06-14 12:07:01 +02:00
parent af7271e2f8
commit 10592b76c7
4 changed files with 416 additions and 99 deletions

View File

@ -34,113 +34,42 @@
<div class="chat-list">
<div class="chat-list-header">
<h2>Messages</h2>
<button type="button" class="new-chat-btn" aria-label="New chat">
<button type="button" class="new-chat-btn" id="newChatBtn" aria-label="New chat" aria-expanded="false" aria-controls="new-chat-panel">
<i class="bi bi-pencil-square" aria-hidden="true"></i>
</button>
</div>
<div class="chat-list-items" role="list" aria-label="Conversations">
<!-- Chat Entry 1 (active) -->
<button type="button" class="chat-item active" aria-label="Open chat with Alex Chen" aria-current="true">
<img
src="../images/content/creator2.png"
alt="Alex Chen"
class="chat-avatar"
/>
<div class="chat-item-info">
<div class="chat-name">Alex Chen</div>
<div class="chat-last-msg">
Hey Sarah! Really loved your last video on minimalism...
<div class="new-chat-panel" id="new-chat-panel" hidden>
<label for="creatorSearch" class="sr-only">Search creator</label>
<input type="search" id="creatorSearch" placeholder="Search creator..." autocomplete="off" />
<div class="creator-search-results" id="creatorSearchResults" role="listbox" aria-label="Creator results"></div>
</div>
</div>
<div class="chat-time">10:17 AM</div>
</button>
<!-- Chat Entry 2 -->
<button type="button" class="chat-item" aria-label="Open chat with Mia Wong">
<img
src="../images/content/creator3.png"
alt="Mia Wong"
class="chat-avatar"
/>
<div class="chat-item-info">
<div class="chat-name">Mia Wong</div>
<div class="chat-last-msg">
Thanks for the prompt tips! They worked perfectly.
</div>
</div>
<div class="chat-time">Yesterday</div>
</button>
<!-- Chat Entry 3 -->
<button type="button" class="chat-item" aria-label="Open chat with Tom Rivera">
<img
src="../images/content/creator4.png"
alt="Tom Rivera"
class="chat-avatar"
/>
<div class="chat-item-info">
<div class="chat-name">Tom Rivera</div>
<div class="chat-last-msg">
Let's schedule a call for the collab?
</div>
</div>
<div class="chat-time">Yesterday</div>
</button>
<div class="chat-list-items" id="chatList" role="list" aria-label="Conversations">
</div>
</div>
<!-- Right Column: Active Chat (with Alex Chen) -->
<!-- Right Column: Active Chat -->
<div class="chat-active">
<div class="chat-header">
<img
src="../images/content/creator2.png"
alt="Alex Chen"
src="../images/content/cat.png"
alt=""
class="chat-avatar-large"
id="activeChatAvatar"
/>
<div class="chat-header-info">
<div class="chat-header-name">Alex Chen</div>
<div class="chat-header-name" id="activeChatName">Select a chat</div>
<div class="chat-header-status">
<span class="online-dot" aria-hidden="true"></span> Online
<span class="online-dot" aria-hidden="true"></span>
<span id="activeChatStatus">Ready</span>
</div>
</div>
</div>
<div class="chat-messages" aria-live="polite" aria-label="Conversation with Alex Chen">
<!-- Message from Alex -->
<div class="message received">
<div class="message-bubble">
Hey Sarah! Really loved your last video on minimalism. Quick
question about your workspace layout?
</div>
<div class="message-time">10:15 AM</div>
</div>
<!-- Reply from Sarah -->
<div class="message sent">
<div class="message-bubble">
Thanks Alex! Appreciate it. Yes, happy to share! The desk is
from Article, and the shelving unit is custom-built. Highly
recommend a clean setup!
</div>
<div class="message-time">10:16 AM</div>
</div>
<!-- Alex replies -->
<div class="message received">
<div class="message-bubble">
Thanks so much! Your aesthetic is exactly what I'm aiming
for. Can't wait for your next piece!
</div>
<div class="message-time">10:17 AM</div>
</div>
<!-- Sarah replies -->
<div class="message sent">
<div class="message-bubble">
Awesome! Let me know if you need more tips. Enjoy the
process! 😊
</div>
<div class="message-time">10:18 AM</div>
</div>
</div>
<div class="chat-input-area">
<input type="text" placeholder="Type your message..." aria-label="Message" />
<button type="button" class="send-btn">Send</button>
<div class="chat-messages" id="chatMessages" aria-live="polite" aria-label="Conversation">
</div>
<form class="chat-input-area" id="chatForm">
<input type="text" id="messageInput" placeholder="Type your message..." aria-label="Message" autocomplete="off" />
<button type="submit" class="send-btn">Send</button>
</form>
</div>
</div>
</main>
@ -175,6 +104,278 @@
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
const STORAGE_KEY = "onlyprompt-chat-conversations";
const chatList = document.getElementById("chatList");
const chatMessages = document.getElementById("chatMessages");
const chatForm = document.getElementById("chatForm");
const messageInput = document.getElementById("messageInput");
const activeChatAvatar = document.getElementById("activeChatAvatar");
const activeChatName = document.getElementById("activeChatName");
const activeChatStatus = document.getElementById("activeChatStatus");
const newChatBtn = document.getElementById("newChatBtn");
const newChatPanel = document.getElementById("new-chat-panel");
const creatorSearch = document.getElementById("creatorSearch");
const creatorSearchResults = document.getElementById("creatorSearchResults");
let conversations = loadConversations();
let creators = [];
let activeConversationId = null;
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function loadConversations() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
return Array.isArray(saved) && saved.length ? saved : demoConversations();
} catch {
return demoConversations();
}
}
function saveConversations() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
}
function demoConversations() {
const now = Date.now();
return [
{
id: "demo-alex",
userId: "demo-alex",
name: "Alex Chen",
avatar: "../images/content/creator2.png",
updatedAt: now - 60000,
messages: [
{ from: "them", text: "Hey, I liked your last prompt idea.", createdAt: now - 180000 },
{ from: "me", text: "Thanks. Which part was useful?", createdAt: now - 120000 },
{ from: "them", text: "The structure was easy to adapt.", createdAt: now - 60000 },
],
},
{
id: "demo-mia",
userId: "demo-mia",
name: "Mia Wong",
avatar: "../images/content/creator3.png",
updatedAt: now - 86400000,
messages: [
{ from: "them", text: "Thanks for the prompt tips.", createdAt: now - 86400000 },
],
},
];
}
function formatTime(timestamp) {
const date = new Date(timestamp);
const today = new Date();
if (date.toDateString() === today.toDateString()) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return date.toLocaleDateString([], { month: "short", day: "numeric" });
}
function getLastMessage(conversation) {
return conversation.messages.at(-1)?.text || "No messages yet.";
}
function renderChatList() {
conversations.sort((a, b) => b.updatedAt - a.updatedAt);
if (!conversations.length) {
chatList.innerHTML = '<div class="chat-empty">Start a chat with a creator.</div>';
return;
}
chatList.innerHTML = conversations
.map((conversation) => `
<button type="button"
class="chat-item ${conversation.id === activeConversationId ? "active" : ""}"
data-chat-id="${escapeHtml(conversation.id)}"
aria-label="Open chat with ${escapeHtml(conversation.name)}"
${conversation.id === activeConversationId ? 'aria-current="true"' : ""}>
<img src="${escapeHtml(conversation.avatar)}" alt="" class="chat-avatar" />
<div class="chat-item-info">
<div class="chat-name">${escapeHtml(conversation.name)}</div>
<div class="chat-last-msg">${escapeHtml(getLastMessage(conversation))}</div>
</div>
<time class="chat-time" datetime="${new Date(conversation.updatedAt).toISOString()}">${formatTime(conversation.updatedAt)}</time>
</button>
`)
.join("");
chatList.querySelectorAll("[data-chat-id]").forEach((button) => {
button.addEventListener("click", () => selectConversation(button.dataset.chatId));
});
}
function renderMessages() {
const conversation = conversations.find((item) => item.id === activeConversationId);
if (!conversation) {
activeChatAvatar.src = "../images/content/cat.png";
activeChatName.textContent = "Select a chat";
activeChatStatus.textContent = "Ready";
chatMessages.innerHTML = '<div class="chat-empty">Choose a conversation or start a new chat.</div>';
messageInput.disabled = true;
chatForm.querySelector("button").disabled = true;
return;
}
activeChatAvatar.src = conversation.avatar;
activeChatName.textContent = conversation.name;
activeChatStatus.textContent = "Online";
chatMessages.setAttribute("aria-label", `Conversation with ${conversation.name}`);
messageInput.disabled = false;
chatForm.querySelector("button").disabled = false;
chatMessages.innerHTML = conversation.messages
.map((message) => `
<div class="message ${message.from === "me" ? "sent" : "received"}">
<div class="message-bubble">${escapeHtml(message.text)}</div>
<time class="message-time" datetime="${new Date(message.createdAt).toISOString()}">${formatTime(message.createdAt)}</time>
</div>
`)
.join("");
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function selectConversation(id) {
activeConversationId = id;
renderChatList();
renderMessages();
}
function startConversation(creator) {
const id = `user-${creator.userId}`;
let conversation = conversations.find((item) => item.id === id);
if (!conversation) {
conversation = {
id,
userId: creator.userId,
name: creator.displayName,
avatar: creator.avatarUrl || "../images/content/cat.png",
updatedAt: Date.now(),
messages: [],
};
conversations.push(conversation);
saveConversations();
}
activeConversationId = id;
newChatPanel.hidden = true;
newChatBtn.setAttribute("aria-expanded", "false");
creatorSearch.value = "";
renderCreatorResults();
renderChatList();
renderMessages();
messageInput.focus();
}
function addMessage(text) {
const conversation = conversations.find((item) => item.id === activeConversationId);
if (!conversation || !text.trim()) return;
conversation.messages.push({
from: "me",
text: text.trim(),
createdAt: Date.now(),
});
conversation.updatedAt = Date.now();
saveConversations();
renderChatList();
renderMessages();
}
async function loadCreators() {
try {
const response = await fetch("/api/v1/profiles?limit=100", {
credentials: "same-origin",
});
if (!response.ok) throw new Error("Creators could not be loaded.");
creators = await response.json();
} catch {
creators = [
{ userId: "demo-alex", displayName: "Alex Chen", slug: "alex", avatarUrl: "../images/content/creator2.png" },
{ userId: "demo-mia", displayName: "Mia Wong", slug: "mia", avatarUrl: "../images/content/creator3.png" },
{ userId: "demo-tom", displayName: "Tom Rivera", slug: "tom", avatarUrl: "../images/content/creator4.png" },
];
}
renderCreatorResults();
}
function renderCreatorResults() {
const search = creatorSearch.value.trim().toLowerCase();
const results = creators
.filter((creator) =>
`${creator.displayName || ""} ${creator.slug || ""}`.toLowerCase().includes(search),
)
.slice(0, 8);
if (!results.length) {
creatorSearchResults.innerHTML = '<div class="creator-result-empty">No creators found.</div>';
return;
}
creatorSearchResults.innerHTML = results
.map((creator) => `
<button type="button" class="creator-result" data-user-id="${escapeHtml(creator.userId)}" role="option">
<img src="${escapeHtml(creator.avatarUrl || "../images/content/cat.png")}" alt="" />
<span>
<strong>${escapeHtml(creator.displayName)}</strong>
<small>@${escapeHtml(creator.slug || "creator")}</small>
</span>
</button>
`)
.join("");
creatorSearchResults.querySelectorAll("[data-user-id]").forEach((button) => {
button.addEventListener("click", () => {
const creator = creators.find((item) => item.userId === button.dataset.userId);
if (creator) startConversation(creator);
});
});
}
function openConversationFromUrl() {
const params = new URLSearchParams(location.search);
const userId = params.get("userId");
if (!userId) return false;
startConversation({
userId,
displayName: params.get("name") || "Creator",
slug: "creator",
avatarUrl: params.get("avatar") || "../images/content/cat.png",
});
history.replaceState(null, "", "/chats.html");
return true;
}
newChatBtn.addEventListener("click", () => {
const willOpen = newChatPanel.hidden;
newChatPanel.hidden = !willOpen;
newChatBtn.setAttribute("aria-expanded", String(willOpen));
if (willOpen) creatorSearch.focus();
});
creatorSearch.addEventListener("input", renderCreatorResults);
chatForm.addEventListener("submit", (event) => {
event.preventDefault();
addMessage(messageInput.value);
messageInput.value = "";
});
loadCreators().then(() => {
if (!openConversationFromUrl()) {
activeConversationId = conversations[0]?.id || null;
renderChatList();
renderMessages();
}
});
</script>
</body>
</html>

View File

@ -99,6 +99,7 @@
function renderCard(c) {
const profileHref = `/profile?id=${encodeURIComponent(c.userId)}`;
const chatHref = `/chats.html?userId=${encodeURIComponent(c.userId)}&name=${encodeURIComponent(c.displayName)}&avatar=${encodeURIComponent(c.avatarUrl || "../images/content/cat.png")}`;
return `
<div class="creator-card">
<a class="creator-avatar-link" href="${profileHref}" aria-label="Open profile for ${c.displayName}">
@ -115,6 +116,7 @@
<span><i class="bi bi-people" aria-hidden="true"></i> ${c.subscribers} subscribers</span>
${c.averageRating > 0 ? `<span>${renderStars(c.averageRating)}</span>` : ""}
</div>
<div class="creator-actions">
<button type="button" class="follow-btn ${c.isFollowing ? "following" : ""}"
data-userid="${c.userId}"
data-following="${c.isFollowing}"
@ -122,6 +124,11 @@
aria-label="${c.isFollowing ? "Unfollow" : "Follow"} ${c.displayName}">
${c.isFollowing ? "Following" : "Follow"}
</button>
<a class="creator-chat-btn" href="${chatHref}" aria-label="Chat with ${c.displayName}">
<i class="bi bi-chat-dots" aria-hidden="true"></i>
Chat
</a>
</div>
</div>
</div>`;
}

View File

@ -42,9 +42,81 @@
.new-chat-btn {
background: none;
border: none;
border-radius: 999px;
font-size: 1.2rem;
color: #3b82f6;
cursor: pointer;
padding: 8px;
}
.new-chat-btn:hover,
.new-chat-btn[aria-expanded="true"] {
background: #eef2ff;
}
.new-chat-panel {
border-bottom: 1px solid #eef2f7;
padding: 14px 16px;
}
.new-chat-panel input {
width: 100%;
border: 1px solid #dbe2ea;
border-radius: 12px;
padding: 10px 12px;
font: inherit;
}
.creator-search-results {
display: grid;
gap: 8px;
margin-top: 10px;
max-height: 260px;
overflow-y: auto;
}
.creator-result {
align-items: center;
background: #f8fafc;
border: 1px solid transparent;
border-radius: 12px;
color: inherit;
cursor: pointer;
display: flex;
gap: 10px;
padding: 10px;
text-align: left;
width: 100%;
}
.creator-result:hover,
.creator-result:focus-visible {
background: #eef2ff;
border-color: #c7d2fe;
}
.creator-result img {
border-radius: 50%;
height: 36px;
object-fit: cover;
width: 36px;
}
.creator-result span {
display: grid;
}
.creator-result small,
.creator-result-empty,
.chat-empty {
color: #64748b;
font-size: 0.82rem;
}
.creator-result-empty,
.chat-empty {
padding: 18px;
text-align: center;
}
.chat-list-items {
@ -195,6 +267,7 @@
padding: 16px 24px;
border-top: 1px solid #eef2f7;
background: #fff;
margin: 0;
}
.chat-input-area input {
flex: 1;
@ -207,6 +280,12 @@
.chat-input-area input:focus {
border-color: #3b82f6;
}
.chat-input-area input:disabled {
background: #f8fafc;
cursor: not-allowed;
}
.send-btn {
background: var(--gradient);
border: none;
@ -221,6 +300,11 @@
opacity: 0.85;
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.chats-main {

View File

@ -133,7 +133,14 @@
.creator-stats i {
margin-right: 4px;
}
.follow-btn {
.creator-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.follow-btn,
.creator-chat-btn {
background: var(--gradient);
color: white;
border: none;
@ -142,11 +149,25 @@
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
min-height: 34px;
text-decoration: none;
transition: opacity 0.2s;
}
.follow-btn:hover {
.follow-btn:hover,
.creator-chat-btn:hover {
opacity: 0.85;
}
.creator-chat-btn {
background: #eef2ff;
color: #2563eb;
}
.follow-btn.following {
background: transparent;
border: 2px solid #94a3b8;
@ -187,6 +208,10 @@
.follow-btn {
width: 100%;
}
.creator-actions,
.creator-chat-btn {
width: 100%;
}
}
/* Star rating in creator cards */