Customize profile colors (#603)

* Variables rearranged

* Make patron.json empty again

* Fix styles.css

* Working prototype

* Table legible color

* UI furthened

* Set opaque theme color with custom colors

* Control Color Selector with buttons

* Show info if can't edit colors

* borzoic can also edit colors

* Add migration

* Can send colors to backend

* Edit existing colors

* Use new layering strat for footer

* useMutation custom hook

* Footer adjusted text color

* Reset style after profile visit

* Set squid color after page load

* Mutate user after color selection
This commit is contained in:
Kalle 2021-07-29 20:30:47 +03:00 committed by GitHub
parent a9490360c0
commit af3a654595
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 832 additions and 508 deletions

View File

@ -48,9 +48,9 @@ export function TableHeader(props: BoxProps) {
px="4"
py="3"
backgroundColor={CSSVariables.themeColor}
color={CSSVariables.secondaryBgColor}
textAlign="left"
fontSize="xs"
textColor="black"
textTransform="uppercase"
letterSpacing="wider"
lineHeight="1rem"

View File

@ -1,352 +1,7 @@
import { Image as ChakraImage, useColorMode } from "@chakra-ui/react";
import randomColor from "randomcolor";
import { useEffect, useState } from "react";
interface HSL {
h: number;
s: number;
l: number;
}
class Color {
public r: number;
public g: number;
public b: number;
constructor(r: number, g: number, b: number) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
toString() {
return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(
this.b
)})`;
}
set(r: number, g: number, b: number) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
hueRotate(angle = 0) {
angle = (angle / 180) * Math.PI;
const sin = Math.sin(angle);
const cos = Math.cos(angle);
this.multiply([
0.213 + cos * 0.787 - sin * 0.213,
0.715 - cos * 0.715 - sin * 0.715,
0.072 - cos * 0.072 + sin * 0.928,
0.213 - cos * 0.213 + sin * 0.143,
0.715 + cos * 0.285 + sin * 0.14,
0.072 - cos * 0.072 - sin * 0.283,
0.213 - cos * 0.213 - sin * 0.787,
0.715 - cos * 0.715 + sin * 0.715,
0.072 + cos * 0.928 + sin * 0.072,
]);
}
grayscale(value = 1) {
this.multiply([
0.2126 + 0.7874 * (1 - value),
0.7152 - 0.7152 * (1 - value),
0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value),
0.7152 + 0.2848 * (1 - value),
0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value),
0.7152 - 0.7152 * (1 - value),
0.0722 + 0.9278 * (1 - value),
]);
}
sepia(value = 1) {
this.multiply([
0.393 + 0.607 * (1 - value),
0.769 - 0.769 * (1 - value),
0.189 - 0.189 * (1 - value),
0.349 - 0.349 * (1 - value),
0.686 + 0.314 * (1 - value),
0.168 - 0.168 * (1 - value),
0.272 - 0.272 * (1 - value),
0.534 - 0.534 * (1 - value),
0.131 + 0.869 * (1 - value),
]);
}
saturate(value = 1) {
this.multiply([
0.213 + 0.787 * value,
0.715 - 0.715 * value,
0.072 - 0.072 * value,
0.213 - 0.213 * value,
0.715 + 0.285 * value,
0.072 - 0.072 * value,
0.213 - 0.213 * value,
0.715 - 0.715 * value,
0.072 + 0.928 * value,
]);
}
multiply(matrix: any) {
const newR = this.clamp(
this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]
);
const newG = this.clamp(
this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]
);
const newB = this.clamp(
this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]
);
this.r = newR;
this.g = newG;
this.b = newB;
}
brightness(value = 1) {
this.linear(value);
}
contrast(value = 1) {
this.linear(value, -(0.5 * value) + 0.5);
}
linear(slope = 1, intercept = 0) {
this.r = this.clamp(this.r * slope + intercept * 255);
this.g = this.clamp(this.g * slope + intercept * 255);
this.b = this.clamp(this.b * slope + intercept * 255);
}
invert(value = 1) {
this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
}
hsl(): HSL {
// Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
const r = this.r / 255;
const g = this.g / 255;
const b = this.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
let l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return {
h: h * 100,
s: s * 100,
l: l * 100,
};
}
clamp(value: number): number {
if (value > 255) {
value = 255;
} else if (value < 0) {
value = 0;
}
return value;
}
}
interface Solution {
loss: number;
values: number[];
}
class Solver {
private target: Color;
private targetHSL: HSL;
private reusedColor: Color;
constructor(target: Color) {
this.target = target;
this.targetHSL = target.hsl();
this.reusedColor = new Color(0, 0, 0);
}
solve() {
const result = this.solveNarrow(this.solveWide());
return {
values: result.values,
loss: result.loss,
filter: this.css(result.values),
};
}
solveWide(): Solution {
const A = 5;
const c = 15;
const a = [60, 180, 18000, 600, 1.2, 1.2];
let best = { loss: Infinity, values: [] as number[] };
for (let i = 0; best.loss > 25 && i < 3; i++) {
const initial = [50, 20, 3750, 50, 100, 100];
const result = this.spsa(A, a, c, initial, 1000);
if (result.loss < best.loss) {
best = result;
}
}
return best;
}
solveNarrow(wide: Solution) {
const A = wide.loss;
const c = 2;
const A1 = A + 1;
const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
return this.spsa(A, a, c, wide.values, 500);
}
spsa(
A: number,
a: number[],
c: number,
values: number[],
iters: number
): Solution {
const alpha = 1;
const gamma = 0.16666666666666666;
let best = [] as number[];
let bestLoss = Infinity;
const deltas = new Array(6);
const highArgs = new Array(6);
const lowArgs = new Array(6);
for (let k = 0; k < iters; k++) {
const ck = c / Math.pow(k + 1, gamma);
for (let i = 0; i < 6; i++) {
deltas[i] = Math.random() > 0.5 ? 1 : -1;
highArgs[i] = values[i] + ck * deltas[i];
lowArgs[i] = values[i] - ck * deltas[i];
}
const lossDiff = this.loss(highArgs) - this.loss(lowArgs);
for (let i = 0; i < 6; i++) {
const g = (lossDiff / (2 * ck)) * deltas[i];
const ak = a[i] / Math.pow(A + k + 1, alpha);
values[i] = fix(values[i] - ak * g, i);
}
const loss = this.loss(values);
if (loss < bestLoss) {
best = values.slice(0);
bestLoss = loss;
}
}
return { values: best, loss: bestLoss };
function fix(value: number, idx: number): number {
let max = 100;
if (idx === 2 /* saturate */) {
max = 7500;
} else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) {
max = 200;
}
if (idx === 3 /* hue-rotate */) {
if (value > max) {
value %= max;
} else if (value < 0) {
value = max + (value % max);
}
} else if (value < 0) {
value = 0;
} else if (value > max) {
value = max;
}
return value;
}
}
loss(filters: number[]) {
// Argument is array of percentages.
const color = this.reusedColor;
color.set(0, 0, 0);
color.invert(filters[0] / 100);
color.sepia(filters[1] / 100);
color.saturate(filters[2] / 100);
color.hueRotate(filters[3] * 3.6);
color.brightness(filters[4] / 100);
color.contrast(filters[5] / 100);
const colorHSL = color.hsl();
return (
Math.abs(color.r - this.target.r) +
Math.abs(color.g - this.target.g) +
Math.abs(color.b - this.target.b) +
Math.abs(colorHSL.h - this.targetHSL.h) +
Math.abs(colorHSL.s - this.targetHSL.s) +
Math.abs(colorHSL.l - this.targetHSL.l)
);
}
css(filters: number[]) {
function fmt(idx: number, multiplier = 1) {
return Math.round(filters[idx] * multiplier);
}
return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(
2
)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(
5
)}%);`;
}
}
type RGB = [number, number, number];
function hexToRgb(hex: string): RGB {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b;
});
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
return [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16),
];
}
throw new Error("Error parsing hex: " + hex);
}
function getFilters(hex: string) {
let rgb = [255, 255, 255];
try {
rgb = hexToRgb(hex);
} catch (e) {}
const color = new Color(rgb[0], rgb[1], rgb[2]);
const solver = new Solver(color);
const result = solver.solve();
return result.filter;
}
import { getFilters } from "utils/getFilters";
const BeautifulDrawingOfBorzoic = ({ type }: { type: "girl" | "boy" }) => {
const [hexCode, setHexCode] = useState(randomColor());

View File

@ -13,8 +13,8 @@ const ColorModeSwitcher = ({ isMobile }: { isMobile?: boolean }) => {
icon={colorMode === "light" ? <FiSun /> : <FiMoon />}
_hover={
colorMode === "dark"
? { bg: "white", color: "black" }
: { bg: "black", color: "white" }
? { bg: "white !important", color: "black" }
: { bg: "black !important", color: "white" }
}
borderRadius={isMobile ? "50%" : "0"}
size={isMobile ? "lg" : "sm"}

View File

@ -1,21 +1,30 @@
import { Box, Image, useColorModeValue } from "@chakra-ui/react";
import { CSSVariables } from "utils/CSSVariables";
import { Box, Image } from "@chakra-ui/react";
import { useRouter } from "next/dist/client/router";
import { useLayoutEffect } from "react";
import { useState } from "react";
import { getFilters } from "utils/getFilters";
import FooterContent from "./FooterContent";
import FooterWaves from "./FooterWaves";
const Footer: React.FC = () => {
const species = useRouter().asPath.charCodeAt(1) % 2 === 0 ? "squid" : "octo";
const footerImageSrc = useColorModeValue(
{ octo: "b8ing_light", squid: "boing_light" },
{ octo: "b8ing_dark", squid: "boing_dark" }
)[species];
const [bgColor, setBgColor] = useState(() =>
getComputedStyle(document.body).getPropertyValue("--theme-color").trim()
);
const imgBase =
useRouter().asPath.charCodeAt(1) % 2 === 0 ? "boing" : "b8ing";
useLayoutEffect(() => {
const customColor = getComputedStyle(document.body)
.getPropertyValue("--custom-theme-color")
.trim();
if (customColor) setBgColor(customColor);
}, []);
return (
<Box as="footer" mt="auto">
<Box as="footer" mt="auto" display="grid" gridTemplateColumns="1fr">
<Image
src={`/layout/${footerImageSrc}.png`}
bg={CSSVariables.themeColor}
src={`/layout/${imgBase}_bg.png`}
w="80px"
ml="auto"
mr="35%"
@ -24,6 +33,24 @@ const Footer: React.FC = () => {
userSelect="none"
loading="lazy"
alt=""
filter={getFilters(bgColor)}
gridRow="1"
gridColumn="1 / 2"
zIndex="1"
/>
<Image
src={`/layout/${imgBase}.png`}
w="80px"
ml="auto"
mr="35%"
mb="-5.1%"
mt="5rem"
userSelect="none"
loading="lazy"
alt=""
gridRow="1"
gridColumn="1 / 2"
zIndex="10"
/>
<FooterWaves />
<FooterContent />

View File

@ -11,7 +11,7 @@ import { getFullUsername } from "utils/strings";
const FooterContent: React.FC = () => {
return (
<Box bg={CSSVariables.themeColor} color="black">
<Box bg={CSSVariables.themeColor} color={CSSVariables.bgColor}>
<Flex
flexDir={["column", null, "row"]}
alignItems="center"
@ -116,7 +116,7 @@ function ExternalLink({
alignItems: "center",
mx: 2,
my: 2,
color: "black",
color: CSSVariables.bgColor,
}}
>
<Box as={icon} display="inline" mr={1} size="20px" /> {children}

View File

@ -44,8 +44,8 @@ const Header = ({ openNav }: { openNav: () => void }) => {
icon={<FiMenu />}
_hover={
colorMode === "dark"
? { bg: "white", color: "black" }
: { bg: "black", color: "white" }
? { bg: "white !important", color: "black" }
: { bg: "black !important", color: "white" }
}
borderRadius="0"
display={["flex", null, null, "none"]}
@ -80,8 +80,8 @@ const Header = ({ openNav }: { openNav: () => void }) => {
leftIcon={<FiHeart />}
_hover={
colorMode === "dark"
? { bg: "white", color: "black" }
: { bg: "black", color: "white" }
? { bg: "white !important", color: "black" }
: { bg: "black !important", color: "white" }
}
borderRadius="0"
size="xs"
@ -103,8 +103,8 @@ const Header = ({ openNav }: { openNav: () => void }) => {
leftIcon={user ? <FiLogOut /> : <FiLogIn />}
_hover={
colorMode === "dark"
? { bg: "white", color: "black" }
: { bg: "black", color: "white" }
? { bg: "white !important", color: "black" }
: { bg: "black !important", color: "white" }
}
borderRadius="0"
size="xs"

View File

@ -46,8 +46,8 @@ export const LanguageSwitcher = ({ isMobile }: { isMobile?: boolean }) => {
icon={<FiGlobe />}
_hover={
colorMode === "dark"
? { bg: "white", color: "black" }
: { bg: "black", color: "white" }
? { bg: "white !important", color: "black" }
: { bg: "black !important", color: "white" }
}
borderRadius={isMobile ? "50%" : "0"}
size={isMobile ? "lg" : "sm"}

View File

@ -0,0 +1,159 @@
import { Box, Button, Flex } from "@chakra-ui/react";
import { useDebounce, useMutation } from "hooks/common";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { CSSVariables } from "utils/CSSVariables";
const themeValues: { name: string; displayName: string }[] = [
{
name: "theme-color",
displayName: "Theme",
},
{
name: "theme-gray",
displayName: "Theme Secondary",
},
{
name: "bg-color",
displayName: "Background",
},
{
name: "secondary-bg-color",
displayName: "Secondary Background",
},
{
name: "text-color",
displayName: "Text",
},
];
const ProfileColorSelectors = ({
hide,
mutateUser,
previousColors,
}: {
hide: () => void;
mutateUser: () => void;
previousColors?: Record<string, string>;
}) => {
const [currentColors, setCurrentColors] = useState<
Record<string, string | undefined>
>(previousColors ?? {});
const debouncedCurrentColors = useDebounce(currentColors);
const { mutate, isMutating } = useMutation({
data: { colors: currentColors },
successToastMsg: "Colors updated",
url: "/api/me/colors",
afterSuccess: () => {
hide();
mutateUser();
},
});
const defaultColors = useMemo(() => {
const bodyStyles = getComputedStyle(
document.getElementsByTagName("body")[0]
);
const result: Record<string, string> = {};
for (const themeValue of themeValues) {
let value = previousColors?.[themeValue.name];
if (!value) {
value = bodyStyles.getPropertyValue(`--${themeValue.name}`).trim();
}
result[themeValue.name] = value;
}
return result;
}, []);
useEffect(() => {
const body = document.getElementsByTagName("body")[0];
for (const [key, value] of Object.entries(debouncedCurrentColors)) {
body.style.setProperty(`--custom-${key}`, value ?? "");
if (key === "theme-color") {
body.style.setProperty(`--custom-${key}-opaque`, `${value}${20}` ?? "");
}
}
}, [debouncedCurrentColors]);
const onSubmitHandler = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
mutate();
};
return (
<form onSubmit={onSubmitHandler}>
<Flex flexWrap="wrap" justify="space-evenly" mt={4}>
{themeValues.map((value) => {
return (
<Flex key={value.name} flexDir="column" align="center" mb={4}>
<Box
as="label"
fontSize="sm"
mb={2}
fontWeight="bold"
color={CSSVariables.themeGray}
display="block"
htmlFor={`${value.name}-input`}
>
{value.displayName}
</Box>
<input
id={`${value.name}-input`}
value={
debouncedCurrentColors[value.name] ??
defaultColors[value.name]
}
type="color"
onChange={(e) =>
setCurrentColors((c) => ({
...c,
[value.name]: e.target.value,
}))
}
/>
<Button
onClick={() =>
setCurrentColors((c) => ({
...c,
[value.name]: undefined,
}))
}
size="xs"
colorScheme="red"
mt={3}
>
Reset
</Button>
</Flex>
);
})}
</Flex>
<Flex justify="center">
<Button
type="submit"
size="sm"
variant="outline"
isLoading={isMutating}
mr={2}
>
Save
</Button>
<Button
size="sm"
variant="outline"
ml={2}
colorScheme="red"
onClick={hide}
>
Cancel
</Button>
</Flex>
</form>
);
};
export default ProfileColorSelectors;

View File

@ -0,0 +1,90 @@
import {
Button,
HStack,
IconButton,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import MyLink from "components/common/MyLink";
import { FiEdit, FiInfo } from "react-icons/fi";
import { IoColorPalette } from "react-icons/io5";
import { RiTShirtLine } from "react-icons/ri";
import { CSSVariables } from "utils/CSSVariables";
const ProfileOwnersButtons = ({
canPostBuilds,
isColorEditorsButtonClickable,
setShowProfileModal,
setBuildToEdit,
setShowColorSelectors,
}: {
canPostBuilds: boolean;
isColorEditorsButtonClickable: boolean;
setShowProfileModal: (isOpen: boolean) => void;
setBuildToEdit: (isOpen: boolean) => void;
setShowColorSelectors: (isOpen: boolean) => void;
}) => {
return (
<HStack spacing={4}>
<Button
leftIcon={<FiEdit />}
variant="outline"
onClick={() => setShowProfileModal(true)}
size="sm"
>
<Trans>Edit profile</Trans>
</Button>
{canPostBuilds && (
<Button
leftIcon={<RiTShirtLine />}
variant="outline"
onClick={() => setBuildToEdit(true)}
size="sm"
>
<Trans>Add build</Trans>
</Button>
)}
<Button
leftIcon={<IoColorPalette />}
variant="outline"
disabled={!isColorEditorsButtonClickable}
onClick={() => setShowColorSelectors(true)}
size="sm"
>
Edit profile colors
</Button>
{!isColorEditorsButtonClickable ? (
<Popover placement="top" trigger="hover">
<PopoverTrigger>
<IconButton
variant="ghost"
isRound
aria-label="Show description"
fontSize="20px"
icon={<FiInfo />}
marginLeft="5px !important"
/>
</PopoverTrigger>
<PopoverContent
zIndex={4}
width="220px"
backgroundColor={CSSVariables.secondaryBgColor}
>
<PopoverBody whiteSpace="pre-wrap">
Editing profile colors is available for{" "}
<MyLink href="https://www.patreon.com/sendou" isExternal>
patrons
</MyLink>{" "}
of tier "Supporter" ($5 dollar tier)
</PopoverBody>
</PopoverContent>
</Popover>
) : null}
</HStack>
);
};
export default ProfileOwnersButtons;

View File

@ -1,11 +1,13 @@
import { useToast } from "@chakra-ui/react";
import { User as PrismaUser } from "@prisma/client";
import { useSession } from "next-auth/client";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { navItems } from "utils/constants";
import { getToastOptions } from "utils/objects";
// https://usehooks.com/useDebounce/
export function useDebounce(value: string, delay: number = 500) {
export function useDebounce<T>(value: T, delay: number = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
@ -38,3 +40,55 @@ export const useActiveNavItem = () => {
return navItem;
};
export const useMutation = ({
url,
method = "POST",
data,
successToastMsg,
afterSuccess,
}: {
url: string;
method?: "POST" | "DELETE" | "PUT";
data: Object;
successToastMsg: string;
afterSuccess?: () => void;
}) => {
const toast = useToast();
const [isMutating, setIsMutating] = useState(false);
const mutate = async () => {
setIsMutating(true);
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
setIsMutating(false);
if (response.status < 200 || response.status > 299) {
let description = "An error occurred";
try {
const error = await response.json();
if (error.message) {
description = error.message;
console.error(error.message);
}
} catch {}
toast({
duration: null,
isClosable: true,
position: "top-right",
status: "error",
description,
});
} else {
toast(getToastOptions(successToastMsg, "success"));
afterSuccess?.();
}
};
return { mutate, isMutating };
};

View File

@ -8,8 +8,9 @@
"build": "lingui compile && npm run prebuild && next build",
"build:analyze": "lingui compile && npm run prebuild && ANALYZE=true next build",
"start": "next start",
"migrate": "prisma migrate deploy --preview-feature",
"migrate:save": "prisma migrate dev --create-only --preview-feature",
"compile": "lingui compile",
"migrate": "npx prisma migrate deploy",
"migrate:save": "npx prisma migrate dev --create-only",
"migrate:reset": "prisma migrate reset",
"gen": "npx prisma generate",
"prebuild": "ts-node prisma/scripts/preBuild.ts",

View File

@ -113,16 +113,16 @@ const extendedTheme = extendTheme({
},
colors: {
theme: {
50: "#e4ffdf",
100: "#bbffb0",
200: "#92ff7f",
300: "#68ff4d",
400: "#3fff1d",
500: "#27e606",
600: "#1bb300",
700: "#108000",
800: "#054d00",
900: "#001b00",
50: CSSVariables.themeColorOpaque,
100: CSSVariables.themeColor,
200: CSSVariables.themeColor,
300: CSSVariables.themeColor,
400: CSSVariables.themeColor,
500: CSSVariables.themeColor,
600: CSSVariables.themeColor,
700: CSSVariables.themeColor,
800: CSSVariables.themeColor,
900: CSSVariables.themeColor,
},
},
});
@ -189,7 +189,7 @@ const MyApp = ({ Component, pageProps }: AppProps) => {
/>
<NextAuthProvider session={pageProps.session}>
<ChakraProvider theme={extendedTheme}>
<ChakraProvider theme={extendedTheme} cssVarsRoot="body">
<I18nProvider i18n={i18n}>
<Layout>
<QueryClientProvider client={queryClient}>

32
pages/api/me/colors.ts Normal file
View File

@ -0,0 +1,32 @@
import { NextApiRequest, NextApiResponse } from "next";
import prisma from "prisma/client";
import { createHandler, getMySession } from "utils/api";
async function POST(req: NextApiRequest, res: NextApiResponse) {
const user = await getMySession(req);
if (!user) return res.status(401).end();
const { colors } = req.body;
const hexCodeRegex = new RegExp(
/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{8})$/
);
if (typeof colors !== "object" || colors === null) {
return res.status(400).json({ message: "invalid type for colors" });
}
for (const [_key, value] of Object.entries(colors)) {
if (!hexCodeRegex.test(value as string)) {
return res.status(400).json({ message: `invalid hex code: ${value}` });
}
}
await prisma.profile.update({ where: { userId: user.id }, data: { colors } });
res.status(200).end();
}
const colorsHandler = (req: NextApiRequest, res: NextApiResponse) =>
createHandler(req, res, { POST });
export default colorsHandler;

View File

@ -1,31 +1,29 @@
@import "/nprogress.css";
body.chakra-ui-light {
--theme-color: #79ff61;
--theme-color-opaque: hsla(111, 100%, 69%, 0.1);
--theme-gray: var(--chakra-colors-gray-600);
--bg-color: #eff0f3;
--secondary-bg-color: #fffafa;
--text-color: var(--chakra-colors-blackAlpha-900);
--border-color: #e2e8f0;
}
body.chakra-ui-dark {
--theme-color: #79ff61;
--theme-color-opaque: hsla(111, 100%, 69%, 0.1);
--theme-gray: var(--chakra-colors-gray-300);
--bg-color: #031e3e;
--secondary-bg-color: #0e2a56;
--text-color: var(--chakra-colors-whiteAlpha-900);
--border-color: #2e466c;
}
body {
--theme-color: var(--custom-theme-color, #79ff61);
--theme-color-opaque: var(--custom-theme-color-opaque, #79ff6120;);
--theme-gray: var(--custom-theme-gray, var(--chakra-colors-gray-300));
--bg-color: var(--custom-bg-color, #031e3e);
--secondary-bg-color: var(--custom-secondary-bg-color, #0e2a56);
--text-color: var(--custom-text-color, #f5f5f5);
--border-color: var(--custom-border-color, #2e466c);
margin: 0;
color: var(--text-color);
background-color: var(--bg-color);
}
body.chakra-ui-light {
--theme-color: var(--custom-theme-color, #1bb300);
--theme-color-opaque: var(--custom-theme-color-opaque, #1bb30020);
--theme-gray: var(--custom-theme-gray, var(--chakra-colors-gray-600));
--bg-color: var(--custom-bg-color, #eff0f3);
--secondary-bg-color: var(--custom-secondary-bg-color, #fffafa);
--text-color: var(--custom-text-color, var(--chakra-colors-blackAlpha-900));
--border-color: var(--custom-border-color, #e2e8f0);
}
header,
footer {
grid-column-end: span 3;
@ -76,72 +74,6 @@ header {
}
}
.rgb {
background: linear-gradient(
124deg,
#ff2400,
#e81d1d,
#e8b71d,
#e3e81d,
#1de840,
#1ddde8,
#2b1de8,
#dd00f3,
#dd00f3
);
-webkit-animation: rainbow 18s ease infinite;
-z-animation: rainbow 18s ease infinite;
-o-animation: rainbow 18s ease infinite;
animation: rainbow 18s ease infinite;
background-size: 1800% 1800%;
}
@-webkit-keyframes rainbow {
0% {
background-position: 0% 82%;
}
50% {
background-position: 100% 19%;
}
100% {
background-position: 0% 82%;
}
}
@-moz-keyframes rainbow {
0% {
background-position: 0% 82%;
}
50% {
background-position: 100% 19%;
}
100% {
background-position: 0% 82%;
}
}
@-o-keyframes rainbow {
0% {
background-position: 0% 82%;
}
50% {
background-position: 100% 19%;
}
100% {
background-position: 0% 82%;
}
}
@keyframes rainbow {
0% {
background-position: 0% 82%;
}
50% {
background-position: 100% 19%;
}
100% {
background-position: 0% 82%;
}
}
/*
For Next.JS image
*/

View File

@ -1,5 +1,5 @@
import { Button, Divider, HStack, Select } from "@chakra-ui/react";
import { t, Trans } from "@lingui/macro";
import { Divider, Select } from "@chakra-ui/react";
import { t } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import { Build, LeagueType, RankedMode } from "@prisma/client";
import BuildCard from "components/builds/BuildCard";
@ -8,7 +8,9 @@ import MyInfiniteScroller from "components/common/MyInfiniteScroller";
import AvatarWithInfo from "components/u/AvatarWithInfo";
import Badges from "components/u/Badges";
import BuildModal from "components/u/BuildModal";
import ProfileColorSelectors from "components/u/ProfileColorSelectors";
import ProfileModal from "components/u/ProfileModal";
import ProfileOwnersButtons from "components/u/ProfileOwnersButton";
import { useUser } from "hooks/common";
import { useBuildsByUser } from "hooks/u";
import { GetStaticPaths, GetStaticProps } from "next";
@ -19,10 +21,12 @@ import {
GetUserByIdentifierData,
} from "prisma/queries/getUserByIdentifier";
import { useEffect, useState } from "react";
import { FiEdit } from "react-icons/fi";
import { RiTShirtLine } from "react-icons/ri";
import useSWR from "swr";
import { GANBA_DISCORD_ID } from "utils/constants";
import {
ADMIN_DISCORD_ID,
BORZOIC_DISCORD_ID,
GANBA_DISCORD_ID,
} from "utils/constants";
import { isCustomUrl } from "utils/validators/profile";
import MyHead from "../../components/common/MyHead";
@ -36,6 +40,7 @@ const ProfilePage = (props: Props) => {
const router = useRouter();
const [showProfileModal, setShowProfileModal] = useState(false);
const [buildToEdit, setBuildToEdit] = useState<boolean | Build>(false);
const [showColorSelectors, setShowColorSelectors] = useState(false);
const [userId, setUserId] = useState<number | undefined>(undefined);
const apiUrl = () => {
@ -47,7 +52,7 @@ const ProfilePage = (props: Props) => {
};
const [loggedInUser] = useUser();
const { data } = useSWR<GetUserByIdentifierData>(apiUrl(), {
const { data, mutate } = useSWR<GetUserByIdentifierData>(apiUrl(), {
initialData: props.user,
});
@ -69,6 +74,16 @@ const ProfilePage = (props: Props) => {
return true;
})();
const canEditProfileColors = (() => {
if ([ADMIN_DISCORD_ID, BORZOIC_DISCORD_ID].includes(user.discordId)) {
return true;
}
if (user.patreonTier ?? -1 < 2) return false;
return true;
})();
useEffect(() => {
if (!router.query.build || !canPostBuilds) return;
@ -85,6 +100,24 @@ const ProfilePage = (props: Props) => {
setUserId(props.user.id);
}, [props.user.id]);
useEffect(() => {
const colors = user.profile?.colors;
if (!colors) return;
if (!canEditProfileColors) return;
const body = document.getElementsByTagName("body")[0];
for (const [key, value] of Object.entries(colors)) {
body.style.setProperty(`--custom-${key}`, value);
}
return () => {
for (const key of Object.keys(colors)) {
body.style.removeProperty(`--custom-${key}`);
}
};
}, []);
return (
<>
<MyHead title={user.username} />
@ -118,7 +151,24 @@ const ProfilePage = (props: Props) => {
)[0]
}
/>
<ProfileOwnersButtons />
{loggedInUser?.id === user.id ? (
<ProfileOwnersButtons
isColorEditorsButtonClickable={canEditProfileColors}
canPostBuilds={canPostBuilds}
setBuildToEdit={setBuildToEdit}
setShowColorSelectors={setShowColorSelectors}
setShowProfileModal={setShowProfileModal}
/>
) : null}
{showColorSelectors ? (
<ProfileColorSelectors
hide={() => setShowColorSelectors(false)}
previousColors={
(user.profile?.colors as Record<string, string>) ?? undefined
}
mutateUser={mutate}
/>
) : null}
{user.profile?.bio && user.profile?.bio.trim().length > 0 && (
<>
<Divider my={6} />
@ -166,35 +216,6 @@ const ProfilePage = (props: Props) => {
)}
</>
);
function ProfileOwnersButtons() {
if (user && loggedInUser?.id === user.id) {
return (
<HStack spacing={4}>
<Button
leftIcon={<FiEdit />}
variant="outline"
onClick={() => setShowProfileModal(true)}
size="sm"
>
<Trans>Edit profile</Trans>
</Button>
{canPostBuilds && (
<Button
leftIcon={<RiTShirtLine />}
variant="outline"
onClick={() => setBuildToEdit(true)}
size="sm"
>
<Trans>Add build</Trans>
</Button>
)}
</HStack>
);
}
return null;
}
};
export const getStaticPaths: GetStaticPaths = async () => {

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Profile" ADD COLUMN "colors" JSONB;

View File

@ -42,6 +42,7 @@ export const getUserByIdentifier = (identifier: string) =>
twitterName: true,
weaponPool: true,
youtubeId: true,
colors: true,
},
},
player: {

View File

@ -5,7 +5,7 @@ datasource db {
}
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
previewFeatures = ["orderByRelation"]
}
@ -50,6 +50,7 @@ model Profile {
weaponPool String[]
updatedAt DateTime @default(now()) @updatedAt
customUrlPath String? @unique
colors Json?
user User @relation(fields: [userId], references: [id])
userId Int @unique
}

BIN
public/layout/b8ing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/layout/b8ing_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

BIN
public/layout/boing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
public/layout/boing_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -5,6 +5,7 @@
export const ADMIN_DISCORD_ID = "79237403620945920";
export const ADMIN_ID = 8;
export const GANBA_DISCORD_ID = "312082701865713665";
export const BORZOIC_DISCORD_ID = "335179210878353409";
export const SALMON_RUN_ADMIN_DISCORD_IDS = [
ADMIN_DISCORD_ID,
"81154649993785344", // Brian

View File

@ -1 +1 @@
[{"username":"Kabagu","discriminator":"6766","patreonTier":3,"discordId":"399267313674747905"},{"username":"Adept","discriminator":"7777","patreonTier":3,"discordId":"68811718473555968"},{"username":"RevengeZ","discriminator":"1919","patreonTier":3,"discordId":"305453601474609153"},{"username":"Krent0n","discriminator":"9587","patreonTier":3,"discordId":"429651155224887299"},{"username":"beta","discriminator":"3025","patreonTier":3,"discordId":"364469424960438272"},{"username":"Fusion","discriminator":"1883","patreonTier":2,"discordId":"78615504511574016"},{"username":"aceticke","discriminator":"0001","patreonTier":2,"discordId":"574626573152419840"},{"username":"prosper","discriminator":"8265","patreonTier":2,"discordId":"412754879933841409"},{"username":"CoconutTank","discriminator":"1053","patreonTier":2,"discordId":"78901351706271744"},{"username":"Lucyfer","discriminator":"0666","patreonTier":2,"discordId":"254943418755710976"},{"username":"fancy","discriminator":"8811","patreonTier":2,"discordId":"125757563047247873"},{"username":"Sakaali","discriminator":"2092","patreonTier":2,"discordId":"140911330059091969"},{"username":"littlepetfrog","discriminator":"6051","patreonTier":2,"discordId":"106462785549897728"},{"username":"Brock.com","discriminator":"0048","patreonTier":2,"discordId":"123514351641559042"},{"username":"Cue","discriminator":"1132","patreonTier":2,"discordId":"347036855453220865"},{"username":"EdmundDickens","discriminator":"1955","patreonTier":2,"discordId":"428618743153688576"},{"username":"miyn","discriminator":"2522","patreonTier":2,"discordId":"358623826721898496"},{"username":"Fuwa","discriminator":"8633","patreonTier":1,"discordId":"150612545336508418"},{"username":"Smaack","discriminator":"8232","patreonTier":1,"discordId":"262647368183185408"}]
[]

345
utils/getFilters.ts Normal file
View File

@ -0,0 +1,345 @@
interface HSL {
h: number;
s: number;
l: number;
}
class Color {
public r: number;
public g: number;
public b: number;
constructor(r: number, g: number, b: number) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
toString() {
return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(
this.b
)})`;
}
set(r: number, g: number, b: number) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
hueRotate(angle = 0) {
angle = (angle / 180) * Math.PI;
const sin = Math.sin(angle);
const cos = Math.cos(angle);
this.multiply([
0.213 + cos * 0.787 - sin * 0.213,
0.715 - cos * 0.715 - sin * 0.715,
0.072 - cos * 0.072 + sin * 0.928,
0.213 - cos * 0.213 + sin * 0.143,
0.715 + cos * 0.285 + sin * 0.14,
0.072 - cos * 0.072 - sin * 0.283,
0.213 - cos * 0.213 - sin * 0.787,
0.715 - cos * 0.715 + sin * 0.715,
0.072 + cos * 0.928 + sin * 0.072,
]);
}
grayscale(value = 1) {
this.multiply([
0.2126 + 0.7874 * (1 - value),
0.7152 - 0.7152 * (1 - value),
0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value),
0.7152 + 0.2848 * (1 - value),
0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value),
0.7152 - 0.7152 * (1 - value),
0.0722 + 0.9278 * (1 - value),
]);
}
sepia(value = 1) {
this.multiply([
0.393 + 0.607 * (1 - value),
0.769 - 0.769 * (1 - value),
0.189 - 0.189 * (1 - value),
0.349 - 0.349 * (1 - value),
0.686 + 0.314 * (1 - value),
0.168 - 0.168 * (1 - value),
0.272 - 0.272 * (1 - value),
0.534 - 0.534 * (1 - value),
0.131 + 0.869 * (1 - value),
]);
}
saturate(value = 1) {
this.multiply([
0.213 + 0.787 * value,
0.715 - 0.715 * value,
0.072 - 0.072 * value,
0.213 - 0.213 * value,
0.715 + 0.285 * value,
0.072 - 0.072 * value,
0.213 - 0.213 * value,
0.715 - 0.715 * value,
0.072 + 0.928 * value,
]);
}
multiply(matrix: any) {
const newR = this.clamp(
this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]
);
const newG = this.clamp(
this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]
);
const newB = this.clamp(
this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]
);
this.r = newR;
this.g = newG;
this.b = newB;
}
brightness(value = 1) {
this.linear(value);
}
contrast(value = 1) {
this.linear(value, -(0.5 * value) + 0.5);
}
linear(slope = 1, intercept = 0) {
this.r = this.clamp(this.r * slope + intercept * 255);
this.g = this.clamp(this.g * slope + intercept * 255);
this.b = this.clamp(this.b * slope + intercept * 255);
}
invert(value = 1) {
this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
}
hsl(): HSL {
// Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
const r = this.r / 255;
const g = this.g / 255;
const b = this.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
let l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return {
h: h * 100,
s: s * 100,
l: l * 100,
};
}
clamp(value: number): number {
if (value > 255) {
value = 255;
} else if (value < 0) {
value = 0;
}
return value;
}
}
interface Solution {
loss: number;
values: number[];
}
class Solver {
private target: Color;
private targetHSL: HSL;
private reusedColor: Color;
constructor(target: Color) {
this.target = target;
this.targetHSL = target.hsl();
this.reusedColor = new Color(0, 0, 0);
}
solve() {
const result = this.solveNarrow(this.solveWide());
return {
values: result.values,
loss: result.loss,
filter: this.css(result.values),
};
}
solveWide(): Solution {
const A = 5;
const c = 15;
const a = [60, 180, 18000, 600, 1.2, 1.2];
let best = { loss: Infinity, values: [] as number[] };
for (let i = 0; best.loss > 25 && i < 3; i++) {
const initial = [50, 20, 3750, 50, 100, 100];
const result = this.spsa(A, a, c, initial, 1000);
if (result.loss < best.loss) {
best = result;
}
}
return best;
}
solveNarrow(wide: Solution) {
const A = wide.loss;
const c = 2;
const A1 = A + 1;
const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
return this.spsa(A, a, c, wide.values, 500);
}
spsa(
A: number,
a: number[],
c: number,
values: number[],
iters: number
): Solution {
const alpha = 1;
const gamma = 0.16666666666666666;
let best = [] as number[];
let bestLoss = Infinity;
const deltas = new Array(6);
const highArgs = new Array(6);
const lowArgs = new Array(6);
for (let k = 0; k < iters; k++) {
const ck = c / Math.pow(k + 1, gamma);
for (let i = 0; i < 6; i++) {
deltas[i] = Math.random() > 0.5 ? 1 : -1;
highArgs[i] = values[i] + ck * deltas[i];
lowArgs[i] = values[i] - ck * deltas[i];
}
const lossDiff = this.loss(highArgs) - this.loss(lowArgs);
for (let i = 0; i < 6; i++) {
const g = (lossDiff / (2 * ck)) * deltas[i];
const ak = a[i] / Math.pow(A + k + 1, alpha);
values[i] = fix(values[i] - ak * g, i);
}
const loss = this.loss(values);
if (loss < bestLoss) {
best = values.slice(0);
bestLoss = loss;
}
}
return { values: best, loss: bestLoss };
function fix(value: number, idx: number): number {
let max = 100;
if (idx === 2 /* saturate */) {
max = 7500;
} else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) {
max = 200;
}
if (idx === 3 /* hue-rotate */) {
if (value > max) {
value %= max;
} else if (value < 0) {
value = max + (value % max);
}
} else if (value < 0) {
value = 0;
} else if (value > max) {
value = max;
}
return value;
}
}
loss(filters: number[]) {
// Argument is array of percentages.
const color = this.reusedColor;
color.set(0, 0, 0);
color.invert(filters[0] / 100);
color.sepia(filters[1] / 100);
color.saturate(filters[2] / 100);
color.hueRotate(filters[3] * 3.6);
color.brightness(filters[4] / 100);
color.contrast(filters[5] / 100);
const colorHSL = color.hsl();
return (
Math.abs(color.r - this.target.r) +
Math.abs(color.g - this.target.g) +
Math.abs(color.b - this.target.b) +
Math.abs(colorHSL.h - this.targetHSL.h) +
Math.abs(colorHSL.s - this.targetHSL.s) +
Math.abs(colorHSL.l - this.targetHSL.l)
);
}
css(filters: number[]) {
function fmt(idx: number, multiplier = 1) {
return Math.round(filters[idx] * multiplier);
}
return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(
2
)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(
5
)}%);`;
}
}
type RGB = [number, number, number];
function hexToRgb(hex: string): RGB {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b;
});
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
return [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16),
];
}
throw new Error("Error parsing hex: " + hex);
}
export function getFilters(hex: string) {
let rgb = [255, 255, 255];
try {
rgb = hexToRgb(hex);
} catch (e) {}
const color = new Color(rgb[0], rgb[1], rgb[2]);
const solver = new Solver(color);
const result = solver.solve();
return result.filter;
}

View File

@ -16,7 +16,10 @@ export async function sendData(method = "POST", url = "", data = {}) {
let description = t`An error occurred`;
try {
const error = await response.json();
if (error.message) description = error.message;
if (error.message) {
description = error.message;
console.error(error.message);
}
} catch {}
toast({