diff --git a/v1/app.go b/v1/app.go index 3d72dc3..d01b6c6 100644 --- a/v1/app.go +++ b/v1/app.go @@ -67,6 +67,8 @@ func (a *App) startup(ctx context.Context) { services, err := models.NewServices( models.WithDatabase(db), models.WithAccountService(), + models.WithChannelService(), + models.WithAccountChannelService(), ) if err != nil { log.Fatal(err) @@ -100,6 +102,84 @@ func (a *App) shutdown(ctx context.Context) { a.logFileMu.Unlock() } +func (a *App) AddChannel(apiKey string) error { + client := rumblelivestreamlib.Client{StreamKey: apiKey} + resp, err := client.Request() + if err != nil { + a.logError.Println("error executing api request:", err) + return fmt.Errorf("Error querying API. Verify key and try again.") + } + + userKey := apiKey + channelKey := "" + if resp.Type == "channel" { + userKey = "" + channelKey = apiKey + } + + err = a.addAccountNotExist(resp.UserID, resp.Username, userKey) + if err != nil { + a.logError.Println("error adding account if not exist:", err) + return fmt.Errorf("Error adding channel. Try again.") + } + + if resp.Type == "channel" { + err = a.addChannelNotExist(resp.Username, fmt.Sprint(resp.ChannelID), resp.ChannelName, channelKey) + if err != nil { + a.logError.Println("error adding channel if not exist:", err) + return fmt.Errorf("Error adding channel. Try again.") + } + } + + return nil +} + +func (a *App) addAccountNotExist(uid string, username string, apiKey string) error { + acct, err := a.services.AccountS.ByUsername(username) + if err != nil { + return fmt.Errorf("error querying account by username: %v", err) + } + if acct == nil { + err = a.services.AccountS.Create(&models.Account{ + UID: &uid, + Username: &username, + ApiKey: &apiKey, + }) + if err != nil { + return fmt.Errorf("error creating account: %v", err) + } + } + return nil +} + +func (a *App) addChannelNotExist(username string, cid string, name string, apiKey string) error { + channel, err := a.services.ChannelS.ByName(name) + if err != nil { + return fmt.Errorf("error querying channel by name: %v", err) + } + if channel == nil { + acct, err := a.services.AccountS.ByUsername(username) + if err != nil { + return fmt.Errorf("error querying account by username: %v", err) + } + if acct == nil { + return fmt.Errorf("account does not exist with username: %s", username) + } + + err = a.services.ChannelS.Create(&models.Channel{ + AccountID: acct.ID, + CID: &cid, + Name: &name, + ApiKey: &apiKey, + }) + if err != nil { + return fmt.Errorf("error creating channel: %v", err) + } + } + + return nil +} + func (a *App) Login(username string, password string) error { var err error client, exists := a.clients[username] @@ -133,7 +213,7 @@ func (a *App) Login(username string, password string) error { return fmt.Errorf("Error logging in. Try again.") } if act == nil { - act = &models.Account{nil, &username, &cookiesS} + act = &models.Account{nil, nil, &username, &cookiesS, nil, nil} err = a.services.AccountS.Create(act) if err != nil { a.logError.Println("error creating account:", err) @@ -143,10 +223,54 @@ func (a *App) Login(username string, password string) error { act.Cookies = &cookiesS err = a.services.AccountS.Update(act) if err != nil { - a.logError.Println("error updating account", err) + a.logError.Println("error updating account:", err) return fmt.Errorf("Error logging in. Try again.") } } return nil } + +func (a *App) SignedIn() (bool, error) { + accounts, err := a.services.AccountS.All() + if err != nil { + a.logError.Println("error getting all accounts:", err) + return false, fmt.Errorf("Error retrieving accounts. Try restarting.") + } + + return len(accounts) > 0, nil +} + +type Account struct { + Account models.Account `json:"account"` + Channels []models.Channel `json:"channels"` +} + +func (a *App) AccountList() (map[string]*Account, error) { + list := map[string]*Account{} + + accountChannels, err := a.services.AccountChannelS.All() + if err != nil { + a.logError.Println("error getting all account channels:", err) + return nil, fmt.Errorf("Error retrieving accounts and channels. Try restarting.") + } + + for _, ac := range accountChannels { + if ac.Account.Username == nil { + a.logError.Println("account-channel contains nil account username") + return nil, fmt.Errorf("Error retrieving accounts and channels. Try restarting.") + } + + act, exists := list[*ac.Account.Username] + if !exists || act == nil { + act = &Account{ac.Account, []models.Channel{}} + list[*ac.Account.Username] = act + } + + if ac.Channel.AccountID != nil { + act.Channels = append(act.Channels, ac.Channel) + } + } + + return list, nil +} diff --git a/v1/frontend/src/App.jsx b/v1/frontend/src/App.jsx index de85ca2..39affbd 100644 --- a/v1/frontend/src/App.jsx +++ b/v1/frontend/src/App.jsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { MemoryRouter as Router, Route, Routes, Link } from 'react-router-dom'; import './App.css'; -import { NavSignIn } from './Navigation'; +import { NavDashboard, NavSignIn } from './Navigation'; +import Dashboard from './screens/Dashboard'; import SignIn from './screens/SignIn'; function App() { @@ -9,6 +10,7 @@ function App() { } /> + } /> ); diff --git a/v1/frontend/src/Navigation.jsx b/v1/frontend/src/Navigation.jsx index 3b66294..2293540 100644 --- a/v1/frontend/src/Navigation.jsx +++ b/v1/frontend/src/Navigation.jsx @@ -1 +1,2 @@ +export const NavDashboard = '/dashboard'; export const NavSignIn = '/'; diff --git a/v1/frontend/src/assets/icons/chevron-right.png b/v1/frontend/src/assets/icons/chevron-right.png new file mode 100644 index 0000000..cdcd507 Binary files /dev/null and b/v1/frontend/src/assets/icons/chevron-right.png differ diff --git a/v1/frontend/src/assets/icons/circle-green-background.png b/v1/frontend/src/assets/icons/circle-green-background.png new file mode 100644 index 0000000..62f56ca Binary files /dev/null and b/v1/frontend/src/assets/icons/circle-green-background.png differ diff --git a/v1/frontend/src/assets/icons/circle-red-background.png b/v1/frontend/src/assets/icons/circle-red-background.png new file mode 100644 index 0000000..5968d0d Binary files /dev/null and b/v1/frontend/src/assets/icons/circle-red-background.png differ diff --git a/v1/frontend/src/assets/icons/heart-fill.png b/v1/frontend/src/assets/icons/heart-fill.png new file mode 100644 index 0000000..293d511 Binary files /dev/null and b/v1/frontend/src/assets/icons/heart-fill.png differ diff --git a/v1/frontend/src/assets/icons/plus-circle-fill.png b/v1/frontend/src/assets/icons/plus-circle-fill.png new file mode 100644 index 0000000..785e701 Binary files /dev/null and b/v1/frontend/src/assets/icons/plus-circle-fill.png differ diff --git a/v1/frontend/src/assets/index.js b/v1/frontend/src/assets/index.js index 6099f55..8606948 100644 --- a/v1/frontend/src/assets/index.js +++ b/v1/frontend/src/assets/index.js @@ -1,9 +1,17 @@ +import chevron_right from './icons/chevron-right.png'; +import circle_green_background from './icons/circle-green-background.png'; import eye from './icons/eye.png'; import eye_slash from './icons/eye-slash.png'; +import heart from './icons/heart-fill.png'; +import plus_circle from './icons/plus-circle-fill.png' import x_lg from './icons/x-lg.png'; import logo from './logo/logo.png'; +export const ChevronRight = chevron_right; +export const CircleGreenBackground = circle_green_background; export const Eye = eye; export const EyeSlash = eye_slash; +export const Heart = heart; export const Logo = logo; +export const PlusCircle = plus_circle; export const XLg = x_lg; \ No newline at end of file diff --git a/v1/frontend/src/components/ChannelSideBar.css b/v1/frontend/src/components/ChannelSideBar.css new file mode 100644 index 0000000..2fba033 --- /dev/null +++ b/v1/frontend/src/components/ChannelSideBar.css @@ -0,0 +1,357 @@ + +.channel-sidebar { + align-items: center; + background-color: #061726; + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + padding: 0px 10px; +} + +.channel-sidebar-account-list { + border-top: 2px solid #273848; + padding-bottom: 10px; +} + +.channel-sidebar-body { + overflow-y: auto; +} + +.channel-sidebar-button { + align-items: center; + background-color: #061726; + border: none; + display: flex; + justify-content: center; + padding: 0px; +} + +.channel-sidebar-button:hover { + cursor: pointer; +} + +.channel-sidebar-button-icon { + height: 60px; + width: 60px; +} + +.channel-sidebar-footer { + padding-bottom: 10px; +} + +.channel-sidebar-icon { + height: 60px; + margin-top: 10px; + position: relative; + width: 60px; +} + +.channel-sidebar-icon-account { + bottom: 0px; + height: 24px; + left: 36px; + position: absolute; + width: 24px; +} + +.channel-sidebar-icon-hover { + background-color: #061726; + border-radius: 5px; + color: black; + padding: 10px; + position: fixed; + /* transform: translate(75px, -50px); */ + z-index: 10; +} +.channel-sidebar-icon-hover:before { + content:""; + position: absolute; + width: 0; + height: 0; + border-top: 3px solid transparent; + border-right: 3px solid #061726; + border-bottom: 3px solid transparent; + margin: 7px 0 0 -13px; +} + +.channel-sidebar-icon-hover-text { + color: white; + font-family: sans-serif; + font-weight: bold; + font-size: 16px; +} + +.channel-sidebar-icon-image { + /* border: 3px solid #85c742; */ + /* border: 3px solid #ec0; */ + border: 3px solid #f23160; + border-radius: 50%; + height: 54px; + transition: border-radius 0.25s; + width: 54px; +} + +.channel-sidebar-icon-image:hover { + border-radius: 30%; + transition: border-radius 0.25s; +} + +.channel-sidebar-icon-initial { + align-items: center; + background-color: #3377cc; + /* border: 3px solid #85c742; */ + /* border: 3px solid #ec0; */ + border: 3px solid #f23160; + border-radius: 50%; + color: #eee; + display: flex; + font-family: sans-serif; + font-size: 34px; + font-weight: bold; + height: 54px; + justify-content: center; + transition: border-radius 0.25s; + width: 54px; +} + +.channel-sidebar-icon-initial:hover { + border-radius: 30%; + transition: border-radius 0.25s; +} + +.modal-add-account-channel { + align-items: center; + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + width: 100%; +} + +.modal-add-account-channel-header { + align-items: center; + display: flex; + flex-direction: column; +} + +.modal-add-account-channel-subtitle { + color: white; + font-family: sans-serif; + font-size: 14px; + margin-top: 10px; + text-align: center; +} + +.modal-add-account-channel-title { + color: white; + font-family: sans-serif; + font-size: 24px; + font-weight: bold; + text-align: center; +} + +.modal-add-account-channel-body { + align-items: center; + display: flex; + flex-direction: column; + width: 100%; +} + +.modal-add-account-channel-button { + align-items: center; + background-color: #1f2e3c; + border: 1px solid #d6e0ea; + border-radius: 5px; + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 5px 0px; + padding: 20px; + width: 100%; +} + +.modal-add-account-channel-button:hover { + background-color: rgba(255, 255, 255, 0.1); + cursor: pointer; +} + +.modal-add-account-channel-button-left { + color: white; + font-family: sans-serif; + font-size: 16px; + font-weight: bold; +} + +.modal-add-account-channel-button-right-icon { + height: 20px; + width: 20px; +} + +.modal-add-account-channel-input { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 10px; + width: 100%; +} + +.modal-add-account-channel-input-password { + background-color: #061726; + border: none; + border-radius: 5px 0px 0px 5px; + box-sizing: border-box; + color: white; + font-family: monospace; + font-size: 16px; + outline: none; + padding: 10px; + resize: none; + width: 90%; +} + +.modal-add-account-channel-input-text { + background-color: #061726; + border: none; + border-radius: 5px; + box-sizing: border-box; + color: white; + font-family: monospace; + font-size: 16px; + outline: none; + padding: 10px; + resize: none; + width: 100%; +} + +.modal-add-account-channel-input-show { + align-items: center; + background-color: #061726; + border: none; + border-radius: 0px 5px 5px 0px; + display: flex; + justify-content: center; + width: 10%; +} + +.modal-add-account-channel-input-show:hover { + cursor: pointer; +} + +.modal-add-account-channel-input-show-icon { + height: 16px; + width: 16px; +} + +.modal-add-account-channel-label { + color: white; + font-family: sans-serif; + font-size: 14px; + font-weight: bold; + margin-bottom: 10px; + width: 100%; +} + +.modal-add-account-channel-label-warning { + color: #f23160; + font-family: sans-serif; + font-size: 14px; + font-weight: bold; + margin-bottom: 10px; + width: 100%; +} + +.modal-add-channel-description { + color: white; + font-family: sans-serif; + font-size: 16px; + font-weight: bold; + margin-top: 20px; + text-align: left; + width: 100%; +} + +.modal-add-channel-description-subtext { + color: white; + font-family: sans-serif; + font-size: 16px; + margin-top: 10px; + text-align: left; + width: 100%; +} + +.modal-add-channel-key { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; +} + +.modal-add-channel-key-input { + background-color: #061726; + border: none; + border-radius: 5px 0px 0px 5px; + box-sizing: border-box; + color: white; + font-family: monospace; + font-size: 16px; + outline: none; + padding: 10px; + resize: none; + width: 90%; +} + +.modal-add-channel-key-show { + align-items: center; + background-color: #061726; + border: none; + border-radius: 0px 5px 5px 0px; + display: flex; + justify-content: center; + width: 10%; +} + +.modal-add-channel-key-show:hover { + cursor: pointer; +} + +.modal-add-channel-key-show-icon { + height: 16px; + width: 16px; +} + +.modal-add-channel-label { + color: white; + font-family: sans-serif; + font-size: 14px; + font-weight: bold; + margin-bottom: 10px; + width: 100%; +} + +.modal-add-channel-label-warning { + color: #f23160; + font-family: sans-serif; + font-size: 14px; + font-weight: bold; + margin-bottom: 10px; + width: 100%; +} + +/* HTML:
*/ +.loader { + width: 60px; + aspect-ratio: 6; + --_g: no-repeat radial-gradient(circle closest-side,#061726 90%,#0000); + background: + var(--_g) 0% 50%, + var(--_g) 50% 50%, + var(--_g) 100% 50%; + background-size: calc(100%/3) 100%; + animation: l7 1s infinite linear; +} +@keyframes l7 { + 33%{background-size:calc(100%/3) 0% ,calc(100%/3) 100%,calc(100%/3) 100%} + 50%{background-size:calc(100%/3) 100%,calc(100%/3) 0% ,calc(100%/3) 100%} + 66%{background-size:calc(100%/3) 100%,calc(100%/3) 100%,calc(100%/3) 0% } +} \ No newline at end of file diff --git a/v1/frontend/src/components/ChannelSideBar.jsx b/v1/frontend/src/components/ChannelSideBar.jsx new file mode 100644 index 0000000..0619fcb --- /dev/null +++ b/v1/frontend/src/components/ChannelSideBar.jsx @@ -0,0 +1,503 @@ +import { useEffect, useState } from 'react'; +import { Modal, SmallModal } from './Modal'; +import { AccountList, AddChannel, Login } from '../../wailsjs/go/main/App'; + +import { ChevronRight, CircleGreenBackground, Eye, EyeSlash, PlusCircle } from '../assets'; +import './ChannelSideBar.css'; + +function ChannelSideBar(props) { + const [accounts, setAccounts] = useState({}); + const [error, setError] = useState(''); + const [addOpen, setAddOpen] = useState(false); + const [refresh, setRefresh] = useState(false); + const [scrollY, setScrollY] = useState(0); + + useEffect(() => { + AccountList() + .then((response) => { + setAccounts(response); + }) + .catch((error) => { + setError(error); + }); + }, [refresh]); + + const sortAccounts = () => { + let keys = Object.keys(accounts); + + let sorted = [...keys].sort((a, b) => + accounts[a].account.username.toLowerCase() > accounts[b].account.username.toLowerCase() + ? 1 + : -1 + ); + + return sorted; + }; + + const handleScroll = (event) => { + setScrollY(event.target.scrollTop); + }; + + return ( + <> + setAddOpen(false)} + onRefresh={() => { + setRefresh(!refresh); + }} + show={addOpen} + /> +
+
+ {sortAccounts().map((account, index) => ( + + ))} +
+
+ setAddOpen(true)} + scrollY={0} + /> +
+
+ + ); +} + +export default ChannelSideBar; + +function AccountChannels(props) { + const sortChannels = () => { + let sorted = [...props.account.channels].sort((a, b) => + a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1 + ); + + return sorted; + }; + + if (props.account.account !== undefined) { + return ( +
+ + {sortChannels().map((channel, index) => ( + + ))} +
+ ); + } +} + +function AccountIcon(props) { + const [hover, setHover] = useState(false); + + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + > + {props.account.profile_image === null ? ( + + {props.account.username[0].toUpperCase()} + + ) : ( + + )} + + {hover && ( + + )} +
+ ); +} + +function ButtonIcon(props) { + const [hover, setHover] = useState(false); + + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {hover && } +
+ ); +} + +function ChannelIcon(props) { + const [hover, setHover] = useState(false); + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + > + {props.channel.profile_image === null ? ( + + {props.channel.name[0].toUpperCase()} + + ) : ( + + )} + {hover && ( + + )} +
+ ); +} + +function HoverName(props) { + return ( +
+ {props.name} +
+ ); +} + +function ModalAdd(props) { + const [accountPassword, setAccountPassword] = useState(''); + const [accountPasswordValid, setAccountPasswordValid] = useState(true); + const updateAccountPassword = (event) => { + if (loading()) { + return; + } + setAccountPassword(event.target.value); + }; + const [accountUsername, setAccountUsername] = useState(''); + const [accountUsernameValid, setAccountUsernameValid] = useState(true); + const updateAccountUsername = (event) => { + if (loading()) { + return; + } + setAccountUsername(event.target.value); + }; + const [addAccountLoading, setAddAccountLoading] = useState(false); + const [addChannelLoading, setAddChannelLoading] = useState(false); + const [channelKey, setChannelKey] = useState(''); + const [channelKeyValid, setChannelKeyValid] = useState(true); + const updateChannelKey = (event) => { + if (loading()) { + return; + } + setChannelKey(event.target.value); + }; + const [error, setError] = useState(''); + const [stage, setStage] = useState('start'); + + useEffect(() => { + if (addAccountLoading) { + Login(accountUsername, accountPassword) + .then(() => { + reset(); + props.onClose(); + props.onRefresh(); + }) + .catch((error) => { + setAddAccountLoading(false); + setError(error); + }); + } + }, [addAccountLoading]); + + useEffect(() => { + if (addChannelLoading) { + AddChannel(channelKey) + .then(() => { + reset(); + props.onClose(); + props.onRefresh(); + }) + .catch((error) => { + setAddChannelLoading(false); + setError(error); + }); + } + }, [addChannelLoading]); + + const back = () => { + if (loading()) { + return; + } + reset(); + }; + + const close = () => { + if (loading()) { + return; + } + reset(); + props.onClose(); + }; + + const reset = () => { + setStage('start'); + resetAccount(); + resetChannel(); + }; + + const add = () => { + switch (stage) { + case 'account': + addAccount(); + break; + case 'channel': + addChannel(); + break; + default: + close(); + } + }; + + const addAccount = () => { + if (loading()) { + return; + } + + if (accountUsername === '') { + setAccountUsernameValid(false); + return; + } + + if (accountPassword === '') { + setAccountPasswordValid(false); + return; + } + + setAddAccountLoading(true); + }; + + const addChannel = () => { + if (loading()) { + return; + } + + if (channelKey === '') { + setChannelKeyValid(false); + return; + } + + setAddChannelLoading(true); + }; + + const loading = () => { + return addAccountLoading || addChannelLoading; + }; + + const resetAccount = () => { + setAccountPassword(''); + setAccountPasswordValid(true); + setAccountUsername(''); + setAccountUsernameValid(true); + setAddAccountLoading(false); + }; + + const resetChannel = () => { + setChannelKey(''); + setChannelKeyValid(true); + setAddChannelLoading(false); + }; + + return ( + <> + setError('')} + show={error !== ''} + style={{ minWidth: '300px', maxWidth: '200px', maxHeight: '200px' }} + title={'Error'} + message={error} + submitButton={'OK'} + onSubmit={() => setError('')} + /> + + {stage === 'start' && } + {stage === 'account' && ( + + )} + {stage === 'channel' && ( + + )} + + + ); +} + +function ModalAddAccount(props) { + const [showKey, setShowKey] = useState(false); + const updateShowKey = () => setShowKey(!showKey); + const [showPassword, setShowPassword] = useState(false); + const updateShowPassword = () => setShowPassword(!showPassword); + + return ( +
+
+ Add Account + + Log into your Rumble account + +
+
+ {props.accountUsernameValid === false ? ( + + ) : ( + + )} +
+ +
+ {props.accountPasswordValid === false ? ( + + ) : ( + + )} +
+ + +
+
+
+
+ ); +} + +function ModalAddChannel(props) { + const [showKey, setShowKey] = useState(false); + const updateShowKey = () => setShowKey(!showKey); + + return ( +
+
+ Add Channel + + Copy an API key below to add a channel + +
+
+ {props.channelKeyValid === false ? ( + + ) : ( + + )} +
+ + +
+ API KEYS SHOULD LOOK LIKE + + https://rumble.com/-livestream-api/get-data?key=really-long_string-of_random-characters + +
+
+
+ ); +} + +function ModalAddStart(props) { + return ( +
+ Add an Account or Channel +
+ + +
+
+
+ ); +} diff --git a/v1/frontend/src/components/Modal.css b/v1/frontend/src/components/Modal.css index d4da5b4..670161f 100644 --- a/v1/frontend/src/components/Modal.css +++ b/v1/frontend/src/components/Modal.css @@ -1,7 +1,8 @@ .modal-background { align-items: center; - background-color: transparent; + /* background-color: transparent; */ + background-color: rgba(0,0,0,0.8); display: flex; height: 100vh; justify-content: center; @@ -29,6 +30,7 @@ font-weight: bold; text-decoration: none; /* width: 20%; */ + height: 40px; width: 70px; } @@ -36,12 +38,14 @@ background-color: transparent; border: 1px solid #495a6a; border-radius: 5px; - color: #495a6a; + /* color: #495a6a; */ + color: white; cursor: pointer; font-size: 18px; font-weight: bold; text-decoration: none; /* width: 20%; */ + height: 40px; width: 70px; } @@ -55,6 +59,7 @@ font-weight: bold; text-decoration: none; /* width: 20%; */ + height: 40px; width: 70px; } @@ -79,8 +84,8 @@ .modal-container { align-items: center; - background-color: rgba(6,23,38,1); - border: 1px solid #495a6a; + background-color: #1f2e3c; + /* border: 1px solid #495a6a; */ border-radius: 15px; color: black; display: flex; @@ -133,7 +138,7 @@ align-items: center; /* background-color: rgba(6,23,38,1); */ background-color: white; - border: 1px solid #495a6a; + /* border: 1px solid #495a6a; */ /* border: 1px solid black; */ border-radius: 15px; color: black; @@ -173,4 +178,4 @@ color: black; font-family: sans-serif; font-size: 24px; -} \ No newline at end of file +} diff --git a/v1/frontend/src/components/Modal.jsx b/v1/frontend/src/components/Modal.jsx index f123753..69aa0ad 100644 --- a/v1/frontend/src/components/Modal.jsx +++ b/v1/frontend/src/components/Modal.jsx @@ -6,7 +6,7 @@ export function Modal(props) {
- {props.submitButton} + {/* {props.submitButton} */} + {props.submitLoading ? ( +
+ ) : ( + props.submitButton + )} )}
diff --git a/v1/frontend/src/screens/Dashboard.css b/v1/frontend/src/screens/Dashboard.css new file mode 100644 index 0000000..195aeb3 --- /dev/null +++ b/v1/frontend/src/screens/Dashboard.css @@ -0,0 +1,7 @@ +.dashboard { + align-items: center; + display: flex; + flex-direction: row; + height: 100vh; + width: 100%; +} diff --git a/v1/frontend/src/screens/Dashboard.jsx b/v1/frontend/src/screens/Dashboard.jsx new file mode 100644 index 0000000..40ec5dc --- /dev/null +++ b/v1/frontend/src/screens/Dashboard.jsx @@ -0,0 +1,14 @@ +import { CircleGreenBackground, Heart } from '../assets'; +import ChannelSideBar from '../components/ChannelSideBar'; +import './Dashboard.css'; + +function Dashboard() { + return ( +
+ +
+
+ ); +} + +export default Dashboard; diff --git a/v1/frontend/src/screens/SignIn.jsx b/v1/frontend/src/screens/SignIn.jsx index 1ef9e73..2f04f9f 100644 --- a/v1/frontend/src/screens/SignIn.jsx +++ b/v1/frontend/src/screens/SignIn.jsx @@ -1,11 +1,14 @@ import { useEffect, useState } from 'react'; import { SmallModal } from '../components/Modal'; -import { Login } from '../../wailsjs/go/main/App'; +import { Login, SignedIn } from '../../wailsjs/go/main/App'; import { Eye, EyeSlash, Logo } from '../assets'; +import { Navigate, useNavigate } from 'react-router-dom'; import './SignIn.css'; +import { NavDashboard } from '../Navigation'; function SignIn() { const [error, setError] = useState(''); + const navigate = useNavigate(); const [password, setPassword] = useState(''); const updatePassword = (event) => setPassword(event.target.value); const [showPassword, setShowPassword] = useState(false); @@ -14,12 +17,25 @@ function SignIn() { const [username, setUsername] = useState(''); const updateUsername = (event) => setUsername(event.target.value); + useEffect(() => { + SignedIn() + .then((signedIn) => { + if (signedIn) { + navigate(NavDashboard); + } + }) + .catch((error) => { + setError(error); + }); + }, []); + useEffect(() => { if (signingIn) { Login(username, password) .then(() => { setUsername(''); setPassword(''); + navigate(NavDashboard); }) .catch((error) => { setError(error); diff --git a/v1/frontend/src/screens/Start.css b/v1/frontend/src/screens/Start.css new file mode 100644 index 0000000..e69de29 diff --git a/v1/frontend/src/screens/Start.jsx b/v1/frontend/src/screens/Start.jsx new file mode 100644 index 0000000..e69de29 diff --git a/v1/internal/config/config.go b/v1/internal/config/config.go index f0c3dff..492ea49 100644 --- a/v1/internal/config/config.go +++ b/v1/internal/config/config.go @@ -11,6 +11,8 @@ const ( configDirNix = ".rum-goggles" configDirWin = "RumGoggles" + imageDir = "images" + logFile = "rumgoggles.log" sqlFile = "rumgoggles.db" ) @@ -32,6 +34,22 @@ func Database() (string, error) { return path, nil } +func ImageDir() (string, error) { + cfgDir, err := configDir() + if err != nil { + return "", pkgErr("error getting config directory", err) + } + + dir := filepath.Join(cfgDir, imageDir) + + err = os.MkdirAll(dir, 0750) + if err != nil { + return "", fmt.Errorf("error making directory: %v", err) + } + + return dir, nil +} + // TODO: implement log rotation // Rotate log file every week? // Keep most recent 4 logs? diff --git a/v1/internal/models/account.go b/v1/internal/models/account.go index 5548af7..05b7493 100644 --- a/v1/internal/models/account.go +++ b/v1/internal/models/account.go @@ -6,36 +6,59 @@ import ( ) const ( - accountColumns = "id, username, cookies" + accountColumns = "id, uid, username, cookies, profile_image, api_key" accountTable = "account" ) type Account struct { - ID *int64 - Username *string - Cookies *string + ID *int64 `json:"id"` + UID *string `json:"uid"` + Username *string `json:"username"` + Cookies *string `json:"cookies"` + ProfileImage *string `json:"profile_image"` + ApiKey *string `json:"api_key"` +} + +func (a *Account) values() []any { + return []any{a.ID, a.UID, a.Username, a.Cookies, a.ProfileImage, a.ApiKey} +} + +func (a *Account) valuesNoID() []any { + return a.values()[1:] +} + +func (a *Account) valuesEndID() []any { + vals := a.values() + return append(vals[1:], vals[0]) } type sqlAccount struct { - id sql.NullInt64 - username sql.NullString - cookies sql.NullString + id sql.NullInt64 + uid sql.NullString + username sql.NullString + cookies sql.NullString + profileImage sql.NullString + apiKey sql.NullString } func (sa *sqlAccount) scan(r Row) error { - return r.Scan(&sa.id, &sa.username, &sa.cookies) + return r.Scan(&sa.id, &sa.uid, &sa.username, &sa.cookies, &sa.profileImage, &sa.apiKey) } func (sa sqlAccount) toAccount() *Account { var a Account a.ID = toInt64(sa.id) + a.UID = toString(sa.uid) a.Username = toString(sa.username) a.Cookies = toString(sa.cookies) + a.ProfileImage = toString(sa.profileImage) + a.ApiKey = toString(sa.apiKey) return &a } type AccountService interface { + All() ([]Account, error) AutoMigrate() error ByUsername(username string) (*Account, error) Create(a *Account) error @@ -55,10 +78,41 @@ type accountService struct { Database *sql.DB } +func (as *accountService) All() ([]Account, error) { + selectQ := fmt.Sprintf(` + SELECT %s + FROM "%s" + `, accountColumns, accountTable) + + rows, err := as.Database.Query(selectQ) + if err != nil { + return nil, pkgErr("error executing select query", err) + } + defer rows.Close() + + accounts := []Account{} + for rows.Next() { + sa := &sqlAccount{} + + err = sa.scan(rows) + if err != nil { + return nil, pkgErr("error scanning row", err) + } + + accounts = append(accounts, *sa.toAccount()) + } + err = rows.Err() + if err != nil && err != sql.ErrNoRows { + return nil, pkgErr("error iterating over rows", err) + } + + return accounts, nil +} + func (as *accountService) AutoMigrate() error { err := as.createAccountTable() if err != nil { - return err + return pkgErr(fmt.Sprintf("error creating %s table", accountTable), err) } return nil @@ -68,14 +122,17 @@ func (as *accountService) createAccountTable() error { createQ := fmt.Sprintf(` CREATE TABLE IF NOT EXISTS "%s" ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + uid TEXT UNIQUE, username TEXT UNIQUE NOT NULL, - cookies TEXT + cookies TEXT, + profile_image TEXT, + api_key TEXT ) `, accountTable) _, err := as.Database.Exec(createQ) if err != nil { - return fmt.Errorf("error creating table: %v", err) + return fmt.Errorf("error executing create query: %v", err) } return nil @@ -103,7 +160,7 @@ func (as *accountService) ByUsername(username string) (*Account, error) { if err == sql.ErrNoRows { return nil, nil } - return nil, pkgErr(fmt.Sprintf("error querying \"%s\" by username", accountTable), err) + return nil, pkgErr("error executing select query", err) } return sa.toAccount(), nil @@ -124,22 +181,31 @@ func (as *accountService) Create(a *Account) error { VALUES (%s) `, accountTable, columns, values(columns)) - _, err = as.Database.Exec(insertQ, a.Username, a.Cookies) + _, err = as.Database.Exec(insertQ, a.valuesNoID()...) if err != nil { - return pkgErr(fmt.Sprintf("error inserting %s", accountTable), err) + return pkgErr("error executing insert query", err) } return nil } func (as *accountService) DestructiveReset() error { + err := as.dropAccountTable() + if err != nil { + return pkgErr(fmt.Sprintf("error dropping %s table", accountTable), err) + } + + return nil +} + +func (as *accountService) dropAccountTable() error { dropQ := fmt.Sprintf(` DROP TABLE IF EXISTS "%s" `, accountTable) _, err := as.Database.Exec(dropQ) if err != nil { - return fmt.Errorf("error dropping table: %v", err) + return fmt.Errorf("error executing drop query: %v", err) } return nil @@ -162,9 +228,9 @@ func (as *accountService) Update(a *Account) error { WHERE id=? `, accountTable, set(columns)) - _, err = as.Database.Exec(updateQ, a.Username, a.Cookies, a.ID) + _, err = as.Database.Exec(updateQ, a.valuesEndID()...) if err != nil { - return pkgErr(fmt.Sprintf("error updating %s", accountTable), err) + return pkgErr(fmt.Sprintf("error executing update query", accountTable), err) } return nil diff --git a/v1/internal/models/accountchannel.go b/v1/internal/models/accountchannel.go new file mode 100644 index 0000000..3ec36e2 --- /dev/null +++ b/v1/internal/models/accountchannel.go @@ -0,0 +1,94 @@ +package models + +import ( + "database/sql" + "fmt" +) + +const ( + accountChannelColumns = "a.id, a.uid, a.username, a.cookies, a.profile_image, a.api_key, c.id, c.account_id, c.cid, c.name, c.profile_image, c.api_key" +) + +type AccountChannel struct { + Account + Channel +} + +type sqlAccountChannel struct { + sqlAccount + sqlChannel +} + +func (sac *sqlAccountChannel) scan(r Row) error { + return r.Scan( + &sac.sqlAccount.id, + &sac.sqlAccount.uid, + &sac.sqlAccount.username, + &sac.sqlAccount.cookies, + &sac.sqlAccount.profileImage, + &sac.sqlAccount.apiKey, + &sac.sqlChannel.id, + &sac.sqlChannel.accountID, + &sac.sqlChannel.cid, + &sac.sqlChannel.name, + &sac.sqlChannel.profileImage, + &sac.sqlChannel.apiKey, + ) +} + +func (sac *sqlAccountChannel) toAccountChannel() *AccountChannel { + var ac AccountChannel + + ac.Account = *sac.toAccount() + ac.Channel = *sac.toChannel() + + return &ac +} + +type AccountChannelService interface { + All() ([]AccountChannel, error) +} + +func NewAccountChannelService(db *sql.DB) AccountChannelService { + return &accountChannelService{ + Database: db, + } +} + +var _ AccountChannelService = &accountChannelService{} + +type accountChannelService struct { + Database *sql.DB +} + +func (as *accountChannelService) All() ([]AccountChannel, error) { + selectQ := fmt.Sprintf(` + SELECT %s + FROM "%s" a + LEFT JOIN "%s" c ON a.id=c.account_id + `, accountChannelColumns, accountTable, channelTable) + + rows, err := as.Database.Query(selectQ) + if err != nil { + return nil, pkgErr("error executing select query", err) + } + defer rows.Close() + + accountChannels := []AccountChannel{} + for rows.Next() { + sac := &sqlAccountChannel{} + + err = sac.scan(rows) + if err != nil { + return nil, pkgErr("error scanning row", err) + } + + accountChannels = append(accountChannels, *sac.toAccountChannel()) + } + err = rows.Err() + if err != nil && err != sql.ErrNoRows { + return nil, pkgErr("error iterating over rows", err) + } + + return accountChannels, nil +} diff --git a/v1/internal/models/channel.go b/v1/internal/models/channel.go new file mode 100644 index 0000000..1e3fa3c --- /dev/null +++ b/v1/internal/models/channel.go @@ -0,0 +1,232 @@ +package models + +import ( + "database/sql" + "fmt" +) + +const ( + channelColumns = "id, account_id, cid, name, profile_image, api_key" + channelTable = "channel" +) + +type Channel struct { + ID *int64 `json:"id"` + AccountID *int64 `json:"account_id"` + CID *string `json:"cid"` + Name *string `json:"name"` + ProfileImage *string `json:"profile_image"` + ApiKey *string `json:"api_key"` +} + +func (c *Channel) values() []any { + return []any{c.ID, c.AccountID, c.CID, c.Name, c.ProfileImage, c.ApiKey} +} + +func (c *Channel) valuesNoID() []any { + return c.values()[1:] +} + +func (c *Channel) valuesEndID() []any { + vals := c.values() + return append(vals[1:], vals[0]) +} + +type sqlChannel struct { + id sql.NullInt64 + accountID sql.NullInt64 + cid sql.NullString + name sql.NullString + profileImage sql.NullString + apiKey sql.NullString +} + +func (sc *sqlChannel) scan(r Row) error { + return r.Scan(&sc.id, &sc.accountID, &sc.cid, &sc.name, &sc.profileImage, &sc.apiKey) +} + +func (sc sqlChannel) toChannel() *Channel { + var c Channel + c.ID = toInt64(sc.id) + c.AccountID = toInt64(sc.accountID) + c.CID = toString(sc.cid) + c.Name = toString(sc.name) + c.ProfileImage = toString(sc.profileImage) + c.ApiKey = toString(sc.apiKey) + + return &c +} + +type ChannelService interface { + AutoMigrate() error + ByName(name string) (*Channel, error) + Create(c *Channel) error + DestructiveReset() error +} + +func NewChannelService(db *sql.DB) ChannelService { + return &channelService{ + Database: db, + } +} + +var _ ChannelService = &channelService{} + +type channelService struct { + Database *sql.DB +} + +func (cs *channelService) AutoMigrate() error { + err := cs.createChannelTable() + if err != nil { + return pkgErr(fmt.Sprintf("error creating %s table", channelTable), err) + } + + return nil +} + +func (cs *channelService) createChannelTable() error { + createQ := fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS "%s" ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + cid TEXT UNIQUE NOT NULL, + name TEXT UNIQUE NOT NULL, + profile_image TEXT, + api_key TEXT NOT NULL, + FOREIGN KEY (account_id) REFERENCES "%s" (id) + ) + `, channelTable, accountTable) + + _, err := cs.Database.Exec(createQ) + if err != nil { + return fmt.Errorf("error executing create query: %v", err) + } + + return nil +} + +func (cs *channelService) ByName(name string) (*Channel, error) { + err := runChannelValFuncs( + &Channel{Name: &name}, + channelRequireName, + ) + if err != nil { + return nil, pkgErr("", err) + } + + selectQ := fmt.Sprintf(` + SELECT %s + FROM "%s" + WHERE name=? + `, channelColumns, channelTable) + + var sc sqlChannel + row := cs.Database.QueryRow(selectQ, name) + err = sc.scan(row) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, pkgErr("error executing select query", err) + } + + return sc.toChannel(), nil +} + +func (cs *channelService) Create(c *Channel) error { + err := runChannelValFuncs( + c, + channelRequireAccountID, + channelRequireApiKey, + channelRequireCID, + channelRequireName, + ) + if err != nil { + return pkgErr("invalid channel", err) + } + + columns := columnsNoID(channelColumns) + insertQ := fmt.Sprintf(` + INSERT INTO "%s" (%s) + VALUES (%s) + `, channelTable, columns, values(columns)) + + _, err = cs.Database.Exec(insertQ, c.valuesNoID()...) + if err != nil { + return pkgErr("error executing insert query", err) + } + + return nil +} + +func (cs *channelService) DestructiveReset() error { + err := cs.dropChannelTable() + if err != nil { + return pkgErr(fmt.Sprintf("error dropping %s table", channelTable), err) + } + + return nil +} + +func (cs *channelService) dropChannelTable() error { + dropQ := fmt.Sprintf(` + DROP TABLE IF EXISTS "%s" + `, channelTable) + + _, err := cs.Database.Exec(dropQ) + if err != nil { + return fmt.Errorf("error executing drop query: %v", err) + } + + return nil +} + +type channelValFunc func(*Channel) error + +func runChannelValFuncs(c *Channel, fns ...channelValFunc) error { + if c == nil { + return fmt.Errorf("channel cannot be nil") + } + + for _, fn := range fns { + err := fn(c) + if err != nil { + return err + } + } + + return nil +} + +func channelRequireAccountID(c *Channel) error { + if c.AccountID == nil || *c.AccountID <= 0 { + return ErrChannelInvalidAccountID + } + + return nil +} + +func channelRequireApiKey(c *Channel) error { + if c.ApiKey == nil || *c.ApiKey == "" { + return ErrChannelInvalidApiKey + } + + return nil +} + +func channelRequireCID(c *Channel) error { + if c.CID == nil || *c.CID == "" { + return ErrChannelInvalidCID + } + + return nil +} + +func channelRequireName(c *Channel) error { + if c.Name == nil || *c.Name == "" { + return ErrChannelInvalidName + } + + return nil +} diff --git a/v1/internal/models/error.go b/v1/internal/models/error.go index 36a3e59..f011ab8 100644 --- a/v1/internal/models/error.go +++ b/v1/internal/models/error.go @@ -7,6 +7,11 @@ const ( ErrAccountInvalidUsername ValidatorError = "invalid account username" ErrAccountInvalidID ValidatorError = "invalid account id" + + ErrChannelInvalidAccountID ValidatorError = "invalid channel account id" + ErrChannelInvalidApiKey ValidatorError = "invalid channel API key" + ErrChannelInvalidCID ValidatorError = "invalid channel CID" + ErrChannelInvalidName ValidatorError = "invalid channel name" ) func pkgErr(prefix string, err error) error { diff --git a/v1/internal/models/services.go b/v1/internal/models/services.go index 8a93cb3..cccde7e 100644 --- a/v1/internal/models/services.go +++ b/v1/internal/models/services.go @@ -7,23 +7,27 @@ import ( type migrationFunc func() error -type service struct { +type table struct { name string automigrate migrationFunc destructivereset migrationFunc } type Services struct { - AccountS AccountService - Database *sql.DB - services []service + AccountS AccountService + AccountChannelS AccountChannelService + ChannelS ChannelService + Database *sql.DB + tables []table } func (s *Services) AutoMigrate() error { - for _, service := range s.services { - err := service.automigrate() - if err != nil { - return pkgErr(fmt.Sprintf("error auto-migrating %s service", service.name), err) + for _, table := range s.tables { + if table.automigrate != nil { + err := table.automigrate() + if err != nil { + return pkgErr(fmt.Sprintf("error auto-migrating %s table", table.name), err) + } } } @@ -40,10 +44,12 @@ func (s *Services) Close() error { } func (s *Services) DestructiveReset() error { - for _, service := range s.services { - err := service.destructivereset() - if err != nil { - return pkgErr(fmt.Sprintf("error destructive-resetting %s service", service.name), err) + for _, table := range s.tables { + if table.destructivereset != nil { + err := table.destructivereset() + if err != nil { + return pkgErr(fmt.Sprintf("error destructive-resetting %s table", table.name), err) + } } } @@ -78,7 +84,24 @@ func WithDatabase(file string) ServicesInit { func WithAccountService() ServicesInit { return func(s *Services) error { s.AccountS = NewAccountService(s.Database) - s.services = append(s.services, service{accountTable, s.AccountS.AutoMigrate, s.AccountS.DestructiveReset}) + s.tables = append(s.tables, table{accountTable, s.AccountS.AutoMigrate, s.AccountS.DestructiveReset}) + + return nil + } +} + +func WithAccountChannelService() ServicesInit { + return func(s *Services) error { + s.AccountChannelS = NewAccountChannelService(s.Database) + + return nil + } +} + +func WithChannelService() ServicesInit { + return func(s *Services) error { + s.ChannelS = NewChannelService(s.Database) + s.tables = append(s.tables, table{channelTable, s.ChannelS.AutoMigrate, s.ChannelS.DestructiveReset}) return nil } diff --git a/v1/main.go b/v1/main.go index 52f3064..7bc52ba 100644 --- a/v1/main.go +++ b/v1/main.go @@ -2,7 +2,10 @@ package main import ( "embed" + "net/http" + "strings" + "github.com/tylertravisty/rum-goggles/v1/internal/config" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" @@ -21,7 +24,8 @@ func main() { Width: 1024, Height: 768, AssetServer: &assetserver.Options{ - Assets: assets, + Assets: assets, + Handler: http.HandlerFunc(GetImage), }, BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 255}, OnShutdown: app.shutdown, @@ -35,3 +39,12 @@ func main() { println("Error:", err.Error()) } } + +func GetImage(w http.ResponseWriter, r *http.Request) { + path := strings.Replace(r.RequestURI, "wails://wails", "", 1) + prefix, err := config.ImageDir() + if err != nil { + return + } + http.ServeFile(w, r, prefix+path) +}