First release

This commit is contained in:
itsuyomi 2023-01-15 04:29:59 -05:00
parent 829456339e
commit c87a897f7f
22 changed files with 4061 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

152
db-fake/db.json Normal file
View File

@ -0,0 +1,152 @@
{
"characters": [
{
"id": 1,
"name": "Gawr Gura",
"image": "https://characterai.io/i/400/static/avatars/oL2IzOD15_wBIP_o6NAWDwiVyAnzz_3aGLu9aU7i254/PvvL1eslO5go1byUXhH-gLRw_HCkFc7KpW_i6ojDPfQ.webp",
"creator": "stinkychum",
"description": "Shark-girl Idol of Hololive EN !"
},
{
"id": 2,
"name": "Rushia Uruha",
"image": "https://characterai.io/i/400/static/avatars/uploaded/2022/10/6/TwLdQ0F1qfRlxY4HBiOBG51onUZG2cuvtVAcPhd33lE.webp",
"creator": "maladroit",
"description": "your cute yandere girlfriend"
},
{
"id": 3,
"name": "Mori Calliope",
"image": "https://characterai.io/i/400/static/avatars/uploaded/qJ6Y0Awr0r0IDfP_T0UVdkjgZGhePCtp5gdbXN4jT8k.webp",
"creator": "trun25554",
"description": "I am Mori Calliope, English Virtual YouTuber, part of Hololive EN~"
},
{
"id": 4,
"name": "Amelia Watson",
"image": "https://characterai.io/i/400/static/avatars/uploaded/D24yxjMB0YTH0YBaPeBkaZcDwU8JSZjfBFDZWIILlKI.webp",
"creator": "trun25554",
"description": "Heeelloo, it's Amelia Watson, English Virtual YouTuber, part of Hololive EN 1st Generation~"
},
{
"id": 5,
"name": "Pipkin Pippa",
"image": "https://characterai.io/i/400/static/avatars/uploaded/2022/10/6/BtUEqYZYEweIhWhWDbISA_2TlSIL6S24XejGMEmp1eo.webp",
"creator": "mundane",
"description": "PIPIPIPI!!! I'm the cutest rabbit in the universe!"
},
{
"id": 6,
"name": "Hoshimachi Suisei",
"image": "https://characterai.io/i/400/static/avatars/uploaded/S564dpkpF-HFN9vu5iOs4LjkQGtIm4KJKSWfrkRJrzg.webp",
"creator": "MahoHiyajo",
"description": "I'm your idol VTuber Hoshimachi Suisei!"
},
{
"id": 7,
"name": "Shirakami Fubuki",
"image": "https://characterai.io/i/400/static/avatars/uploaded/2022/10/6/8wd3Cm4I5zVYjahtPopdIcItDD3f-EgB75MgFKOqhvg.webp",
"creator": "MahoHiyajo",
"description": "I am Shirakami Fubuki, virtual fox of Hololive!"
},
{
"id": 8,
"name": "Laplus Darkness",
"image": "https://characterai.io/i/400/static/avatars/uploaded/2022/10/7/rPeXKHHJ1LD6oKlcNaLv4gEYZd4pIABU3AS9EJgyeKg.webp",
"creator": "Farenna",
"description": "The Leader of HoloX from Hololive Gen 6!"
},
{
"id": 9,
"name": "Nanashi Mumei",
"image": "https://characterai.io/i/400/static/avatars/uploaded/2022/10/5/eo9KFPGEZb6vR3PsrXTW1bf4scYVY5xyZ3jKpwUC28I.webp",
"creator": "MahoHiyajo",
"description": "Oh hi! I am Nanashi Mumei from Hololive English!"
},
{
"id": 10,
"name": "Nyatasha Nyanners",
"image": "https://characterai.io/i/400/static/avatars/uploaded/z4_5IKIeFCvrTKxPxtgGJYTngjdbJP4mcwGBZZB4hMI.webp",
"creator": "makifoxgirl",
"description": "im a vtuber and singer"
},
{
"id": 11,
"name": "Takanashi Kiara",
"image": "https://characterai.io/i/400/static/avatars/uploaded/vSjzwmn-COUNjv7AF4CWKzOu3imxTRvrj5_mGbU4J68.webp",
"creator": "Sarve",
"description": "Vtuber, Idol, part-time warrior, am Phoenix!"
},
{
"id": 12,
"name": "Ninomae Inanis",
"image": "https://characterai.io/i/400/static/avatars/uploaded/2022/10/27/jxY2eF0UqiM0uAobpr5753cNjSinuPB0p8kUG2qUeSY.webp",
"creator": "AndyYuno",
"description": "Wah! I am Ninomae Ina'nis, a Virtual Youtuber from Hololive EN!"
},
{
"id": 13,
"name": "Usada Pekora",
"image": "https://characterai.io/i/400/static/avatars/uploaded/2022/10/5/on9CzLR9Cp78Wl_W3zsEe0ldMWfhq-QY6BZG-Zmriwg.webp",
"creator": "MahoHiyajo",
"description": "Konpeko! Konpeko! Konpeko! I'm Usada Pekora-peko!"
},
{
"id": 14,
"name": "Minato Aqua",
"image": "https://characterai.io/i/400/static/avatars/uploaded/2022/10/6/wC5j5y3sYr9Y8I7AE15VOFVmoT3Qrh_1WzVheyIXwLY.webp",
"creator": "Rairu",
"description": "Hololive idol gamer maid"
},
{
"id": 15,
"name": "Inugami Korone",
"image": "https://characterai.io/i/400/static/avatars/uploaded/2022/10/6/vZMxHfgpzkckaN9s8aiCSFeX09ZhRBnnS-vnq4uDeow.webp",
"creator": "MahoHiyajo",
"description": "I'm Inugami Korone from Hololive! Yubi! Yubi!"
},
{
"id": 16,
"name": "Murasaki Shion",
"image": "https://characterai.io/i/400/static/avatars/uploaded/2022/10/7/Ox2fpcJ1tn_Qydw_DmZwjzyAZXuW5FKnRU0fgxy2_Ho.webp",
"creator": "MahoHiyajo",
"description": "A young magician from Hololive, Murasaki Shion!"
}
],
"chats": [
{
"id": 1,
"messages": [
{
"text": "This is an example message from Gura. It's the first message sent.",
"sender": "CHARACTER"
},
{
"text": "This is a message you sent back to Gura.",
"sender": "YOU"
},
{
"text": "This is Gura's response to your message. Hello!",
"sender": "CHARACTER"
}
]
},
{
"id": 2,
"messages": [
{
"text": "Here's another example message, this time from Rushia.",
"sender": "CHARACTER"
},
{
"text": "This is your response.",
"sender": "YOU"
},
{
"text": "This is Rushia's 2nd message.",
"sender": "CHARACTER"
}
]
}
]
}

