diff --git a/ui/actions.mjs b/ui/actions.mjs index 7d6fadf..448ee1a 100644 --- a/ui/actions.mjs +++ b/ui/actions.mjs @@ -14,7 +14,7 @@ export function toast(message, type = "info", timeout = 5) { setTimeout(() => { toast.remove(); - }, timeout * 10000); + }, timeout * 1000); } export function popup(popup, classes = []) { diff --git a/ui/api/Popups.mjs b/ui/api/Popups.mjs new file mode 100644 index 0000000..b64d351 --- /dev/null +++ b/ui/api/Popups.mjs @@ -0,0 +1,56 @@ +import {popup, removePopups, toast} from "../actions.mjs"; +import {PopupComponents} from "../components/popup.mjs"; +import {Api} from "./Api.mjs"; +import {Live} from "../live/Live.mjs"; +import {CommonTemplates} from "../components/common.mjs"; +import {signal} from "https://fjs.targoninc.com/f.js"; +import {Store} from "./Store.mjs"; + +export class Popups { + static newDm() { + const userSearchResults = signal([]); + const channels = Store.get("channels"); + + popup(PopupComponents.searchPopup(() => { + removePopups(); + }, (e) => { + const query = e.target.value; + if (query.length < 3) { + userSearchResults.value = []; + return; + } + + Api.search(query).then((res) => { + if (res.status !== 200) { + toast("Failed to search for users", "error"); + return; + } + userSearchResults.value = res.data.filter(user => { + return !channels.value.some(channel => { + if (channel.type === "dm" && channel.members.length === 1) { + return channel.members[0].id === user.id; + } + + return channel.type === "dm" && channel.members[1].id === user.id; + }); + }); + }) + }, () => {}, userSearchResults, (result) => { + return CommonTemplates.chatWithButton(result.username, () => { + Api.createDirect(result.id).then((res) => { + if (res.status !== 200) { + toast("Failed to create DM", "error"); + removePopups(); + return; + } + toast("DM created", "success"); + Live.send({ + type: "createChannel", + channelId: res.data.id, + }); + removePopups(); + }); + }); + }, "New DM", "Search for users")); + } +} \ No newline at end of file diff --git a/ui/api/Store.mjs b/ui/api/Store.mjs index cbb65a9..1241f80 100644 --- a/ui/api/Store.mjs +++ b/ui/api/Store.mjs @@ -22,7 +22,7 @@ export class Store { static definition = { user: { type: "object", - default: null, + default: signal(null), }, currentChannelId: { type: "number", diff --git a/ui/classes.css b/ui/classes.css index 0d1b4f5..83d1c11 100644 --- a/ui/classes.css +++ b/ui/classes.css @@ -275,4 +275,10 @@ .resize-indicator:hover { background: rgba(150, 150, 150, 0.2); +} + +.one-line { + white-space: nowrap; + overflow: hidden; + max-width: 100%; } \ No newline at end of file diff --git a/ui/components/channel.mjs b/ui/components/channel.mjs new file mode 100644 index 0000000..b18835e --- /dev/null +++ b/ui/components/channel.mjs @@ -0,0 +1,67 @@ +import {computedSignal, create, ifjs, signal, signalMap} from "https://fjs.targoninc.com/f.js"; +import {Live} from "../live/Live.mjs"; +import {truncate} from "../tooling/Text.mjs"; + +export class ChannelTemplates { + static dmChannel(channel, messages, activeChannel) { + const activeClass = computedSignal(activeChannel, (id) => id === channel.id ? "active" : "_"); + + return create("div") + .classes("channel", "flex-v", "no-gap", "full-width", activeClass) + .onclick(() => { + activeChannel.value = channel.id; + }) + .children( + create("span") + .text(channel.name) + .build(), + create("span") + .classes("text-small", "one-line") + .text(truncate(messages.value[channel.id]?.at(-1)?.text || "No messages", 100)) + .build(), + ).build(); + } + + static groupChannel(channel, messages, activeChannel) { + const activeClass = computedSignal(activeChannel, (id) => id === channel.id ? "active" : "_"); + const editing = signal(false); + + return create("div") + .classes("channel", "flex-v", "full-width", activeClass) + .onclick(() => { + activeChannel.value = channel.id; + }) + .children( + ifjs(editing, create("input") + .type("text") + .value(channel.name) + .onchange((e) => { + Live.send({ + type: "updateChannel", + channelId: channel.id, + name: e.target.value, + }); + }).build()), + ifjs(editing, create("span") + .text(channel.name) + .build(), true), + create("span") + .classes("text-small") + .text("Group") + .build(), + ).build(); + } + + static channelList(channels, messages, activeChannel) { + return signalMap(channels, + create("div") + .classes("flex-v", "no-gap") + , channel => { + if (channel.type === "gr") { + return ChannelTemplates.groupChannel(channel, messages, activeChannel); + } else { + return ChannelTemplates.dmChannel(channel, messages, activeChannel); + } + }); + } +} \ No newline at end of file diff --git a/ui/components/common.mjs b/ui/components/common.mjs index e4dd01b..462c714 100644 --- a/ui/components/common.mjs +++ b/ui/components/common.mjs @@ -1,4 +1,9 @@ -import {create, FjsObservable, ifjs} from "https://fjs.targoninc.com/f.js"; +import {create, FjsObservable, ifjs, signal, signalFromProperty} from "https://fjs.targoninc.com/f.js"; +import {popup, removePopups, toast} from "../actions.mjs"; +import {PopupComponents} from "./popup.mjs"; +import {Api} from "../api/Api.mjs"; +import {Live} from "../live/Live.mjs"; +import {Popups} from "../api/Popups.mjs"; export class CommonTemplates { static icon(icon, classes = [], tag = "span") { @@ -73,6 +78,26 @@ export class CommonTemplates { ).build(); } + static actions(userSignal) { + const username = signalFromProperty(userSignal, "username"); + const displayname = signalFromProperty(userSignal, "displayname"); + + return create("div") + .classes("flex", "align-center", "full-width", "space-between", "padded") + .children( + CommonTemplates.buttonWithIcon("chat", "Chat", () => window.router.navigate('chat')), + CommonTemplates.buttonWithIcon("person_add", "New DM", () => Popups.newDm()), + create("div") + .classes("padded") + .children( + CommonTemplates.userInList("face_5", displayname, username, () => { + window.router.navigate('profile'); + }) + ).build(), + CommonTemplates.pageLink("Logout", "logout") + ).build(); + } + static userInList(image, name, text, onclick) { return create("button") .classes("flex") diff --git a/ui/components/pages/chat.mjs b/ui/components/pages/chat.mjs index d7676a7..93201c9 100644 --- a/ui/components/pages/chat.mjs +++ b/ui/components/pages/chat.mjs @@ -2,12 +2,10 @@ import {computedSignal, create, ifjs, signal, signalMap} from "https://fjs.targo import {LayoutTemplates} from "../layout.mjs"; import {Store} from "../../api/Store.mjs"; import {CommonTemplates} from "../common.mjs"; -import {Api} from "../../api/Api.mjs"; -import {popup, removePopups, toast} from "../../actions.mjs"; import {Hooks, removeMessage} from "../../api/Hooks.mjs"; import {Time} from "../../tooling/Time.mjs"; import {Live} from "../../live/Live.mjs"; -import {PopupComponents} from "../popup.mjs"; +import {ChannelTemplates} from "../channel.mjs"; export class ChatComponent { static render() { @@ -47,30 +45,17 @@ export class ChatComponent { return create("div") .classes("panes-v", "full-width", "full-height") .children( - ChatComponent.actions(user, channels), + CommonTemplates.actions(user, channels), create("div") .classes("panes", "full-width", "flex-grow") .children( - LayoutTemplates.resizableFromRight(ChatComponent.channelList(displayChannels, messages, activeChannel), "50%", "200px", "50%"), + LayoutTemplates.resizableFromRight(ChannelTemplates.channelList(displayChannels, messages, activeChannel), "50%", "200px", "50%"), ifjs(activeChannel, LayoutTemplates.flexPane(ChatComponent.chat(activeChannel, messages), "300px", "100%")), ifjs(activeChannel, LayoutTemplates.flexPane(create("span").text("No channel selected").build(), "300px", "100%"), true) ).build() ).build(); } - static channelList(channels, messages, activeChannel) { - return signalMap(channels, - create("div") - .classes("flex-v", "no-gap") - , channel => { - if (channel.type === "gr") { - return ChatComponent.groupChannel(channel, messages, activeChannel); - } else { - return ChatComponent.dmChannel(channel, messages, activeChannel); - } - }); - } - static chat(activeChannel, allMessages) { const sending = signal(false); const messageText = signal(""); @@ -133,94 +118,6 @@ export class ChatComponent { }, sending, ["rounded-max", "double"]); } - static actions(user, channels) { - const userSearchResults = signal([]); - - return create("div") - .classes("flex", "align-center", "full-width", "space-between") - .children( - CommonTemplates.buttonWithIcon("person_add", "New DM", () => { - popup(PopupComponents.searchPopup(() => { - removePopups(); - }, (e) => { - const query = e.target.value; - if (query.length < 3) { - userSearchResults.value = []; - return; - } - - Api.search(query).then((res) => { - if (res.status !== 200) { - toast("Failed to search for users", "error"); - return; - } - userSearchResults.value = res.data.filter(user => { - return !channels.value.some(channel => { - if (channel.type === "dm" && channel.members.length === 1) { - return channel.members[0].id === user.id; - } - - return channel.type === "dm" && channel.members[1].id === user.id; - }); - }); - }) - }, () => {}, userSearchResults, (result) => { - return CommonTemplates.chatWithButton(result.username, () => { - Api.createDirect(result.id).then((res) => { - if (res.status !== 200) { - toast("Failed to create DM", "error"); - removePopups(); - return; - } - toast("DM created", "success"); - Live.send({ - type: "createChannel", - channelId: res.data.id, - }); - removePopups(); - }); - }); - }, "New DM", "Search for users")); - }), - create("div") - .classes("padded") - .children( - CommonTemplates.userInList("face_5", user.displayname ?? user.displayname, user.username, () => {}) - ).build(), - CommonTemplates.pageLink("Logout", "logout") - ).build(); - } - - static groupChannel(channel, messages, activeChannel) { - const activeClass = computedSignal(activeChannel, (id) => id === channel.id ? "active" : "_"); - const editing = signal(false); - - return create("div") - .classes("channel", "flex-v", "full-width", activeClass) - .onclick(() => { - activeChannel.value = channel.id; - }) - .children( - ifjs(editing, create("input") - .type("text") - .value(channel.name) - .onchange((e) => { - Live.send({ - type: "updateChannel", - channelId: channel.id, - name: e.target.value, - }); - }).build()), - ifjs(editing, create("span") - .text(channel.name) - .build(), true), - create("span") - .classes("text-small") - .text("Group") - .build(), - ).build(); - } - static message(message, messages) { const messageIndex = messages.value.indexOf(message); const previousMessage = messages.value[messageIndex - 1]; @@ -289,23 +186,4 @@ export class ChatComponent { }), ).build(); } - - static dmChannel(channel, messages, activeChannel) { - const activeClass = computedSignal(activeChannel, (id) => id === channel.id ? "active" : "_"); - - return create("div") - .classes("channel", "flex-v", "full-width", activeClass) - .onclick(() => { - activeChannel.value = channel.id; - }) - .children( - create("span") - .text(channel.name) - .build(), - create("span") - .classes("text-small") - .text(messages.value[channel.id]?.at(-1)?.text || "No messages") - .build(), - ).build(); - } } \ No newline at end of file diff --git a/ui/components/pages/profile.mjs b/ui/components/pages/profile.mjs new file mode 100644 index 0000000..3d2e6ba --- /dev/null +++ b/ui/components/pages/profile.mjs @@ -0,0 +1,75 @@ +import {LayoutTemplates} from "../layout.mjs"; +import {create, signalFromProperty, store} from "https://fjs.targoninc.com/f.js"; +import {CommonTemplates} from "../common.mjs"; +import {Store} from "../../api/Store.mjs"; +import {Api} from "../../api/Api.mjs"; +import {toast} from "../../actions.mjs"; + +export class ProfileComponent { + static render() { + return LayoutTemplates.pageFull(ProfileComponent.content()); + } + + static content() { + const user = Store.get('user'); + const channels = Store.get("channels"); + + return create("div") + .classes("panes-v", "full-width", "full-height") + .children( + CommonTemplates.actions(user, channels), + create("div") + .classes("panes", "full-width", "flex-grow") + .children( + LayoutTemplates.pane(LayoutTemplates.centeredContent(ProfileComponent.basicInfoSection(user)), "100%", "500px", "100%") + ).build() + ).build(); + } + + static basicInfoSection(user) { + const username = signalFromProperty(user, "username"); + const displayname = signalFromProperty(user, "displayname"); + const description = signalFromProperty(user, "description"); + const updateUser = () => { + Api.updateUser(username.value, displayname.value, description.value).then((res) => { + if (res.status !== 200) { + toast("Failed to update user info", "error"); + return; + } + toast("User info updated", "success"); + Api.getUser().then((res) => { + if (res.status !== 200) { + toast("Failed to get user info", "error"); + return; + } + store().setSignalValue('user', res.data.user); + }); + }); + }; + + return create("div") + .classes("flex-v") + .children( + create("div") + .classes("flex") + .children( + create("div") + .classes("flex-v") + .children( + CommonTemplates.input("text", "username", "Username", "New username", username, (e) => { + username.value = e.target.value; + updateUser(); + }, true), + CommonTemplates.input("text", "displayname", "Display name", "New display name", displayname, (e) => { + displayname.value = e.target.value; + updateUser(); + }, true), + CommonTemplates.input("text", "description", "Description", "New description", description, (e) => { + description.value = e.target.value; + updateUser(); + }, true), + ).build(), + ).build(), + ).build(); + } +} \ No newline at end of file diff --git a/ui/index.mjs b/ui/index.mjs index 8e72db4..19d0899 100644 --- a/ui/index.mjs +++ b/ui/index.mjs @@ -5,6 +5,7 @@ import {Api} from "./api/Api.mjs"; import {Store} from "./api/Store.mjs"; import {Hooks} from "./api/Hooks.mjs"; import {Live} from "./live/Live.mjs"; +import {store} from "https://fjs.targoninc.com/f.js"; Store.create(); @@ -14,10 +15,10 @@ window.router = new Router(routes, async (route, params) => { const res = await Api.getUser(); if (res.status === 200) { - Store.set('user', res.data.user); + store().setSignalValue('user', res.data.user); Hooks.runUser(res.data.user); } else { - Store.set('user', null); + store().setSignalValue('user', null); if (route.noUser) { window.router.navigate(route.noUser); return; diff --git a/ui/routing/Page.mjs b/ui/routing/Page.mjs index bd4f336..625dda7 100644 --- a/ui/routing/Page.mjs +++ b/ui/routing/Page.mjs @@ -73,6 +73,10 @@ export class Page { "uitest": { path: "uitest", component: "UiTestComponent" + }, + "profile": { + path: "profile", + component: "ProfileComponent" } }; } \ No newline at end of file diff --git a/ui/routing/Routes.mjs b/ui/routing/Routes.mjs index 5c37f5b..28f4da8 100644 --- a/ui/routing/Routes.mjs +++ b/ui/routing/Routes.mjs @@ -25,5 +25,10 @@ export const routes = [ { path: "uitest", title: "UI Test" + }, + { + path: "profile", + title: "Profile", + noUser: "login" } ]; \ No newline at end of file diff --git a/ui/tooling/Text.mjs b/ui/tooling/Text.mjs new file mode 100644 index 0000000..4b52f71 --- /dev/null +++ b/ui/tooling/Text.mjs @@ -0,0 +1,11 @@ +export const truncate = (text, maxLength) => { + return text.length > maxLength ? text.substring(0, maxLength) + "..." : text; +} + +export const fallback = (a, b) => { + return !a || a.trim() === '' ? b : a; +} + +export const bigNumber = (number) => { + return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} \ No newline at end of file