24
index.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pygmalion Web Frontend</title>
</head>
<body>
<script>
function useColor(light="white", dark="#1A202C"){
// Fixes the light theme flashing issue when loading a page. Assumes that the theme starts in dark mode.
const colorMode = window.localStorage.getItem("chakra-ui-color-mode");
if(colorMode === null){
return dark;
}
return colorMode === "dark" ? dark : light;
}
document.body.style.background = useColor();
</script>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3265
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "pygmalion-web-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/icons": "^2.0.14",
"@chakra-ui/react": "^2.4.4",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"framer-motion": "^6.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.6.1"
},
"devDependencies": {
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^3.0.0",
"vite": "^4.0.0"
}
}

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

45
src/api/characters.js Normal file
View File

@ -0,0 +1,45 @@
const SERVER_URL = "http://localhost:3000"; // Based on json-server's defaults
export async function getCharacters(query){
const url = SERVER_URL + "/characters";
try {
const response = await fetch(url);
if(!response.ok){
console.log(response.status, response.statusText);
return {};
}
else{
const json = await response.json();
return json;
}
} catch (error) {
console.log(error);
return {};
}
}
export async function getCharacter(charId){
const url = SERVER_URL + "/characters/" + charId;
const response = await fetch(url);
if(!response.ok){
console.log(response.status, response.statusText);
return {};
}
else{
const json = await response.json();
return json;
}
}
export async function getChats(charId){
const url = SERVER_URL + "/chats/" + charId;
const response = await fetch(url);
if(!response.ok){
console.log(response.status, response.statusText);
return {};
}
else{
const json = await response.json();
return json;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,30 @@
import { Card, CardBody, Image, Stack, Heading, Text, AspectRatio, LinkBox, LinkOverlay } from "@chakra-ui/react";
const CharacterCard = (props) => {
return (
<Card maxWidth="sm" h="100%">
<CardBody p="0">
<LinkBox>
<LinkOverlay href={"/chat/" + props.charId}>
<AspectRatio maxWidth="100%" ratio={1}>
<Image
src={props.image}
alt={props.alt}
borderRadius="8px 8px 0 0"
width="100%"
height="100%"
/>
</AspectRatio>
</LinkOverlay>
</LinkBox>
<Stack mt="6" spacing="3" m="0.5rem" maxHeight="9rem">
<Heading size="md" className="line-clamp" overflow="hidden" textOverflow="ellipsis">{props.name}</Heading>
<Text fontStyle="italic" fontSize="0.75rem">@{props.creator}</Text>
<Text fontSize="0.75rem" overflow="hidden" textOverflow="ellipsis" className="line-clamp-2">{props.description}</Text>
</Stack>
</CardBody>
</Card>
);
};
export default CharacterCard;

138
src/components/NavBar.jsx Normal file
View File

@ -0,0 +1,138 @@
import React, { useState } from "react";
import {
Link, Box, Flex, Button, Image, Stack, IconButton,
useColorMode, useColorModeValue
} from "@chakra-ui/react";
import { SunIcon, MoonIcon, HamburgerIcon, CloseIcon, SearchIcon, AddIcon } from "@chakra-ui/icons";
import logo from "../assets/wAIfu_20flag_20square.png";
// Navbar component code based on https://codesandbox.io/s/b22b7
const NavBar = () => {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
const logoBorderColor = useColorModeValue("var(--flag-color-dark-blue)", "var(--chakra-colors-whiteAlpha-800)");
return (
<NavBarContainer boxShadow="base" p="1rem">
<Link href="/" boxSize="min(48px, 100%)">
<Image
borderRadius="full"
height="100%"
border="2px"
borderColor={logoBorderColor}
src={logo}
alt="wAIfu flag logo"
/>
</Link>
<Stack direction="row">
<Box display={{ base: "block", md: "none"}}>
<ColorThemeSwitch />
</Box>
<NavToggle toggle={toggle} isOpen={isOpen} />
</Stack>
<NavMenu isOpen={isOpen} />
</NavBarContainer>
);
};
const NavToggle = ({ toggle, isOpen }) => {
return (
<IconButton
display={{ base: "block", md: "none" }}
height="auto"
colorScheme="gray"
color="gray.400"
bg="#ffffff00"
size="lg"
aria-label="Navigation menu toggle"
icon={isOpen ? <CloseIcon height="100%" /> : <HamburgerIcon height="100%" />}
onClick={toggle}>
</IconButton>
);
};
const NavItem = ({ children, to = "/", ...rest}) => {
return (
<Link href={to} {...rest}>
{children}
</Link>
);
};
const NavMenu = ({ isOpen }) => {
return (
<Box
display={{ base: isOpen ? "block" : "none", md: "block" }}
flexBasis={{ base: "100%", md: "auto" }}
height="min(48px, 100%)"
>
<Stack
spacing={8}
align="center"
justify={["center", "center", "flex-end", "flex-end"]}
direction={["column", "row", "row", "row"]}
pt={[4, 4, 0, 0]}
height="100%"
>
<NavItem to="/search">
<Button as={SearchIcon} bg="#ffffff00" boxSize="3.12rem" />
</NavItem>
<NavItem to="/create">
<Button as={AddIcon} bg="#ffffff00" boxSize="3.12rem" />
</NavItem>
<NavItem to="/about">About</NavItem>
<Box display={{ base: "none", md: "block"}}>
<ColorThemeSwitch />
</Box>
<NavItem to="/signup">
<Button size="sm" rounded="md">
Sign Up
</Button>
</NavItem>
</Stack>
</Box>
);
};
const NavBarContainer = ({ children, ...props }) => {
return (
<Flex
as="nav"
align="center"
justify="space-between"
wrap="wrap"
width="100%"
mb={8}
p={8}
{...props}
>
{children}
</Flex>
);
};
const ColorThemeSwitch = ({ ...props }) => {
const { colorMode, toggleColorMode } = useColorMode();
return (
<IconButton
colorScheme="gray"
color="gray.400"
bg="#ffffff00"
size="lg"
aria-label="Switch color themes"
icon={colorMode === "dark" ? <SunIcon /> : <MoonIcon />}
onClick={() => {
document.body.style.background = "";
toggleColorMode();
}}
{...props}>
</IconButton>
);
}
export default NavBar;

View File

@ -0,0 +1,30 @@
import React from "react";
import { Flex, Input, Button } from "@chakra-ui/react";
const MessageInput = ({ inputMessage, setInputMessage, handleSendMessage }) => {
return (
<Flex w="100%" mt="5" mb="5">
<Input
mr="2"
placeholder="Type a message"
border="2px solid"
borderRadius="24px"
onKeyPress={(e) => {
if (e.key === "Enter") {
handleSendMessage();
}
}}
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
/>
<Button
disabled={inputMessage.trim().length <= 0}
onClick={handleSendMessage}
>
Send
</Button>
</Flex>
);
};
export default MessageInput;

View File

@ -0,0 +1,39 @@
import React, { useEffect, useRef } from "react";
import { Avatar, Flex, Text } from "@chakra-ui/react";
const Messages = ({ messages, character }) => {
const AlwaysScrollToBottom = () => {
const elementRef = useRef();
useEffect(() => elementRef.current.scrollIntoView());
return <div ref={elementRef} />;
};
return ( // To make the messages start at the bottom, use flex-direction, not flex-end
<Flex width="100%" height="100%" overflowY="scroll" flexDirection="column-reverse" p="3">
<AlwaysScrollToBottom />
{messages.map((item, index) => {
const senderName = item.sender === "CHARACTER" ? character.name : "Anon";
const senderImage = item.sender === "CHARACTER" ? character.image : "";
return (
<Flex key={index} width="100%" mb="4" mt="4">
<Avatar
name={senderName}
src={senderImage}
/>
<Flex
minWidth="100px"
maxWidth="100%"
ml="3"
flexDirection="column"
>
<Text fontWeight="bold">{senderName}</Text>
<Text>{item.text}</Text>
</Flex>
</Flex>
);
})}
</Flex>
);
};
export default Messages;

22
src/error-page.jsx Normal file
View File

@ -0,0 +1,22 @@
import { useRouteError } from "react-router-dom";
import {
Heading,
Text,
Link,
VStack
} from "@chakra-ui/react";
const ErrorPage = () => {
const error = useRouteError();
console.error(error);
return (
<VStack id="error-page" spacing="24px" mt="4rem">
<Heading as="h1" size="4xl">{error.status} - {error.statusText || error.message}</Heading>
<Text fontSize="2xl">Sorry, something went wrong.</Text>
<Link href="/">Go Back</Link>
</VStack>
);
};
export default ErrorPage;

17
src/index.css Normal file
View File

@ -0,0 +1,17 @@
:root {
--flag-color-dark-blue: #002052;
--flag-color-dark-magenta: #8F008E;
--flag-color-mid-blue: #005282;
}
.line-clamp {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}

63
src/main.jsx Normal file
View File

@ -0,0 +1,63 @@
import React from "react";
import ReactDOM from "react-dom/client";
import {
createBrowserRouter,
RouterProvider,
} from "react-router-dom";
import {
ChakraProvider,
} from "@chakra-ui/react";
import "./index.css";
import theme from "./themes/defaultTheme";
import Root, { loader as rootLoader } from "./routes/root";
import ErrorPage from "./error-page";
import About from "./routes/about";
import Search from "./routes/search";
import Create from "./routes/create";
import Chat, {
loader as characterLoader,
} from "./routes/chat";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader
},
{
path: "/search",
element: <Search />,
errorElement: <ErrorPage />
},
{
path: "/about",
element: <About />,
errorElement: <ErrorPage />
},
{
path: "/create",
element: <Create />,
errorElement: <ErrorPage />
},
{
path: "/chat/:char",
element: <Chat />,
errorElement: <ErrorPage />,
loader: characterLoader,
}
]);
const rootElement = document.getElementById("root");
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<ChakraProvider theme={theme}>
<RouterProvider router={router} />
</ChakraProvider>
</React.StrictMode>
);

12
src/routes/about.jsx Normal file
View File

@ -0,0 +1,12 @@
import NavBar from "../components/NavBar";
const About = () => {
return (
<>
<NavBar />
<p>TODO About page</p>
</>
);
};
export default About;

79
src/routes/chat.jsx Normal file
View File

@ -0,0 +1,79 @@
import { useState, useEffect } from "react";
import { useLoaderData } from "react-router-dom";
import { getCharacter, getChats } from "../api/characters";
import { Flex, Divider, Text, Box } from "@chakra-ui/react";
import NavBar from "../components/NavBar";
import Messages from "../components/chat/Messages";
import MessageInput from "../components/chat/MessageInput";
export async function loader({ params }){
return getCharacter(params.char);
}
// Followed https://ordinarycoders.com/blog/article/react-chakra-ui for most of this page.
const Chat = () => {
const character = useLoaderData();
const [messages, setMessages] = useState([]);
const [inputMessage, setInputMessage] = useState("");
const handleSendMessage = () => {
if (!inputMessage.trim().length) {
return;
}
const data = inputMessage;
setMessages((old) => [{ sender: "YOU", text: data }, ...old]);
setInputMessage("");
setMessages((old) => [{ sender: "CHARACTER", text: "This is a placeholder response to your message.", }, ...old]);
};
useEffect(() => {
let mounted = true;
getChats(character.id)
.then(
(response) => {
if(mounted){
if(response.messages){
let messagesList = response.messages.reverse(); // Reversed because the messages section uses flex-direction="column-reverse"
setMessages(messagesList);
}
else{
// If no messages found, default to the description.
setMessages([{ sender: "CHARACTER", text: character.description }]);
}
}
}
)
.catch((error) => {
console.log(error);
setMessages([{ sender: "", text: "An error occurred when fetching messages."}]); // TODO this isn't the best way to show an error.
});
return () => mounted = false;
}, []);
return (
<Box
height="100vh"
>
<NavBar />
{messages ? (
<Flex flexDir="column" height="100%" maxHeight="86vh">
<Flex width="100%" maxHeight="100%" justify="center" align="center" flexGrow="1">
<Flex width="60%" height="100%" maxHeight="100%" flexDir="column">
<Divider />
<Messages messages={messages} character={character} />
<Divider />
<MessageInput inputMessage={inputMessage} setInputMessage={setInputMessage} handleSendMessage={handleSendMessage} />
</Flex>
</Flex>
</Flex>
) : (
<Text ml="4">An error occured when trying to fetch messages.</Text>
)}
</Box>
);
}
export default Chat;

12
src/routes/create.jsx Normal file
View File

@ -0,0 +1,12 @@
import NavBar from "../components/NavBar";
const Create = () => {
return (
<>
<NavBar />
<p>TODO create character page</p>
</>
);
};
export default Create;

53
src/routes/root.jsx Normal file
View File

@ -0,0 +1,53 @@
import {
useLoaderData
} from "react-router-dom";
import { Grid, GridItem, Heading } from "@chakra-ui/react";
import NavBar from "../components/NavBar";
import CharacterCard from "../components/CharacterCard";
import { getCharacters } from "../api/characters";
export async function loader(){
const characters = await getCharacters();
return { characters };
}
const Root = () => {
const { characters } = useLoaderData();
return (
<>
<NavBar />
<Grid
templateColumns={["repeat(1, 1fr)", "repeat(2, 1fr)", "repeat(4, 1fr)", "repeat(6, 1fr)", "repeat(8, 1fr)"]}
ml="1.5rem"
mr="1.5rem"
gridGap="1rem"
>
<GridItem colStart={1} colEnd={-1}>
<Heading size="md" noOfLines={1}>Characters</Heading>
</GridItem>
{characters.length ? (
characters.map((character) => (
<GridItem key={character.id}>
<CharacterCard
charId={character.id}
image={character.image}
alt={"Image of " + character.name}
name={character.name}
creator={character.creator}
description={character.description}
/>
</GridItem>
))
) : (
<GridItem>
<p>No characters found!</p>
</GridItem>
)}
</Grid>
</>
);
};
export default Root;

12
src/routes/search.jsx Normal file
View File

@ -0,0 +1,12 @@
import NavBar from "../components/NavBar";
const Search = () => {
return (
<>
<NavBar />
<p>TODO search page</p>
</>
);
};
export default Search;

View File

@ -0,0 +1,10 @@
import { extendTheme } from "@chakra-ui/react";
const config = {
initialColorMode: "dark",
useSystemColorMode: false
};
const theme = extendTheme({ config });
export default theme;

7
vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})