mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-15 09:09:10 +00:00
Refactor node packages to yarn workspaces & ditch next.js for CRA. (#161)
* chore: restructure project into yarn workspaces, remove next * fix tests, remove webapp from terraform * remove more ui deployment bits * remove pages, fix FUNDING.yml * remove isomorphism * remove next providers * fix linting issues * feat: start basis of new web ui system on CRA * chore: move types to @roleypoly/types package * chore: move src/common/utils to @roleypoly/misc-utils * chore: remove roleypoly/ path remappers * chore: renmove vercel config * chore: re-add worker-types to api package * chore: fix type linting scope for api * fix(web): craco should include all of packages dir * fix(ci): change api webpack path for wrangler * chore: remove GAR actions from CI * chore: update codeql job * chore: test better github dar matcher in lint-staged
This commit is contained in:
parent
49e308507e
commit
2ff6588030
328 changed files with 16624 additions and 3525 deletions
1
packages/api/.gitignore
vendored
Normal file
1
packages/api/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
dist
|
13
packages/api/bindings.d.ts
vendored
Normal file
13
packages/api/bindings.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
export {};
|
||||
|
||||
declare global {
|
||||
const BOT_CLIENT_ID: string;
|
||||
const BOT_CLIENT_SECRET: string;
|
||||
const UI_PUBLIC_URI: string;
|
||||
const API_PUBLIC_URI: string;
|
||||
const ROOT_USERS: string;
|
||||
|
||||
const KV_SESSIONS: KVNamespace;
|
||||
const KV_GUILDS: KVNamespace;
|
||||
const KV_GUILD_DATA: KVNamespace;
|
||||
}
|
36
packages/api/handlers/bot-join.ts
Normal file
36
packages/api/handlers/bot-join.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { Bounce } from '../utils/bounce';
|
||||
import { botClientID } from '../utils/config';
|
||||
|
||||
const validGuildID = /^[0-9]+$/;
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
permissions: number;
|
||||
guildID?: string;
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) => {
|
||||
let url = `https://discord.com/api/oauth2/authorize?client_id=${params.clientID}&scope=bot&permissions=${params.permissions}`;
|
||||
|
||||
if (params.guildID) {
|
||||
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export const BotJoin = (request: Request): Response => {
|
||||
let guildID = new URL(request.url).searchParams.get('guild') || '';
|
||||
|
||||
if (guildID && !validGuildID.test(guildID)) {
|
||||
guildID = '';
|
||||
}
|
||||
|
||||
return Bounce(
|
||||
buildURL({
|
||||
clientID: botClientID,
|
||||
permissions: 268435456,
|
||||
guildID,
|
||||
})
|
||||
);
|
||||
};
|
94
packages/api/handlers/create-roleypoly-data.ts
Normal file
94
packages/api/handlers/create-roleypoly-data.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { CategoryType, Features, GuildData as GuildDataT } from '@roleypoly/types';
|
||||
import KSUID from 'ksuid';
|
||||
import { onlyRootUsers, respond } from '../utils/api-tools';
|
||||
import { GuildData } from '../utils/kv';
|
||||
|
||||
// Temporary use.
|
||||
export const CreateRoleypolyData = onlyRootUsers(
|
||||
async (request: Request): Promise<Response> => {
|
||||
const data: GuildDataT = {
|
||||
id: '386659935687147521',
|
||||
message:
|
||||
'Hey, this is kind of a demo setup so features/use cases can be shown off.\n\nThanks for using Roleypoly <3',
|
||||
features: Features.Preview,
|
||||
categories: [
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Demo Roles',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 0,
|
||||
roles: [
|
||||
'557825026406088717',
|
||||
'557824994269200384',
|
||||
'557824893241131029',
|
||||
'557812915386843170',
|
||||
'557812901717737472',
|
||||
'557812805546541066',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Colors',
|
||||
type: CategoryType.Single,
|
||||
hidden: false,
|
||||
position: 1,
|
||||
roles: [
|
||||
'394060232893923349',
|
||||
'394060145799331851',
|
||||
'394060192846839809',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Test Roles',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 5,
|
||||
roles: [
|
||||
'558104828216213505',
|
||||
'558103534453653514',
|
||||
'558297233582194728',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Region',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 3,
|
||||
roles: [
|
||||
'397296181803483136',
|
||||
'397296137066774529',
|
||||
'397296218809827329',
|
||||
'397296267283267605',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Opt-in Channels',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 4,
|
||||
roles: ['414514823959674890', '764230661904007219'],
|
||||
},
|
||||
{
|
||||
id: KSUID.randomSync().string,
|
||||
name: 'Pronouns',
|
||||
type: CategoryType.Multi,
|
||||
hidden: false,
|
||||
position: 2,
|
||||
roles: [
|
||||
'485916566790340608',
|
||||
'485916566941335583',
|
||||
'485916566311927808',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await GuildData.put(data.id, data);
|
||||
|
||||
return respond({ ok: true });
|
||||
}
|
||||
);
|
56
packages/api/handlers/get-picker-data.ts
Normal file
56
packages/api/handlers/get-picker-data.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { DiscordUser, GuildSlug, PresentableGuild, SessionData } from '@roleypoly/types';
|
||||
import { respond, withSession } from '../utils/api-tools';
|
||||
import { getGuild, getGuildData, getGuildMemberRoles } from '../utils/guild';
|
||||
|
||||
const fail = () => respond({ error: 'guild not found' }, { status: 404 });
|
||||
|
||||
export const GetPickerData = withSession(
|
||||
(session: SessionData) => async (request: Request): Promise<Response> => {
|
||||
const url = new URL(request.url);
|
||||
const [, , guildID] = url.pathname.split('/');
|
||||
|
||||
if (!guildID) {
|
||||
return respond({ error: 'missing guild id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { id: userID } = session.user as DiscordUser;
|
||||
const guilds = session.guilds as GuildSlug[];
|
||||
|
||||
// Save a Discord API request by checking if this user is a member by session first
|
||||
const checkGuild = guilds.find((guild) => guild.id === guildID);
|
||||
if (!checkGuild) {
|
||||
return fail();
|
||||
}
|
||||
|
||||
const guild = await getGuild(guildID, {
|
||||
skipCachePull: url.searchParams.has('__no_cache'),
|
||||
});
|
||||
if (!guild) {
|
||||
return fail();
|
||||
}
|
||||
|
||||
const memberRolesP = getGuildMemberRoles({
|
||||
serverID: guildID,
|
||||
userID,
|
||||
});
|
||||
|
||||
const guildDataP = getGuildData(guildID);
|
||||
|
||||
const [guildData, memberRoles] = await Promise.all([guildDataP, memberRolesP]);
|
||||
if (!memberRoles) {
|
||||
return fail();
|
||||
}
|
||||
|
||||
const presentableGuild: PresentableGuild = {
|
||||
id: guildID,
|
||||
guild: checkGuild,
|
||||
roles: guild.roles,
|
||||
member: {
|
||||
roles: memberRoles,
|
||||
},
|
||||
data: guildData,
|
||||
};
|
||||
|
||||
return respond(presentableGuild);
|
||||
}
|
||||
);
|
12
packages/api/handlers/get-session.ts
Normal file
12
packages/api/handlers/get-session.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { SessionData } from '@roleypoly/types';
|
||||
import { respond, withSession } from '../utils/api-tools';
|
||||
|
||||
export const GetSession = withSession((session?: SessionData) => (): Response => {
|
||||
const { user, guilds, sessionID } = session || {};
|
||||
|
||||
return respond({
|
||||
user,
|
||||
guilds,
|
||||
sessionID,
|
||||
});
|
||||
});
|
41
packages/api/handlers/get-slug.ts
Normal file
41
packages/api/handlers/get-slug.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { GuildSlug } from '@roleypoly/types';
|
||||
import { respond } from '../utils/api-tools';
|
||||
import { getGuild } from '../utils/guild';
|
||||
|
||||
export const GetSlug = async (request: Request): Promise<Response> => {
|
||||
const reqURL = new URL(request.url);
|
||||
const [, , serverID] = reqURL.pathname.split('/');
|
||||
|
||||
if (!serverID) {
|
||||
return respond(
|
||||
{
|
||||
error: 'missing server ID',
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const guild = await getGuild(serverID);
|
||||
if (!guild) {
|
||||
return respond(
|
||||
{
|
||||
error: 'guild not found',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { id, name, icon } = guild;
|
||||
const guildSlug: GuildSlug = {
|
||||
id,
|
||||
name,
|
||||
icon,
|
||||
permissionLevel: 0,
|
||||
};
|
||||
console.log({ guildSlug });
|
||||
return respond(guildSlug);
|
||||
};
|
24
packages/api/handlers/login-bounce.ts
Normal file
24
packages/api/handlers/login-bounce.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import KSUID from 'ksuid';
|
||||
import { Bounce } from '../utils/bounce';
|
||||
import { apiPublicURI, botClientID } from '../utils/config';
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
redirectURI: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) =>
|
||||
`https://discord.com/api/oauth2/authorize?client_id=${
|
||||
params.clientID
|
||||
}&response_type=code&scope=identify%20guilds&redirect_uri=${encodeURIComponent(
|
||||
params.redirectURI
|
||||
)}&state=${params.state}`;
|
||||
|
||||
export const LoginBounce = async (request: Request): Promise<Response> => {
|
||||
const state = await KSUID.random();
|
||||
const redirectURI = `${apiPublicURI}/login-callback`;
|
||||
const clientID = botClientID;
|
||||
|
||||
return Bounce(buildURL({ state: state.string, redirectURI, clientID }));
|
||||
};
|
142
packages/api/handlers/login-callback.ts
Normal file
142
packages/api/handlers/login-callback.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { AuthTokenResponse, DiscordUser, GuildSlug, SessionData } from '@roleypoly/types';
|
||||
import KSUID from 'ksuid';
|
||||
import {
|
||||
AuthType,
|
||||
discordFetch,
|
||||
formData,
|
||||
parsePermissions,
|
||||
resolveFailures,
|
||||
userAgent,
|
||||
} from '../utils/api-tools';
|
||||
import { Bounce } from '../utils/bounce';
|
||||
import { apiPublicURI, botClientID, botClientSecret, uiPublicURI } from '../utils/config';
|
||||
import { Sessions } from '../utils/kv';
|
||||
|
||||
const AuthErrorResponse = (extra?: string) =>
|
||||
Bounce(
|
||||
uiPublicURI +
|
||||
`/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}`
|
||||
);
|
||||
|
||||
export const LoginCallback = resolveFailures(
|
||||
AuthErrorResponse,
|
||||
async (request: Request): Promise<Response> => {
|
||||
const query = new URL(request.url).searchParams;
|
||||
|
||||
const stateValue = query.get('state');
|
||||
|
||||
if (stateValue === null) {
|
||||
return AuthErrorResponse('state missing');
|
||||
}
|
||||
|
||||
try {
|
||||
const state = KSUID.parse(stateValue);
|
||||
const stateExpiry = state.date.getTime() + 1000 * 60 * 5;
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime > stateExpiry) {
|
||||
return AuthErrorResponse('state expired');
|
||||
}
|
||||
} catch (e) {
|
||||
return AuthErrorResponse('state invalid');
|
||||
}
|
||||
|
||||
const code = query.get('code');
|
||||
if (!code) {
|
||||
return AuthErrorResponse('code missing');
|
||||
}
|
||||
|
||||
const tokenRequest = {
|
||||
client_id: botClientID,
|
||||
client_secret: botClientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: apiPublicURI + '/login-callback',
|
||||
scope: 'identify guilds',
|
||||
code,
|
||||
};
|
||||
|
||||
const tokenFetch = await fetch('https://discord.com/api/v8/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
body: formData(tokenRequest),
|
||||
});
|
||||
|
||||
const tokens = (await tokenFetch.json()) as AuthTokenResponse;
|
||||
|
||||
if (!tokens.access_token) {
|
||||
return AuthErrorResponse('token response invalid');
|
||||
}
|
||||
|
||||
const [sessionID, user, guilds] = await Promise.all([
|
||||
KSUID.random(),
|
||||
getUser(tokens.access_token),
|
||||
getGuilds(tokens.access_token),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
return AuthErrorResponse('failed to fetch user');
|
||||
}
|
||||
|
||||
const sessionData: SessionData = {
|
||||
tokens,
|
||||
sessionID: sessionID.string,
|
||||
user,
|
||||
guilds,
|
||||
};
|
||||
|
||||
await Sessions.put(sessionID.string, sessionData, 60 * 60 * 6);
|
||||
|
||||
return Bounce(
|
||||
uiPublicURI + '/machinery/new-session?session_id=' + sessionID.string
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const getUser = async (accessToken: string): Promise<DiscordUser | null> => {
|
||||
const user = await discordFetch<DiscordUser>(
|
||||
'/users/@me',
|
||||
accessToken,
|
||||
AuthType.Bearer
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, username, discriminator, bot, avatar } = user;
|
||||
|
||||
return { id, username, discriminator, bot, avatar };
|
||||
};
|
||||
|
||||
type UserGuildsPayload = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
owner: boolean;
|
||||
permissions: number;
|
||||
features: string[];
|
||||
}[];
|
||||
|
||||
const getGuilds = async (accessToken: string) => {
|
||||
const guilds = await discordFetch<UserGuildsPayload>(
|
||||
'/users/@me/guilds',
|
||||
accessToken,
|
||||
AuthType.Bearer
|
||||
);
|
||||
|
||||
if (!guilds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const guildSlugs = guilds.map<GuildSlug>((guild) => ({
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
permissionLevel: parsePermissions(BigInt(guild.permissions), guild.owner),
|
||||
}));
|
||||
|
||||
return guildSlugs;
|
||||
};
|
27
packages/api/handlers/revoke-session.ts
Normal file
27
packages/api/handlers/revoke-session.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { SessionData } from '@roleypoly/types';
|
||||
import { formData, respond, userAgent, withSession } from '../utils/api-tools';
|
||||
import { botClientID, botClientSecret } from '../utils/config';
|
||||
import { Sessions } from '../utils/kv';
|
||||
|
||||
export const RevokeSession = withSession(
|
||||
(session: SessionData) => async (request: Request) => {
|
||||
const tokenRequest = {
|
||||
token: session.tokens.access_token,
|
||||
client_id: botClientID,
|
||||
client_secret: botClientSecret,
|
||||
};
|
||||
|
||||
await fetch('https://discord.com/api/v8/oauth2/token/revoke', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
body: formData(tokenRequest),
|
||||
});
|
||||
|
||||
await Sessions.delete(session.sessionID);
|
||||
|
||||
return respond({ ok: true });
|
||||
}
|
||||
);
|
141
packages/api/handlers/update-roles.ts
Normal file
141
packages/api/handlers/update-roles.ts
Normal file
|
@ -0,0 +1,141 @@
|
|||
import {
|
||||
GuildData,
|
||||
Member,
|
||||
Role,
|
||||
RoleSafety,
|
||||
RoleTransaction,
|
||||
RoleUpdate,
|
||||
SessionData,
|
||||
TransactionType,
|
||||
} from '@roleypoly/types';
|
||||
import { difference, groupBy, keyBy, union } from 'lodash';
|
||||
import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools';
|
||||
import { botToken } from '../utils/config';
|
||||
import {
|
||||
getGuild,
|
||||
getGuildData,
|
||||
getGuildMemberRoles,
|
||||
updateGuildMemberRoles,
|
||||
} from '../utils/guild';
|
||||
|
||||
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
|
||||
|
||||
export const UpdateRoles = withSession(
|
||||
({ guilds, user: { id: userID } }: SessionData) => async (request: Request) => {
|
||||
const updateRequest = (await request.json()) as RoleUpdate;
|
||||
const [, , guildID] = new URL(request.url).pathname.split('/');
|
||||
|
||||
if (!guildID) {
|
||||
return respond({ error: 'guild ID missing from URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (updateRequest.transactions.length === 0) {
|
||||
return respond(
|
||||
{ error: 'must have as least one transaction' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const guildCheck = guilds.find((guild) => guild.id === guildID);
|
||||
if (!guildCheck) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const guild = await getGuild(guildID);
|
||||
if (!guild) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const guildMemberRoles = await getGuildMemberRoles(
|
||||
{ serverID: guildID, userID },
|
||||
{ skipCachePull: true }
|
||||
);
|
||||
if (!guildMemberRoles) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const newRoles = calculateNewRoles({
|
||||
currentRoles: guildMemberRoles,
|
||||
guildRoles: guild.roles,
|
||||
guildData: await getGuildData(guildID),
|
||||
updateRequest,
|
||||
});
|
||||
|
||||
const patchMemberRoles = await discordFetch<Member>(
|
||||
`/guilds/${guildID}/members/${userID}`,
|
||||
botToken,
|
||||
AuthType.Bot,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roles: newRoles,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchMemberRoles) {
|
||||
return respond({ error: 'discord rejected the request' }, { status: 500 });
|
||||
}
|
||||
|
||||
const updatedMember: Member = {
|
||||
roles: patchMemberRoles.roles,
|
||||
};
|
||||
|
||||
await updateGuildMemberRoles(
|
||||
{ serverID: guildID, userID },
|
||||
patchMemberRoles.roles
|
||||
);
|
||||
|
||||
return respond(updatedMember);
|
||||
}
|
||||
);
|
||||
|
||||
const calculateNewRoles = ({
|
||||
currentRoles,
|
||||
guildData,
|
||||
guildRoles,
|
||||
updateRequest,
|
||||
}: {
|
||||
currentRoles: string[];
|
||||
guildRoles: Role[];
|
||||
guildData: GuildData;
|
||||
updateRequest: RoleUpdate;
|
||||
}): string[] => {
|
||||
const roleMap = keyBy(guildRoles, 'id');
|
||||
|
||||
// These roles were ones changed between knownState (role picker page load/cache) and current (fresh from discord).
|
||||
// We could cause issues, so we'll re-add them later.
|
||||
// const diffRoles = difference(updateRequest.knownState, currentRoles);
|
||||
|
||||
// Only these are safe
|
||||
const allSafeRoles = guildData.categories.reduce<string[]>(
|
||||
(categorizedRoles, category) =>
|
||||
!category.hidden
|
||||
? [
|
||||
...categorizedRoles,
|
||||
...category.roles.filter(
|
||||
(roleID) => roleMap[roleID]?.safety === RoleSafety.Safe
|
||||
),
|
||||
]
|
||||
: categorizedRoles,
|
||||
[]
|
||||
);
|
||||
|
||||
const safeTransactions = updateRequest.transactions.filter((tx: RoleTransaction) =>
|
||||
allSafeRoles.includes(tx.id)
|
||||
);
|
||||
|
||||
const changesByAction = groupBy(safeTransactions, 'action');
|
||||
|
||||
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map((tx) => tx.id);
|
||||
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
|
||||
(tx) => tx.id
|
||||
);
|
||||
|
||||
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
|
||||
|
||||
return final;
|
||||
};
|
61
packages/api/index.ts
Normal file
61
packages/api/index.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { BotJoin } from './handlers/bot-join';
|
||||
import { CreateRoleypolyData } from './handlers/create-roleypoly-data';
|
||||
import { GetPickerData } from './handlers/get-picker-data';
|
||||
import { GetSession } from './handlers/get-session';
|
||||
import { GetSlug } from './handlers/get-slug';
|
||||
import { LoginBounce } from './handlers/login-bounce';
|
||||
import { LoginCallback } from './handlers/login-callback';
|
||||
import { RevokeSession } from './handlers/revoke-session';
|
||||
import { UpdateRoles } from './handlers/update-roles';
|
||||
import { Router } from './router';
|
||||
import { respond } from './utils/api-tools';
|
||||
import { uiPublicURI } from './utils/config';
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// OAuth
|
||||
router.add('GET', 'bot-join', BotJoin);
|
||||
router.add('GET', 'login-bounce', LoginBounce);
|
||||
router.add('GET', 'login-callback', LoginCallback);
|
||||
|
||||
// Session
|
||||
router.add('GET', 'get-session', GetSession);
|
||||
router.add('POST', 'revoke-session', RevokeSession);
|
||||
|
||||
// Main biz logic
|
||||
router.add('GET', 'get-slug', GetSlug);
|
||||
router.add('GET', 'get-picker-data', GetPickerData);
|
||||
router.add('PATCH', 'update-roles', UpdateRoles);
|
||||
|
||||
// Root users only
|
||||
router.add('GET', 'x-create-roleypoly-data', CreateRoleypolyData);
|
||||
|
||||
// Tester Routes
|
||||
router.add('GET', 'x-headers', (request) => {
|
||||
const headers: { [x: string]: string } = {};
|
||||
|
||||
for (let [key, value] of request.headers.entries()) {
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(headers));
|
||||
});
|
||||
|
||||
// Root Zen <3
|
||||
router.addFallback('root', () => {
|
||||
return respond({
|
||||
__warning: '🦊',
|
||||
this: 'is',
|
||||
a: 'fox-based',
|
||||
web: 'application',
|
||||
please: 'be',
|
||||
mindful: 'of',
|
||||
your: 'surroundings',
|
||||
warning__: '🦊',
|
||||
meta: uiPublicURI,
|
||||
});
|
||||
});
|
||||
|
||||
addEventListener('fetch', (event: FetchEvent) => {
|
||||
event.respondWith(router.handle(event.request));
|
||||
});
|
19
packages/api/package.json
Normal file
19
packages/api/package.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@roleypoly/api",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "yarn workspace @roleypoly/worker-emulator build --basePath `pwd`",
|
||||
"lint:types": "tsc --noEmit",
|
||||
"start": "yarn workspace @roleypoly/worker-emulator start --basePath `pwd`"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^2.1.0",
|
||||
"@roleypoly/misc-utils": "*",
|
||||
"@roleypoly/types": "*",
|
||||
"@roleypoly/worker-emulator": "*",
|
||||
"ksuid": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"ts-loader": "^8.0.18",
|
||||
"tsconfig-paths-webpack-plugin": "^3.3.0"
|
||||
}
|
||||
}
|
84
packages/api/router.ts
Normal file
84
packages/api/router.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { addCORS } from './utils/api-tools';
|
||||
import { uiPublicURI } from './utils/config';
|
||||
|
||||
export type Handler = (request: Request) => Promise<Response> | Response;
|
||||
|
||||
type RoutingTree = {
|
||||
[method: string]: {
|
||||
[path: string]: Handler;
|
||||
};
|
||||
};
|
||||
|
||||
type Fallbacks = {
|
||||
root: Handler;
|
||||
404: Handler;
|
||||
500: Handler;
|
||||
};
|
||||
|
||||
export class Router {
|
||||
private routingTree: RoutingTree = {};
|
||||
private fallbacks: Fallbacks = {
|
||||
root: this.respondToRoot,
|
||||
404: this.notFound,
|
||||
500: this.serverError,
|
||||
};
|
||||
|
||||
private uiURL = new URL(uiPublicURI);
|
||||
|
||||
addFallback(which: keyof Fallbacks, handler: Handler) {
|
||||
this.fallbacks[which] = handler;
|
||||
}
|
||||
|
||||
add(method: string, rootPath: string, handler: Handler) {
|
||||
const lowerMethod = method.toLowerCase();
|
||||
|
||||
if (!this.routingTree[lowerMethod]) {
|
||||
this.routingTree[lowerMethod] = {};
|
||||
}
|
||||
|
||||
this.routingTree[lowerMethod][rootPath] = handler;
|
||||
}
|
||||
|
||||
async handle(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/' || url.pathname === '') {
|
||||
return this.fallbacks.root(request);
|
||||
}
|
||||
const lowerMethod = request.method.toLowerCase();
|
||||
const rootPath = url.pathname.split('/')[1];
|
||||
const handler = this.routingTree[lowerMethod]?.[rootPath];
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
const response = await handler(request);
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return this.fallbacks[500](request);
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerMethod === 'options') {
|
||||
return new Response(null, addCORS({}));
|
||||
}
|
||||
|
||||
return this.fallbacks[404](request);
|
||||
}
|
||||
|
||||
private respondToRoot(): Response {
|
||||
return new Response('Hi there!');
|
||||
}
|
||||
|
||||
private notFound(): Response {
|
||||
return new Response(JSON.stringify({ error: 'not_found' }), {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
private serverError(): Response {
|
||||
return new Response(JSON.stringify({ error: 'internal_server_error' }), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
15
packages/api/tsconfig.json
Normal file
15
packages/api/tsconfig.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"target": "ES2019"
|
||||
},
|
||||
"include": [
|
||||
"./*.ts",
|
||||
"./**/*.ts",
|
||||
"../../node_modules/@cloudflare/workers-types/index.d.ts"
|
||||
],
|
||||
"exclude": ["./**/*.spec.ts", "./dist/**"],
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
178
packages/api/utils/api-tools.ts
Normal file
178
packages/api/utils/api-tools.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
import {
|
||||
evaluatePermission,
|
||||
permissions as Permissions,
|
||||
} from '@roleypoly/misc-utils/hasPermission';
|
||||
import { SessionData, UserGuildPermissions } from '@roleypoly/types';
|
||||
import { Handler } from '../router';
|
||||
import { rootUsers, uiPublicURI } from './config';
|
||||
import { Sessions, WrappedKVNamespace } from './kv';
|
||||
|
||||
export const formData = (obj: Record<string, any>): string => {
|
||||
return Object.keys(obj)
|
||||
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
|
||||
.join('&');
|
||||
};
|
||||
|
||||
export const addCORS = (init: ResponseInit = {}) => ({
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
'access-control-allow-origin': uiPublicURI,
|
||||
'access-control-allow-methods': '*',
|
||||
'access-control-allow-headers': '*',
|
||||
},
|
||||
});
|
||||
|
||||
export const respond = (obj: Record<string, any>, init: ResponseInit = {}) =>
|
||||
new Response(JSON.stringify(obj), addCORS(init));
|
||||
|
||||
export const resolveFailures = (
|
||||
handleWith: () => Response,
|
||||
handler: (request: Request) => Promise<Response> | Response
|
||||
) => async (request: Request): Promise<Response> => {
|
||||
try {
|
||||
return handler(request);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return (
|
||||
handleWith() || respond({ error: 'internal server error' }, { status: 500 })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const parsePermissions = (
|
||||
permissions: bigint,
|
||||
owner: boolean = false
|
||||
): UserGuildPermissions => {
|
||||
if (owner || evaluatePermission(permissions, Permissions.ADMINISTRATOR)) {
|
||||
return UserGuildPermissions.Admin;
|
||||
}
|
||||
|
||||
if (evaluatePermission(permissions, Permissions.MANAGE_ROLES)) {
|
||||
return UserGuildPermissions.Manager;
|
||||
}
|
||||
|
||||
return UserGuildPermissions.User;
|
||||
};
|
||||
|
||||
export const getSessionID = (request: Request): { type: string; id: string } | null => {
|
||||
const sessionID = request.headers.get('authorization');
|
||||
if (!sessionID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [type, id] = sessionID.split(' ');
|
||||
if (type !== 'Bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { type, id };
|
||||
};
|
||||
|
||||
export const userAgent =
|
||||
'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)';
|
||||
|
||||
export enum AuthType {
|
||||
Bearer = 'Bearer',
|
||||
Bot = 'Bot',
|
||||
}
|
||||
|
||||
export const discordFetch = async <T>(
|
||||
url: string,
|
||||
auth: string,
|
||||
authType: AuthType = AuthType.Bearer,
|
||||
init?: RequestInit
|
||||
): Promise<T | null> => {
|
||||
const response = await fetch('https://discord.com/api/v8' + url, {
|
||||
...(init || {}),
|
||||
headers: {
|
||||
...(init?.headers || {}),
|
||||
authorization: `${AuthType[authType]} ${auth}`,
|
||||
'user-agent': userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
console.error('discordFetch failed', {
|
||||
url,
|
||||
authType,
|
||||
payload: await response.text(),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
return (await response.json()) as T;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const cacheLayer = <Identity, Data>(
|
||||
kv: WrappedKVNamespace,
|
||||
keyFactory: (identity: Identity) => string,
|
||||
missHandler: (identity: Identity) => Promise<Data | null>,
|
||||
ttlSeconds?: number
|
||||
) => async (
|
||||
identity: Identity,
|
||||
options: { skipCachePull?: boolean } = {}
|
||||
): Promise<Data | null> => {
|
||||
const key = keyFactory(identity);
|
||||
|
||||
if (!options.skipCachePull) {
|
||||
const value = await kv.get<Data>(key);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackValue = await missHandler(identity);
|
||||
if (!fallbackValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await kv.put(key, fallbackValue, ttlSeconds);
|
||||
|
||||
return fallbackValue;
|
||||
};
|
||||
|
||||
const NotAuthenticated = (extra?: string) =>
|
||||
respond(
|
||||
{
|
||||
error: extra || 'not authenticated',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
|
||||
export const withSession = (
|
||||
wrappedHandler: (session: SessionData) => Handler
|
||||
): Handler => async (request: Request): Promise<Response> => {
|
||||
const sessionID = getSessionID(request);
|
||||
if (!sessionID) {
|
||||
return NotAuthenticated('missing authentication');
|
||||
}
|
||||
|
||||
const session = await Sessions.get<SessionData>(sessionID.id);
|
||||
if (!session) {
|
||||
return NotAuthenticated('authentication expired or not found');
|
||||
}
|
||||
|
||||
return await wrappedHandler(session)(request);
|
||||
};
|
||||
|
||||
export const isRoot = (userID: string): boolean => rootUsers.includes(userID);
|
||||
|
||||
export const onlyRootUsers = (handler: Handler): Handler =>
|
||||
withSession((session) => (request: Request) => {
|
||||
if (isRoot(session.user.id)) {
|
||||
return handler(request);
|
||||
}
|
||||
|
||||
return respond(
|
||||
{
|
||||
error: 'not_found',
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
7
packages/api/utils/bounce.ts
Normal file
7
packages/api/utils/bounce.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const Bounce = (url: string): Response =>
|
||||
new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
location: url,
|
||||
},
|
||||
});
|
13
packages/api/utils/config.ts
Normal file
13
packages/api/utils/config.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
const self = (global as any) as Record<string, string>;
|
||||
|
||||
const env = (key: string) => self[key] ?? '';
|
||||
|
||||
const safeURI = (x: string) => x.replace(/\/$/, '');
|
||||
const list = (x: string) => x.split(',');
|
||||
|
||||
export const botClientID = env('BOT_CLIENT_ID');
|
||||
export const botClientSecret = env('BOT_CLIENT_SECRET');
|
||||
export const botToken = env('BOT_TOKEN');
|
||||
export const uiPublicURI = safeURI(env('UI_PUBLIC_URI'));
|
||||
export const apiPublicURI = safeURI(env('API_PUBLIC_URI'));
|
||||
export const rootUsers = list(env('ROOT_USERS'));
|
163
packages/api/utils/guild.ts
Normal file
163
packages/api/utils/guild.ts
Normal file
|
@ -0,0 +1,163 @@
|
|||
import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission';
|
||||
import {
|
||||
Features,
|
||||
Guild,
|
||||
GuildData as GuildDataT,
|
||||
OwnRoleInfo,
|
||||
Role,
|
||||
RoleSafety,
|
||||
} from '@roleypoly/types';
|
||||
import { AuthType, cacheLayer, discordFetch } from './api-tools';
|
||||
import { botClientID, botToken } from './config';
|
||||
import { GuildData, Guilds } from './kv';
|
||||
|
||||
type APIGuild = {
|
||||
// Only relevant stuff
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
roles: APIRole[];
|
||||
};
|
||||
|
||||
type APIRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
permissions: string;
|
||||
managed: boolean;
|
||||
};
|
||||
|
||||
export const getGuild = cacheLayer(
|
||||
Guilds,
|
||||
(id: string) => `guilds/${id}`,
|
||||
async (id: string) => {
|
||||
const guildRaw = await discordFetch<APIGuild>(
|
||||
`/guilds/${id}`,
|
||||
botToken,
|
||||
AuthType.Bot
|
||||
);
|
||||
|
||||
if (!guildRaw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const botMemberRoles =
|
||||
(await getGuildMemberRoles({
|
||||
serverID: id,
|
||||
userID: botClientID,
|
||||
})) || [];
|
||||
|
||||
const highestRolePosition = botMemberRoles.reduce<number>((highest, roleID) => {
|
||||
const role = guildRaw.roles.find((guildRole) => guildRole.id === roleID);
|
||||
if (!role) {
|
||||
return highest;
|
||||
}
|
||||
|
||||
// If highest is a bigger number, it stays the highest.
|
||||
if (highest > role.position) {
|
||||
return highest;
|
||||
}
|
||||
|
||||
return role.position;
|
||||
}, 0);
|
||||
|
||||
const roles = guildRaw.roles.map<Role>((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
managed: role.managed,
|
||||
position: role.position,
|
||||
permissions: role.permissions,
|
||||
safety: calculateRoleSafety(role, highestRolePosition),
|
||||
}));
|
||||
|
||||
// Filters the raw guild data into data we actually want
|
||||
const guild: Guild & OwnRoleInfo = {
|
||||
id: guildRaw.id,
|
||||
name: guildRaw.name,
|
||||
icon: guildRaw.icon,
|
||||
roles,
|
||||
highestRolePosition,
|
||||
};
|
||||
|
||||
return guild;
|
||||
},
|
||||
60 * 60 * 2 // 2 hour TTL
|
||||
);
|
||||
|
||||
type GuildMemberIdentity = {
|
||||
serverID: string;
|
||||
userID: string;
|
||||
};
|
||||
|
||||
type APIMember = {
|
||||
// Only relevant stuff, again.
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
const guildMemberRolesIdentity = ({ serverID, userID }: GuildMemberIdentity) =>
|
||||
`guilds/${serverID}/members/${userID}/roles`;
|
||||
|
||||
export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>(
|
||||
Guilds,
|
||||
guildMemberRolesIdentity,
|
||||
async ({ serverID, userID }) => {
|
||||
const discordMember = await discordFetch<APIMember>(
|
||||
`/guilds/${serverID}/members/${userID}`,
|
||||
botToken,
|
||||
AuthType.Bot
|
||||
);
|
||||
|
||||
if (!discordMember) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return discordMember.roles;
|
||||
},
|
||||
60 * 5 // 5 minute TTL
|
||||
);
|
||||
|
||||
export const updateGuildMemberRoles = async (
|
||||
identity: GuildMemberIdentity,
|
||||
roles: Role['id'][]
|
||||
) => {
|
||||
await Guilds.put(guildMemberRolesIdentity(identity), roles, 60 * 5);
|
||||
};
|
||||
|
||||
export const getGuildData = async (id: string): Promise<GuildDataT> => {
|
||||
const guildData = await GuildData.get<GuildDataT>(id);
|
||||
|
||||
if (!guildData) {
|
||||
return {
|
||||
id,
|
||||
message: '',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
};
|
||||
}
|
||||
|
||||
return guildData;
|
||||
};
|
||||
|
||||
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
|
||||
let safety = RoleSafety.Safe;
|
||||
|
||||
if (role.managed) {
|
||||
safety |= RoleSafety.ManagedRole;
|
||||
}
|
||||
|
||||
if (role.position > highestBotRolePosition) {
|
||||
safety |= RoleSafety.HigherThanBot;
|
||||
}
|
||||
|
||||
const permBigInt = BigInt(role.permissions);
|
||||
if (
|
||||
evaluatePermission(permBigInt, permissions.ADMINISTRATOR) ||
|
||||
evaluatePermission(permBigInt, permissions.MANAGE_ROLES)
|
||||
) {
|
||||
safety |= RoleSafety.DangerousPermissions;
|
||||
}
|
||||
|
||||
return safety;
|
||||
};
|
90
packages/api/utils/kv.ts
Normal file
90
packages/api/utils/kv.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
export class WrappedKVNamespace {
|
||||
constructor(private kvNamespace: KVNamespace) {}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const data = await this.kvNamespace.get(key, 'text');
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(data) as T;
|
||||
}
|
||||
|
||||
async put<T>(key: string, value: T, ttlSeconds?: number) {
|
||||
await this.kvNamespace.put(key, JSON.stringify(value), {
|
||||
expirationTtl: ttlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
list = this.kvNamespace.list;
|
||||
getWithMetadata = this.kvNamespace.getWithMetadata;
|
||||
delete = this.kvNamespace.delete;
|
||||
}
|
||||
|
||||
class EmulatedKV implements KVNamespace {
|
||||
constructor() {
|
||||
console.warn('EmulatedKV used. Data will be lost.');
|
||||
}
|
||||
|
||||
private data: Map<string, any> = new Map();
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
if (!this.data.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.data.get(key);
|
||||
}
|
||||
|
||||
async getWithMetadata<T, Metadata = unknown>(
|
||||
key: string
|
||||
): KVValueWithMetadata<T, Metadata> {
|
||||
return {
|
||||
value: await this.get<T>(key),
|
||||
metadata: {} as Metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async put(key: string, value: string | ReadableStream<any> | ArrayBuffer | FormData) {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
|
||||
async delete(key: string) {
|
||||
this.data.delete(key);
|
||||
}
|
||||
|
||||
async list(options?: {
|
||||
prefix?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<{
|
||||
keys: { name: string; expiration?: number; metadata?: unknown }[];
|
||||
list_complete: boolean;
|
||||
cursor: string;
|
||||
}> {
|
||||
let keys: { name: string }[] = [];
|
||||
|
||||
for (let key of this.data.keys()) {
|
||||
if (options?.prefix && !key.startsWith(options.prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
keys.push({ name: key });
|
||||
}
|
||||
|
||||
return {
|
||||
keys,
|
||||
cursor: '0',
|
||||
list_complete: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const kvOrLocal = (namespace: KVNamespace | null): KVNamespace =>
|
||||
namespace || new EmulatedKV();
|
||||
|
||||
const self = (global as any) as Record<string, any>;
|
||||
|
||||
export const Sessions = new WrappedKVNamespace(kvOrLocal(self.KV_SESSIONS ?? null));
|
||||
export const GuildData = new WrappedKVNamespace(kvOrLocal(self.KV_GUILD_DATA ?? null));
|
||||
export const Guilds = new WrappedKVNamespace(kvOrLocal(self.KV_GUILDS ?? null));
|
28
packages/api/webpack.config.js
Normal file
28
packages/api/webpack.config.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
const path = require('path');
|
||||
|
||||
const mode = process.env.NODE_ENV || 'production';
|
||||
|
||||
module.exports = {
|
||||
target: 'webworker',
|
||||
entry: path.join(__dirname, 'index.ts'),
|
||||
output: {
|
||||
filename: `worker.${mode}.js`,
|
||||
path: path.join(__dirname, 'dist'),
|
||||
},
|
||||
mode,
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
configFile: path.join(__dirname, 'tsconfig.json'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
15
packages/api/worker.config.js
Normal file
15
packages/api/worker.config.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
const reexportEnv = (keys = []) => {
|
||||
return keys.reduce((acc, key) => ({ ...acc, [key]: process.env[key] }), {});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
environment: reexportEnv([
|
||||
'BOT_CLIENT_ID',
|
||||
'BOT_CLIENT_SECRET',
|
||||
'BOT_TOKEN',
|
||||
'UI_PUBLIC_URI',
|
||||
'API_PUBLIC_URI',
|
||||
'ROOT_USERS',
|
||||
]),
|
||||
kv: ['KV_SESSIONS', 'KV_GUILDS', 'KV_GUILD_DATA'],
|
||||
};
|
102
packages/backend-emulator/kv.js
Normal file
102
packages/backend-emulator/kv.js
Normal file
|
@ -0,0 +1,102 @@
|
|||
const level = require('level');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
let hasWarned = false;
|
||||
|
||||
const getConversion = {
|
||||
text: (x) => x,
|
||||
json: (x) => JSON.parse(x),
|
||||
arrayBuffer: (x) => Buffer.from(x).buffer,
|
||||
stream: (x) => Buffer.from(x),
|
||||
};
|
||||
|
||||
class KVShim {
|
||||
constructor(namespace) {
|
||||
this.namespace = namespace;
|
||||
|
||||
fs.mkdirSync(path.resolve(__dirname, '../../.devdbs'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
this.level = level(path.resolve(__dirname, '../../.devdbs', namespace));
|
||||
})();
|
||||
}
|
||||
|
||||
makeValue(value, expirationTtl) {
|
||||
if (!expirationTtl) {
|
||||
return JSON.stringify({
|
||||
value,
|
||||
expires: false,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
value,
|
||||
expires: Date.now() + 1000 * expirationTtl,
|
||||
});
|
||||
}
|
||||
|
||||
validate(value) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.expires && value.expires < Date.now()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async get(key, type = 'text') {
|
||||
try {
|
||||
const result = JSON.parse(await this.level.get(key));
|
||||
|
||||
if (!this.validate(result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getConversion[type](result.value);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getWithMetadata(key, type) {
|
||||
return {
|
||||
value: await this.get(key, type),
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
async put(key, value, { expirationTtl, expiration, metadata }) {
|
||||
if ((expiration || metadata) && !hasWarned) {
|
||||
console.warn(
|
||||
'expiration and metadata is lost in the emulator. Use expirationTtl, please.'
|
||||
);
|
||||
hasWarned = true;
|
||||
}
|
||||
|
||||
return await this.level.put(key, this.makeValue(value, expirationTtl));
|
||||
}
|
||||
|
||||
// This loses scope for some unknown reason
|
||||
delete = async (key) => {
|
||||
return this.level.del(key);
|
||||
};
|
||||
|
||||
list() {
|
||||
console.warn('List is frowned upon and will fail to fetch keys in the emulator.');
|
||||
return {
|
||||
keys: [],
|
||||
cursor: '0',
|
||||
list_complete: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KVShim,
|
||||
};
|
195
packages/backend-emulator/main.js
Normal file
195
packages/backend-emulator/main.js
Normal file
|
@ -0,0 +1,195 @@
|
|||
const path = require('path');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
|
||||
const vm = require('vm');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const chokidar = require('chokidar');
|
||||
const webpack = require('webpack');
|
||||
const { Crypto } = require('@peculiar/webcrypto');
|
||||
const { KVShim } = require('./kv');
|
||||
const crypto = new Crypto();
|
||||
const fetch = require('node-fetch');
|
||||
const args = require('minimist')(process.argv.slice(2));
|
||||
|
||||
const basePath = args.basePath;
|
||||
if (!basePath) {
|
||||
throw new Error('--basePath is not set.');
|
||||
}
|
||||
|
||||
const workerConfig = require(`${basePath}/worker.config.js`);
|
||||
|
||||
const getKVs = (namespaces = []) =>
|
||||
namespaces.reduce((acc, ns) => ({ ...acc, [ns]: new KVShim(ns) }), {});
|
||||
|
||||
const workerShims = {
|
||||
...workerConfig.environment,
|
||||
...getKVs(workerConfig.kv),
|
||||
};
|
||||
|
||||
let listeners = [];
|
||||
|
||||
let isResponseConstructorAllowed = false;
|
||||
|
||||
/**
|
||||
* SafeResponse wraps a fetch Response to yell loudly if constructed at an unsafe time.
|
||||
* Cloudflare will reject all Response objects that aren't created during a request, so no pre-generation is allowed.
|
||||
*/
|
||||
class SafeResponse extends fetch.Response {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
if (!isResponseConstructorAllowed) {
|
||||
throw new Error(
|
||||
'Response object created outside of request context. This will be rejected by Cloudflare.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const context = () =>
|
||||
vm.createContext(
|
||||
{
|
||||
addEventListener: (a, fn) => {
|
||||
if (a === 'fetch') {
|
||||
console.log('addEventListeners: added fetch');
|
||||
listeners.push(fn);
|
||||
}
|
||||
},
|
||||
Response: SafeResponse,
|
||||
URL: URL,
|
||||
crypto: crypto,
|
||||
setTimeout: setTimeout,
|
||||
setInterval: setInterval,
|
||||
clearInterval: clearInterval,
|
||||
clearTimeout: clearTimeout,
|
||||
fetch: fetch,
|
||||
console: console,
|
||||
...workerShims,
|
||||
},
|
||||
{
|
||||
codeGeneration: {
|
||||
strings: false,
|
||||
wasm: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const event = {
|
||||
respondWith: async (value) => {
|
||||
const timeStart = Date.now();
|
||||
let loggedStatus;
|
||||
try {
|
||||
const response = await value;
|
||||
if (!response) {
|
||||
throw new Error(
|
||||
`response was invalid, got ${JSON.stringify(response)}`
|
||||
);
|
||||
}
|
||||
res.statusCode = response.status;
|
||||
loggedStatus = String(response.status);
|
||||
response.headers.forEach((value, key) => res.setHeader(key, value));
|
||||
res.end(response.body);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.statusCode = 500;
|
||||
loggedStatus = '500';
|
||||
res.end(JSON.stringify({ error: 'internal server error' }));
|
||||
}
|
||||
const timeEnd = Date.now();
|
||||
console.log(
|
||||
`${loggedStatus} [${timeEnd - timeStart}ms] - ${req.method} ${req.url}`
|
||||
);
|
||||
isResponseConstructorAllowed = false;
|
||||
},
|
||||
request: new fetch.Request(
|
||||
new URL(`http://${req.headers.host || 'localhost'}${req.url}`),
|
||||
{
|
||||
body: ['GET', 'HEAD'].includes(req.method) ? undefined : req,
|
||||
headers: req.headers,
|
||||
method: req.method,
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
event.request.headers.set('cf-client-ip', req.connection.remoteAddress);
|
||||
|
||||
if (listeners.length === 0) {
|
||||
res.statusCode = 503;
|
||||
res.end('No handlers are available.');
|
||||
console.error('No handlers are available');
|
||||
return;
|
||||
}
|
||||
|
||||
isResponseConstructorAllowed = true;
|
||||
for (let listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (e) {
|
||||
console.error('listener errored', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fork = async (fn) => fn();
|
||||
|
||||
const reload = () => {
|
||||
// Clear listeners...
|
||||
listeners = [];
|
||||
|
||||
// Fork and re-run
|
||||
fork(async () =>
|
||||
vm.runInContext(
|
||||
fs.readFileSync(path.resolve(__dirname, `${basePath}/dist/worker.js`)),
|
||||
context(),
|
||||
{
|
||||
displayErrors: true,
|
||||
filename: 'worker.js',
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const rebuild = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const webpackConfig = require(`${basePath}/webpack.config.js`);
|
||||
webpackConfig.output.filename = 'worker.js';
|
||||
webpack(webpackConfig).run((err, stats) => {
|
||||
if (err) {
|
||||
console.log('Compilation failed.', err);
|
||||
reject(err);
|
||||
} else {
|
||||
if (stats.hasErrors()) {
|
||||
console.error('Compilation errored:', stats.compilation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Compilation done.');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const watcher = chokidar.watch(path.resolve(__dirname, basePath), {
|
||||
ignoreInitial: true,
|
||||
ignore: '**/dist',
|
||||
});
|
||||
|
||||
watcher.on('all', async (type, path) => {
|
||||
if (path.includes('dist')) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('change detected, rebuilding and reloading', { type, path });
|
||||
|
||||
await rebuild();
|
||||
reload();
|
||||
});
|
||||
|
||||
fork(async () => {
|
||||
await rebuild();
|
||||
reload();
|
||||
});
|
||||
|
||||
console.log('starting on http://localhost:6609');
|
||||
server.listen(6609, '0.0.0.0');
|
17
packages/backend-emulator/package.json
Normal file
17
packages/backend-emulator/package.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@roleypoly/worker-emulator",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "node main.js --build",
|
||||
"start": "node main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@peculiar/webcrypto": "^1.1.6",
|
||||
"chokidar": "^3.5.1",
|
||||
"dotenv": "^8.2.0",
|
||||
"level": "^6.0.1",
|
||||
"minimist": "^1.2.5",
|
||||
"node-fetch": "^2.6.1",
|
||||
"webpack": "^4.x"
|
||||
}
|
||||
}
|
8
packages/design-system/.storybook/main.js
Normal file
8
packages/design-system/.storybook/main.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
const path = require('path');
|
||||
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
|
||||
const { NormalModuleReplacementPlugin } = require('webpack');
|
||||
|
||||
module.exports = {
|
||||
stories: ['../**/*.stories.mdx', '../**/*.stories.@(js|jsx|ts|tsx)'],
|
||||
addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
|
||||
};
|
6
packages/design-system/.storybook/manager.js
Normal file
6
packages/design-system/.storybook/manager.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { addons } from '@storybook/addons';
|
||||
import { roleypolyTheme } from './theme';
|
||||
|
||||
addons.setConfig({
|
||||
theme: roleypolyTheme,
|
||||
});
|
8
packages/design-system/.storybook/mocks/next_link.tsx
Normal file
8
packages/design-system/.storybook/mocks/next_link.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import * as React from 'react';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
const Link = (props: Props) => <>{props.children}</>;
|
||||
|
||||
export default Link;
|
39
packages/design-system/.storybook/preview-head.html
Normal file
39
packages/design-system/.storybook/preview-head.html
Normal file
|
@ -0,0 +1,39 @@
|
|||
<script>
|
||||
(function (d) {
|
||||
var config = {
|
||||
kitId: 'bck0pci',
|
||||
scriptTimeout: 3000,
|
||||
async: true,
|
||||
},
|
||||
h = d.documentElement,
|
||||
t = setTimeout(function () {
|
||||
h.className = h.className.replace(/\bwf-loading\b/g, '') + ' wf-inactive';
|
||||
}, config.scriptTimeout),
|
||||
tk = d.createElement('script'),
|
||||
f = false,
|
||||
s = d.getElementsByTagName('script')[0],
|
||||
a;
|
||||
h.className += ' wf-loading';
|
||||
tk.src = 'https://use.typekit.net/' + config.kitId + '.js';
|
||||
tk.async = true;
|
||||
tk.onload = tk.onreadystatechange = function () {
|
||||
a = this.readyState;
|
||||
if (f || (a && a != 'complete' && a != 'loaded')) return;
|
||||
f = true;
|
||||
clearTimeout(t);
|
||||
try {
|
||||
Typekit.load(config);
|
||||
} catch (e) {}
|
||||
};
|
||||
s.parentNode.insertBefore(tk, s);
|
||||
})(document);
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'source-han-sans-japanese', 'Source Sans Pro', sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol' !important;
|
||||
color: #f2efef;
|
||||
background-color: #453e3d;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
10
packages/design-system/.storybook/preview.js
Normal file
10
packages/design-system/.storybook/preview.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { roleypolyTheme } from './theme';
|
||||
import { mdxComponents } from '../atoms/typography/mdx';
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
docs: {
|
||||
theme: roleypolyTheme,
|
||||
components: mdxComponents,
|
||||
},
|
||||
};
|
34
packages/design-system/.storybook/theme.js
Normal file
34
packages/design-system/.storybook/theme.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { create } from '@storybook/theming';
|
||||
import { palette } from '../atoms/colors';
|
||||
|
||||
export const roleypolyTheme = create({
|
||||
base: 'dark',
|
||||
|
||||
colorPrimary: palette.green400,
|
||||
colorSecondary: palette.taupe200,
|
||||
|
||||
// UI
|
||||
appBg: palette.taupe300,
|
||||
appContentBg: palette.taupe200,
|
||||
appBorderColor: palette.taupe100,
|
||||
appBorderRadius: 0,
|
||||
|
||||
// Typography
|
||||
fontBase: 'system-ui, sans-serif',
|
||||
fontCode: 'monospace',
|
||||
|
||||
// Text colors
|
||||
textColor: palette.grey600,
|
||||
textInverseColor: palette.grey100,
|
||||
|
||||
// Toolbar default and active colors
|
||||
barTextColor: palette.taupe500,
|
||||
barSelectedColor: palette.taupe600,
|
||||
barBg: palette.taupe100,
|
||||
|
||||
// Form colors
|
||||
inputBg: 'rgba(0,0,0,0.24)',
|
||||
inputBorder: palette.taupe100,
|
||||
inputTextColor: palette.grey600,
|
||||
inputBorderRadius: 0,
|
||||
});
|
24
packages/design-system/Intro.stories.mdx
Normal file
24
packages/design-system/Intro.stories.mdx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
import { Logotype } from '@roleypoly/design-system/atoms/branding';
|
||||
import { Space } from '@roleypoly/design-system/atoms/space';
|
||||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
|
||||
<Meta title="Roleypoly Design System" />
|
||||
|
||||
<Logotype height="4em" circleFill={palette.taupe100} circleOuterFill={palette.taupe300} />
|
||||
<Space />
|
||||
|
||||
# Rapid UI
|
||||
|
||||
#### Roleypoly Design System
|
||||
|
||||
This is a tool for Roleypoly developers to design and show off UI
|
||||
components as they build them.
|
||||
|
||||
If you're interested in helping build Roleypoly, [please visit the GitHub project.][roleypoly]
|
||||
|
||||
All components here follow the [Atomic Design System][atomic], and might be used in any number of Roleypoly UI systems, not limited to
|
||||
just the end user web application.
|
||||
|
||||
[roleypoly]: https://github.com/roleypoly/roleypoly
|
||||
[atomic]: https://bradfrost.com/blog/post/atomic-web-design/
|
42
packages/design-system/README.md
Normal file
42
packages/design-system/README.md
Normal file
|
@ -0,0 +1,42 @@
|
|||
# Roleypoly Design System
|
||||
|
||||
Codename: **rapid**
|
||||
|
||||
The Roleypoly Design System (rapid) is an atomic design system to help rapidly and consistently build Roleypoly and related services. The color system in use is deliberately simple, and should be adhered to.
|
||||
|
||||
## Developing
|
||||
|
||||
**Please follow hermeticity considerations.**
|
||||
|
||||
This package cannot reference RPC types, as they do not exist in the outside world. Storybook is the core component of this, and Storybook doesn't know how to find RPC types at CI build time, as Bazel is also not present. If you are worried about RPC types being compatible, please write a unit test and include the RPC types then.
|
||||
|
||||
You need:
|
||||
|
||||
- `node` (lts or later)
|
||||
- `yarn` (v1.x)
|
||||
|
||||
Run:
|
||||
|
||||
- `yarn storybook` to get started.
|
||||
|
||||
## Atomic Design 101
|
||||
|
||||
Components are split into the following categories:
|
||||
|
||||
- **atoms**
|
||||
- smallest possible parts.
|
||||
- typically individual pieces, such as branding, layout macros, and style-wrapped native elements.
|
||||
- **molecules**
|
||||
- groups of atoms
|
||||
- typically these make up sections of major UI parts
|
||||
- **organisms**
|
||||
- groups of molecules
|
||||
- typically these are major UI parts
|
||||
- **templates**
|
||||
- groups of organisms
|
||||
- typically a full page, without data.
|
||||
- **pages**
|
||||
- _not covered by rapid_
|
||||
- routes data into templates.
|
||||
|
||||
This sort of layout works extremely well with Next.js, the UI toolkit within Roleypoly. You should also be able to develop most parts, up until pages, directly within Storybook.
|
34
packages/design-system/atoms/avatar/Avatar.stories.tsx
Normal file
34
packages/design-system/atoms/avatar/Avatar.stories.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import * as React from 'react';
|
||||
import { Avatar, AvatarProps } from './Avatar';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Avatar',
|
||||
component: Avatar,
|
||||
argTypes: {
|
||||
initials: { control: 'text' },
|
||||
},
|
||||
args: {
|
||||
initials: 'KR',
|
||||
hash: 'aa',
|
||||
},
|
||||
};
|
||||
|
||||
type StoryArgs = {
|
||||
initials?: string;
|
||||
} & AvatarProps;
|
||||
|
||||
export const WithInitials = ({ initials, ...rest }: StoryArgs) => (
|
||||
<Avatar src="https://i.imgur.com/epMSRQH.png" size={48} {...rest}>
|
||||
{initials}
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
export const WithText = ({ initials, ...rest }: StoryArgs) => (
|
||||
<Avatar size={48} {...rest}>
|
||||
{initials}
|
||||
</Avatar>
|
||||
);
|
||||
export const Empty = (args: StoryArgs) => <Avatar size={48} {...args}></Avatar>;
|
||||
export const DeliberatelyEmpty = (args: StoryArgs) => (
|
||||
<Avatar size={48} deliberatelyEmpty={true} {...args}></Avatar>
|
||||
);
|
44
packages/design-system/atoms/avatar/Avatar.styled.ts
Normal file
44
packages/design-system/atoms/avatar/Avatar.styled.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { AvatarProps } from './Avatar';
|
||||
|
||||
type ContainerProps = Pick<AvatarProps, 'size'> & Pick<AvatarProps, 'deliberatelyEmpty'>;
|
||||
export const Container = styled.div<ContainerProps>`
|
||||
border-radius: 100%;
|
||||
box-sizing: border-box;
|
||||
width: ${(props: ContainerProps) => props.size || 48}px;
|
||||
height: ${(props: ContainerProps) => props.size || 48}px;
|
||||
min-width: ${(props: ContainerProps) => props.size || 48}px;
|
||||
min-height: ${(props: ContainerProps) => props.size || 48}px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${palette.grey100};
|
||||
position: relative;
|
||||
background-color: ${palette.grey500};
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
font-size: ${(props: ContainerProps) => props.size};
|
||||
${(props) =>
|
||||
props.deliberatelyEmpty &&
|
||||
css`
|
||||
border: 4px solid rgba(0, 0, 0, 0.25);
|
||||
background-color: ${palette.taupe400};
|
||||
color: ${palette.taupe600};
|
||||
`}
|
||||
`;
|
||||
|
||||
type ImageProps = Pick<AvatarProps, 'src'>;
|
||||
export const Image = styled.div<ImageProps>`
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50% 50%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
border-radius: 100%;
|
||||
`;
|
29
packages/design-system/atoms/avatar/Avatar.tsx
Normal file
29
packages/design-system/atoms/avatar/Avatar.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { Container, Image } from './Avatar.styled';
|
||||
|
||||
export type AvatarProps = {
|
||||
src?: string;
|
||||
children?: string | React.ReactNode;
|
||||
size?: number;
|
||||
hash?: string;
|
||||
deliberatelyEmpty?: boolean;
|
||||
};
|
||||
|
||||
/** Chuldren is recommended to not be larger than 2 uppercase letters. */
|
||||
export const Avatar = (props: AvatarProps) => (
|
||||
<Container size={props.size} deliberatelyEmpty={props.deliberatelyEmpty}>
|
||||
{props.src && props.hash && (
|
||||
<Image
|
||||
style={{
|
||||
backgroundImage: `url(${props.src})`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{props.children || (
|
||||
/* needs specifically to prevent layout issues. */
|
||||
<> </>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
16
packages/design-system/atoms/avatar/avatarUtils.tsx
Normal file
16
packages/design-system/atoms/avatar/avatarUtils.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
export const initialsFromName = (name: string) =>
|
||||
!!name
|
||||
? name
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((x) => x[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
: '';
|
||||
|
||||
export const avatarHash = (
|
||||
id: string,
|
||||
hash: string,
|
||||
bucket: 'icons' | 'avatars' = 'icons',
|
||||
size: number = 256
|
||||
) => `https://cdn.discordapp.com/${bucket}/${id}/${hash}.webp?size=${size}`;
|
2
packages/design-system/atoms/avatar/index.ts
Normal file
2
packages/design-system/atoms/avatar/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './Avatar';
|
||||
export * as utils from './avatarUtils';
|
25
packages/design-system/atoms/branding/Branding.stories.tsx
Normal file
25
packages/design-system/atoms/branding/Branding.stories.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { palette } from '../colors';
|
||||
import { Logomark as BrandingLogomark, Logotype as BrandingLogotype } from './Branding';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Branding',
|
||||
};
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background-color: ${palette.taupe100};
|
||||
padding: 2em;
|
||||
`;
|
||||
|
||||
export const Logomark = () => (
|
||||
<Wrapper>
|
||||
<BrandingLogomark />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
export const Logotype = () => (
|
||||
<Wrapper>
|
||||
<BrandingLogotype />
|
||||
</Wrapper>
|
||||
);
|
154
packages/design-system/atoms/branding/Branding.tsx
Normal file
154
packages/design-system/atoms/branding/Branding.tsx
Normal file
|
@ -0,0 +1,154 @@
|
|||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import * as React from 'react';
|
||||
|
||||
export type LogoProps = {
|
||||
fill: string;
|
||||
width: number;
|
||||
height: number;
|
||||
circleFill: string;
|
||||
circleOuterFill: string;
|
||||
typeFill: string;
|
||||
style: object;
|
||||
className: string;
|
||||
'data-for'?: string;
|
||||
'data-tip'?: string;
|
||||
};
|
||||
|
||||
export const Logotype = ({
|
||||
typeFill = palette.taupe400,
|
||||
circleFill = palette.red200,
|
||||
circleOuterFill = palette.green200,
|
||||
...props
|
||||
}: Partial<LogoProps>) => (
|
||||
<svg
|
||||
style={props.style}
|
||||
className={props.className}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
data-for={props['data-for']}
|
||||
data-tip={props['data-tip']}
|
||||
viewBox="45 25 400 88"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0)">
|
||||
<path
|
||||
d="M179.855 95.49V96H170.845L154.95 74.495H146.79V96H138.97V40.92H156.905C161.212 40.92 164.838 41.6 167.785 42.96C170.788 44.32 173.027 46.2183 174.5 48.655C176.03 51.0917 176.795 53.925 176.795 57.155C176.795 59.7617 176.285 62.17 175.265 64.38C174.302 66.59 172.828 68.5167 170.845 70.16C168.862 71.7467 166.397 72.9083 163.45 73.645L179.855 95.49ZM146.79 68.035H155.715C159.965 68.035 163.167 67.1283 165.32 65.315C167.53 63.445 168.635 60.8383 168.635 57.495C168.635 54.265 167.615 51.8 165.575 50.1C163.592 48.4 160.645 47.55 156.735 47.55H146.79V68.035Z"
|
||||
fill={typeFill}
|
||||
/>
|
||||
<path
|
||||
d="M197.154 97.02C193.188 97.02 189.873 96.17 187.209 94.47C184.546 92.7133 182.563 90.3617 181.259 87.415C179.956 84.4117 179.304 81.04 179.304 77.3C179.304 72.7667 180.154 69.055 181.854 66.165C183.554 63.2183 185.849 61.0933 188.739 59.79C191.629 58.43 194.916 57.75 198.599 57.75C202.566 57.75 205.881 58.6283 208.544 60.385C211.208 62.085 213.191 64.4083 214.494 67.355C215.798 70.3017 216.449 73.645 216.449 77.385C216.449 81.975 215.599 85.7433 213.899 88.69C212.199 91.58 209.904 93.705 207.014 95.065C204.124 96.3683 200.838 97.02 197.154 97.02ZM197.834 91.07C205.144 91.07 208.799 86.48 208.799 77.3C208.799 73.1633 207.893 69.8767 206.079 67.44C204.323 64.9467 201.659 63.7 198.089 63.7C194.123 63.7 191.261 64.9183 189.504 67.355C187.804 69.735 186.954 73.05 186.954 77.3C186.954 81.4933 187.861 84.8367 189.674 87.33C191.488 89.8233 194.208 91.07 197.834 91.07Z"
|
||||
fill={typeFill}
|
||||
/>
|
||||
<path d="M221.961 96V37.52L229.271 36.67V96H221.961Z" fill={typeFill} />
|
||||
<path
|
||||
d="M268.08 78.66H242.155C242.212 82.91 243.232 85.9983 245.215 87.925C247.255 89.8517 249.862 90.815 253.035 90.815C255.302 90.815 257.257 90.5317 258.9 89.965C260.6 89.3983 262.47 88.52 264.51 87.33L267.315 92.515C262.669 95.5183 257.795 97.02 252.695 97.02C247.255 97.02 242.892 95.5183 239.605 92.515C236.375 89.455 234.76 84.6383 234.76 78.065C234.76 74.325 235.44 70.925 236.8 67.865C238.16 64.805 240.172 62.3683 242.835 60.555C245.555 58.685 248.87 57.75 252.78 57.75C256.067 57.75 258.872 58.43 261.195 59.79C263.519 61.0933 265.275 62.9067 266.465 65.23C267.655 67.5533 268.25 70.2167 268.25 73.22L268.08 78.66ZM252.78 63.445C249.834 63.445 247.51 64.3233 245.81 66.08C244.167 67.8367 243.09 70.245 242.58 73.305H261.11V72.965C261.11 69.7917 260.374 67.4117 258.9 65.825C257.484 64.2383 255.444 63.445 252.78 63.445Z"
|
||||
fill={typeFill}
|
||||
/>
|
||||
<path
|
||||
d="M305.129 58.77V59.28L290.934 98.635C289.687 102.092 288.356 104.84 286.939 106.88C285.522 108.977 283.907 110.478 282.094 111.385C280.337 112.348 278.241 112.83 275.804 112.83C273.311 112.83 270.846 112.405 268.409 111.555L270.364 105.945C272.007 106.455 273.622 106.71 275.209 106.71C276.512 106.71 277.617 106.512 278.524 106.115C279.431 105.718 280.309 104.953 281.159 103.82C282.066 102.687 282.944 100.987 283.794 98.72L284.814 96H282.859L268.579 59.28V58.77H276.314L285.069 82.57L286.004 85.12L287.364 89.115L289.489 82.57L297.649 58.77H305.129Z"
|
||||
fill={typeFill}
|
||||
/>
|
||||
<path
|
||||
d="M327.505 57.75C331.018 57.75 333.936 58.6 336.26 60.3C338.583 62 340.283 64.2667 341.36 67.1C342.493 69.9333 343.06 73.0217 343.06 76.365C343.06 80.6717 342.21 84.3833 340.51 87.5C338.866 90.6167 336.6 92.9967 333.71 94.64C330.82 96.2267 327.533 97.02 323.85 97.02C321.073 97.02 318.381 96.6517 315.775 95.915V111.81H308.465V58.77L315.775 57.92V61.235C317.815 60.1017 319.826 59.2517 321.81 58.685C323.85 58.0617 325.748 57.75 327.505 57.75ZM323.255 91.24C325.578 91.24 327.618 90.73 329.375 89.71C331.188 88.6333 332.605 86.99 333.625 84.78C334.701 82.57 335.24 79.8217 335.24 76.535C335.24 72.285 334.39 69.14 332.69 67.1C330.99 65.06 328.581 64.04 325.465 64.04C322.178 64.04 318.948 64.975 315.775 66.845V89.965C318.438 90.815 320.931 91.24 323.255 91.24Z"
|
||||
fill={typeFill}
|
||||
/>
|
||||
<path
|
||||
d="M363.436 97.02C359.469 97.02 356.154 96.17 353.491 94.47C350.827 92.7133 348.844 90.3617 347.541 87.415C346.237 84.4117 345.586 81.04 345.586 77.3C345.586 72.7667 346.436 69.055 348.136 66.165C349.836 63.2183 352.131 61.0933 355.021 59.79C357.911 58.43 361.197 57.75 364.881 57.75C368.847 57.75 372.162 58.6283 374.826 60.385C377.489 62.085 379.472 64.4083 380.776 67.355C382.079 70.3017 382.731 73.645 382.731 77.385C382.731 81.975 381.881 85.7433 380.181 88.69C378.481 91.58 376.186 93.705 373.296 95.065C370.406 96.3683 367.119 97.02 363.436 97.02ZM364.116 91.07C371.426 91.07 375.081 86.48 375.081 77.3C375.081 73.1633 374.174 69.8767 372.361 67.44C370.604 64.9467 367.941 63.7 364.371 63.7C360.404 63.7 357.542 64.9183 355.786 67.355C354.086 69.735 353.236 73.05 353.236 77.3C353.236 81.4933 354.142 84.8367 355.956 87.33C357.769 89.8233 360.489 91.07 364.116 91.07Z"
|
||||
fill={typeFill}
|
||||
/>
|
||||
<path d="M388.242 96V37.52L395.552 36.67V96H388.242Z" fill={typeFill} />
|
||||
<path
|
||||
d="M435.382 58.77V59.28L421.187 98.635C419.94 102.092 418.608 104.84 417.192 106.88C415.775 108.977 414.16 110.478 412.347 111.385C410.59 112.348 408.493 112.83 406.057 112.83C403.563 112.83 401.098 112.405 398.662 111.555L400.617 105.945C402.26 106.455 403.875 106.71 405.462 106.71C406.765 106.71 407.87 106.512 408.777 106.115C409.683 105.718 410.562 104.953 411.412 103.82C412.318 102.687 413.197 100.987 414.047 98.72L415.067 96H413.112L398.832 59.28V58.77H406.567L415.322 82.57L416.257 85.12L417.617 89.115L419.742 82.57L427.902 58.77H435.382Z"
|
||||
fill={typeFill}
|
||||
/>
|
||||
<mask
|
||||
id="mask0"
|
||||
mask-type="alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="45"
|
||||
y="25"
|
||||
width="142"
|
||||
height="142"
|
||||
>
|
||||
<rect
|
||||
x="115.711"
|
||||
y="25"
|
||||
width="100"
|
||||
height="100"
|
||||
transform="rotate(45 115.711 25)"
|
||||
fill="#C4C4C4"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<circle
|
||||
cx="79.9999"
|
||||
cy="60"
|
||||
r="46"
|
||||
fill={circleFill}
|
||||
stroke={circleOuterFill}
|
||||
strokeWidth="8"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="487" height="143" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Logomark = ({
|
||||
circleFill = palette.red200,
|
||||
circleOuterFill = palette.green200,
|
||||
...props
|
||||
}: Partial<LogoProps>) => (
|
||||
<svg
|
||||
data-for={props['data-for']}
|
||||
data-tip={props['data-tip']}
|
||||
style={props.style}
|
||||
className={props.className}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
viewBox="30 10 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0)">
|
||||
<mask
|
||||
id="mask0"
|
||||
mask-type="alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="45"
|
||||
y="25"
|
||||
width="142"
|
||||
height="142"
|
||||
>
|
||||
<rect
|
||||
x="115.711"
|
||||
y="25"
|
||||
width="100"
|
||||
height="100"
|
||||
transform="rotate(45 115.711 25)"
|
||||
fill="#C4C4C4"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<circle
|
||||
cx="79.9999"
|
||||
cy="60"
|
||||
r="46"
|
||||
fill={circleFill}
|
||||
stroke={circleOuterFill}
|
||||
strokeWidth="8"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="130" height="110" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
85
packages/design-system/atoms/branding/BrandingOld.tsx
Normal file
85
packages/design-system/atoms/branding/BrandingOld.tsx
Normal file
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,78 @@
|
|||
import { Text } from '@roleypoly/design-system/atoms/typography';
|
||||
import * as React from 'react';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import styled from 'styled-components';
|
||||
import { palette } from '../colors';
|
||||
import { Logomark, Logotype } from './Branding';
|
||||
import { AllVariants, DynamicLogomark, DynamicLogotype } from './DynamicBranding';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Branding/Dynamic',
|
||||
component: DynamicLogotype,
|
||||
};
|
||||
|
||||
const WrapperDiv = styled.div`
|
||||
background-color: ${palette.taupe100};
|
||||
padding: 2em;
|
||||
`;
|
||||
|
||||
const Wrapper = (props: { children: React.ReactNode }) => (
|
||||
<>
|
||||
<WrapperDiv>{props.children}</WrapperDiv>
|
||||
<ReactTooltip />
|
||||
</>
|
||||
);
|
||||
|
||||
export const dynamicLogotype = (args) => {
|
||||
return (
|
||||
<Wrapper>
|
||||
<DynamicLogotype {...args} />
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const dynamicLogomark = (args) => {
|
||||
return (
|
||||
<Wrapper>
|
||||
<DynamicLogomark {...args} />
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const AllCustomizedLogotypes = () => {
|
||||
return (
|
||||
<Wrapper>
|
||||
<div>
|
||||
<Text>Base Logo</Text>
|
||||
<br />
|
||||
<Logotype height={50} />
|
||||
</div>
|
||||
{AllVariants.map((variant, idx) => (
|
||||
<div key={idx} style={{ marginTop: 5 }}>
|
||||
<Text>{variant.name}</Text>
|
||||
<br />
|
||||
<variant.Logotype height={50} />
|
||||
</div>
|
||||
))}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const AllCustomizedLogomarks = () => {
|
||||
return (
|
||||
<Wrapper>
|
||||
<div>
|
||||
<Text>Base Logo</Text>
|
||||
<br />
|
||||
<Logomark height={50} />
|
||||
</div>
|
||||
{AllVariants.map((variant, idx) => (
|
||||
<div key={idx}>
|
||||
<Text>{variant.name}</Text>
|
||||
<br />
|
||||
<variant.Logomark height={50} />
|
||||
</div>
|
||||
))}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
427
packages/design-system/atoms/branding/DynamicBranding.tsx
Normal file
427
packages/design-system/atoms/branding/DynamicBranding.tsx
Normal file
|
@ -0,0 +1,427 @@
|
|||
import * as React from 'react';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import { palette } from '../colors';
|
||||
import { SparkleOverlay } from '../sparkle';
|
||||
import { Logomark, Logotype } from './Branding';
|
||||
import { LogoFlagProps, LogomarkFlag, LogotypeFlag } from './FlagBranding';
|
||||
|
||||
type DynamicLogoProps = LogoFlagProps & {
|
||||
currentDate?: Date;
|
||||
};
|
||||
|
||||
export const DynamicLogomark = (props: Partial<DynamicLogoProps>) => {
|
||||
const variant = React.useMemo(() => getRelevantVariant(props.currentDate), [
|
||||
props.currentDate,
|
||||
]);
|
||||
|
||||
if (!variant) {
|
||||
return <Logomark {...props} />;
|
||||
}
|
||||
|
||||
let tooltipProps = {};
|
||||
if (variant.tooltip) {
|
||||
tooltipProps = {
|
||||
'data-tip': variant.tooltip,
|
||||
// 'data-for': 'dynamic-logomark',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<variant.Logomark {...tooltipProps} {...props} />
|
||||
<ReactTooltip id="dynamic-logomark" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DynamicLogotype = (props: Partial<DynamicLogoProps>) => {
|
||||
const variant = React.useMemo(() => getRelevantVariant(props.currentDate), [
|
||||
props.currentDate,
|
||||
]);
|
||||
|
||||
if (!variant) {
|
||||
return <Logotype {...props} />;
|
||||
}
|
||||
|
||||
let tooltipProps = {};
|
||||
if (variant.tooltip) {
|
||||
tooltipProps = {
|
||||
'data-tip': variant.tooltip,
|
||||
// 'data-for': 'dynamic-logomark',
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<variant.Logotype {...tooltipProps} {...props} />
|
||||
<ReactTooltip id="dynamic-logomark" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getRelevantVariant = (currentDate?: Date) => {
|
||||
for (let variant of AllVariants) {
|
||||
if (variant.activeIf(currentDate)) return variant;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// These should be updated for 2021.
|
||||
// Please feel free to update with a PR for 2022 or any missing weeks within 2021.
|
||||
// Rules:
|
||||
// - common law holidays should have at least 2 extra days before and after
|
||||
// - pride days should be 1 extra day before and after
|
||||
// - weeks/months should have no buffer
|
||||
// - 4/20 is just for 4/20.
|
||||
|
||||
const matchDay = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
currentDate: Date = new Date(),
|
||||
staticDate: boolean = false
|
||||
) => {
|
||||
if (!staticDate) {
|
||||
// pre-fill start/end years to simplify
|
||||
start.setFullYear(currentDate.getFullYear());
|
||||
end.setFullYear(currentDate.getFullYear());
|
||||
}
|
||||
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
if (currentDate > start && currentDate < end) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
type Variant = {
|
||||
name: string;
|
||||
activeIf: (currentDate?: Date) => boolean;
|
||||
sharedProps?: Partial<DynamicLogoProps>;
|
||||
flagStripes?: string[];
|
||||
tooltip?: string;
|
||||
Logomark: React.FunctionComponent<any>;
|
||||
Logotype: React.FunctionComponent<any>;
|
||||
};
|
||||
|
||||
export const Trans: Variant = {
|
||||
// March 31, Nov 13-20+1
|
||||
name: 'Trans Pride',
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Mar-31'), new Date('2021-Apr-1'), currentDate) ||
|
||||
matchDay(new Date('2021-Nov-13'), new Date('2021-Nov-22'), currentDate),
|
||||
sharedProps: {
|
||||
circleFill: '#F7A8B8',
|
||||
circleOuterFill: palette.taupe200,
|
||||
typeFill: palette.grey500,
|
||||
stripes: ['#55CDFC', '#F7A8B8', palette.grey600, '#F7A8B8', '#55CDFC'],
|
||||
},
|
||||
tooltip: 'Roleypoly says trans rights!',
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<LogomarkFlag {...props} {...Trans.sharedProps} />
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<LogotypeFlag {...props} {...Trans.sharedProps} />
|
||||
),
|
||||
};
|
||||
|
||||
export const Bi: Variant = {
|
||||
// Sept 16-23
|
||||
name: 'Bi Week',
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Sep-16'), new Date('2021-Sep-24'), currentDate),
|
||||
sharedProps: {
|
||||
circleFill: '#D60270',
|
||||
circleOuterFill: palette.taupe200,
|
||||
typeFill: '#9B4F96',
|
||||
stripes: ['#0038A8', '#0038A8', '#9B4F96', '#D60270', '#D60270'],
|
||||
},
|
||||
tooltip: 'Being bi is a lot like a riding a bicycle since they can go both ways.',
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<LogomarkFlag {...props} {...Bi.sharedProps} />
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<LogotypeFlag {...props} {...Bi.sharedProps} />
|
||||
),
|
||||
};
|
||||
|
||||
export const Lesbian: Variant = {
|
||||
// Apr 26
|
||||
name: 'Lesbian Pride',
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Apr-25'), new Date('2021-Apt-27'), currentDate),
|
||||
sharedProps: {
|
||||
circleFill: '#D362A4',
|
||||
circleOuterFill: palette.taupe200,
|
||||
typeFill: '#FF9A56',
|
||||
stripes: ['#D52D00', '#FF9A56', palette.grey600, '#D362A4', '#A30262'],
|
||||
},
|
||||
tooltip: "I'm a lesbiab... lesbiam... Less Bien... Girls.",
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<LogomarkFlag {...props} {...Lesbian.sharedProps} />
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<LogotypeFlag {...props} {...Lesbian.sharedProps} />
|
||||
),
|
||||
};
|
||||
|
||||
export const Ace: Variant = {
|
||||
// Oct 24-30
|
||||
name: 'Ace Week',
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Oct-24'), new Date('2021-Oct-31'), currentDate),
|
||||
sharedProps: {
|
||||
circleFill: '#333',
|
||||
circleOuterFill: palette.taupe200,
|
||||
typeFill: '#CCC',
|
||||
stripes: ['#84067C', palette.grey600, '#CCCCCC', palette.grey100],
|
||||
},
|
||||
tooltip: "Sexualn't",
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<LogomarkFlag {...props} {...Ace.sharedProps} />
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<LogotypeFlag {...props} {...Ace.sharedProps} />
|
||||
),
|
||||
};
|
||||
|
||||
export const Birthday: Variant = {
|
||||
// Jan 15
|
||||
name: "Roleypoly's Birthday",
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Jan-15'), new Date('2021-Jan-16'), currentDate),
|
||||
sharedProps: {
|
||||
circleFill: 'none',
|
||||
circleOuterFill: palette.taupe300,
|
||||
typeFill: palette.taupe500,
|
||||
},
|
||||
tooltip: '🎉 HAPPY BIRTHDAY ROLEYPOLY 🎉',
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<svg
|
||||
style={props.style}
|
||||
className={props.className}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
data-for={props['data-for']}
|
||||
data-tip={props['data-tip']}
|
||||
viewBox="30 10 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0)">
|
||||
<path
|
||||
d="M104.737 85.406V86H94.243L75.73 60.953H66.226V86H57.118V21.848H78.007C83.023 21.848 87.247 22.64 90.679 24.224C94.177 25.808 96.784 28.019 98.5 30.857C100.282 33.695 101.173 36.995 101.173 40.757C101.173 43.793 100.579 46.598 99.391 49.172C98.269 51.746 96.553 53.99 94.243 55.904C91.933 57.752 89.062 59.105 85.63 59.963L104.737 85.406ZM66.226 53.429H76.621C81.571 53.429 85.3 52.373 87.808 50.261C90.382 48.083 91.669 45.047 91.669 41.153C91.669 37.391 90.481 34.52 88.105 32.54C85.795 30.56 82.363 29.57 77.809 29.57H66.226V53.429Z"
|
||||
fill={props.typeFill || Birthday.sharedProps?.typeFill}
|
||||
/>
|
||||
|
||||
<mask
|
||||
id="mask0"
|
||||
mask-type="alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="45"
|
||||
y="25"
|
||||
width="142"
|
||||
height="142"
|
||||
>
|
||||
<path
|
||||
d="M115.711 25L186.421 95.7107L115.711 166.421L45 95.7107L115.711 25Z"
|
||||
fill={
|
||||
props.circleOuterFill || Birthday.sharedProps?.circleOuterFill
|
||||
}
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M79.9998 102C103.196 102 122 83.196 122 60C122 36.804 103.196 18 79.9998 18C56.8039 18 37.9998 36.804 37.9998 60C37.9998 83.196 56.8039 102 79.9998 102ZM79.9998 110C107.614 110 130 87.6142 130 60C130 32.3858 107.614 10 79.9998 10C52.3856 10 29.9998 32.3858 29.9998 60C29.9998 87.6142 52.3856 110 79.9998 110Z"
|
||||
fill={
|
||||
props.circleOuterFill || Birthday.sharedProps?.circleOuterFill
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="130" height="110" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<div style={{ display: 'inline-block', maxWidth: props.width }}>
|
||||
<SparkleOverlay strokeColor={palette.discord400}>
|
||||
<Logotype {...props} {...Birthday.sharedProps} />
|
||||
</SparkleOverlay>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const DevilsLettuce: Variant = {
|
||||
name: 'Meme #1',
|
||||
// Apr 20
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Apr-20'), new Date('2021-Apr-21'), currentDate),
|
||||
sharedProps: {
|
||||
circleFill: palette.green400,
|
||||
circleOuterFill: palette.green200,
|
||||
typeFill: palette.green400,
|
||||
},
|
||||
tooltip: 'Legalize it.',
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<Logomark {...props} {...DevilsLettuce.sharedProps} />
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<Logotype {...props} {...DevilsLettuce.sharedProps} />
|
||||
),
|
||||
};
|
||||
|
||||
export const BicycleDay: Variant = {
|
||||
name: 'Meme #2',
|
||||
// Apr 19
|
||||
// TODO: hexagon is bestagon
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Apr-19'), new Date('2021-Apr-20'), currentDate),
|
||||
sharedProps: {
|
||||
circleFill: palette.gold400,
|
||||
circleOuterFill: palette.taupe200,
|
||||
typeFill: palette.discord400,
|
||||
stripes: Object.values(palette),
|
||||
},
|
||||
tooltip: 'It increases brain complexity.',
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<LogomarkFlag {...props} {...BicycleDay.sharedProps} />
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<LogotypeFlag {...props} {...BicycleDay.sharedProps} />
|
||||
),
|
||||
};
|
||||
|
||||
export const Christmas: Variant = {
|
||||
name: 'Christmas!',
|
||||
// Dec 20-27
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Dec-20'), new Date('2021-Dec-28'), currentDate),
|
||||
sharedProps: {
|
||||
circleFill: palette.green200,
|
||||
circleOuterFill: palette.red200,
|
||||
typeFill: palette.green400,
|
||||
stripes: [
|
||||
palette.grey600,
|
||||
palette.red400,
|
||||
palette.grey600,
|
||||
palette.green400,
|
||||
palette.grey600,
|
||||
palette.red400,
|
||||
palette.grey600,
|
||||
palette.green400,
|
||||
palette.grey600,
|
||||
palette.red400,
|
||||
palette.grey600,
|
||||
],
|
||||
},
|
||||
tooltip: 'Have yourself a merry little Christmas~',
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<SparkleOverlay strokeColor={'white'}>
|
||||
<LogomarkFlag {...props} {...Christmas.sharedProps} />
|
||||
</SparkleOverlay>
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<SparkleOverlay strokeColor={'white'}>
|
||||
<LogotypeFlag {...props} {...Christmas.sharedProps} />
|
||||
</SparkleOverlay>
|
||||
),
|
||||
};
|
||||
|
||||
export const NewYear: Variant = {
|
||||
name: "New Year's Day",
|
||||
// Dec 30 - Jan 2
|
||||
// TODO: sparkle
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Dec-30'), new Date('2021-Jan-3'), currentDate),
|
||||
sharedProps: {
|
||||
circleFill: '#222',
|
||||
circleOuterFill: palette.red400,
|
||||
typeFill: '#aaa',
|
||||
},
|
||||
tooltip: 'Fuck 2020. 🎆🎇🎆🎇',
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<Logomark {...props} {...NewYear.sharedProps} />
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<div style={{ display: 'inline-block', maxWidth: props.width }}>
|
||||
<SparkleOverlay>
|
||||
<Logotype {...props} {...NewYear.sharedProps} />
|
||||
</SparkleOverlay>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const LunarNewYear: Variant = {
|
||||
name: 'Lunar New Year',
|
||||
// Feb 12, 2021
|
||||
// Feb 1, 2022
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Feb-10'), new Date('2021-Feb-13'), currentDate, true) ||
|
||||
matchDay(new Date('2022-Jan-30'), new Date('2022-Feb-3'), currentDate, true),
|
||||
sharedProps: {
|
||||
circleFill: palette.red200,
|
||||
circleOuterFill: palette.gold400,
|
||||
typeFill: palette.taupe300,
|
||||
},
|
||||
tooltip: '恭喜发财! 🎊🎆🎇',
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<Logomark {...props} {...LunarNewYear.sharedProps} />
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<div style={{ display: 'inline-block', maxWidth: props.width }}>
|
||||
<SparkleOverlay>
|
||||
<Logotype {...props} {...LunarNewYear.sharedProps} />
|
||||
</SparkleOverlay>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Pride: Variant = {
|
||||
name: 'LGBTQPOC Pride Month',
|
||||
// June
|
||||
activeIf: (currentDate?: Date) =>
|
||||
matchDay(new Date('2021-Jun-1'), new Date('2021-Jul-1'), currentDate),
|
||||
sharedProps: {
|
||||
circleOuterFill: palette.taupe200,
|
||||
typeFill: palette.grey500,
|
||||
stripes: [
|
||||
'#593BB5',
|
||||
'#26B186',
|
||||
'#FFC468',
|
||||
'#F97C21',
|
||||
'#F62C8B',
|
||||
'#8B5950',
|
||||
'#2D3234',
|
||||
],
|
||||
},
|
||||
tooltip: 'LOVE WINS. 💖🌈🌟',
|
||||
Logomark: (props: DynamicLogoProps) => (
|
||||
<LogomarkFlag {...props} {...Pride.sharedProps} />
|
||||
),
|
||||
Logotype: (props: DynamicLogoProps) => (
|
||||
<LogotypeFlag {...props} {...Pride.sharedProps} />
|
||||
),
|
||||
};
|
||||
|
||||
export const AllVariants: Variant[] = [
|
||||
Trans,
|
||||
Pride,
|
||||
Bi,
|
||||
Lesbian,
|
||||
Ace,
|
||||
Birthday,
|
||||
DevilsLettuce,
|
||||
BicycleDay,
|
||||
Christmas,
|
||||
NewYear,
|
||||
LunarNewYear,
|
||||
];
|
|
@ -0,0 +1,13 @@
|
|||
import { LogoFlagProps, LogomarkFlag, LogotypeFlag } from './FlagBranding';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Branding/Flags',
|
||||
component: LogomarkFlag,
|
||||
args: {
|
||||
stripes: ['#F9238B', '#FB7B04', '#FFCA66', '#00B289', '#5A38B5', '#B413F5'],
|
||||
height: 50,
|
||||
},
|
||||
};
|
||||
|
||||
export const logomarkFlag = (args: LogoFlagProps) => <LogomarkFlag {...args} />;
|
||||
export const logotypeFlag = (args: LogoFlagProps) => <LogotypeFlag {...args} />;
|
192
packages/design-system/atoms/branding/FlagBranding.tsx
Normal file
192
packages/design-system/atoms/branding/FlagBranding.tsx
Normal file
|
@ -0,0 +1,192 @@
|
|||
import * as React from 'react';
|
||||
import { palette } from '../colors';
|
||||
import { LogoProps } from './Branding';
|
||||
|
||||
export type LogoFlagProps = LogoProps & {
|
||||
stripes: string[];
|
||||
};
|
||||
|
||||
export const generateStripes = (stripes: string[]) => {
|
||||
const barWidth = 100 / stripes.length;
|
||||
return (
|
||||
<g transform="scale(0.9) rotate(-45 65 65) ">
|
||||
{stripes.map((stripeFill, idx) => (
|
||||
<rect
|
||||
key={idx}
|
||||
x={30 + idx * barWidth}
|
||||
y={30}
|
||||
width={barWidth + 1}
|
||||
height={100}
|
||||
fill={stripeFill}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export const LogomarkFlag = (props: LogoFlagProps) => (
|
||||
<svg
|
||||
style={props.style}
|
||||
className={props.className}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
data-for={props['data-for']}
|
||||
data-tip={props['data-tip']}
|
||||
viewBox="30 10 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0)">
|
||||
<mask
|
||||
id="mask0"
|
||||
mask-type="alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="45"
|
||||
y="25"
|
||||
width="142"
|
||||
height="142"
|
||||
>
|
||||
<rect
|
||||
x="115.711"
|
||||
y="25"
|
||||
width="100"
|
||||
height="100"
|
||||
transform="rotate(45 115.711 25)"
|
||||
fill="#C4C4C4"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<circle
|
||||
cx="79.9999"
|
||||
cy="60"
|
||||
r="46"
|
||||
fill="none"
|
||||
stroke="#1D8227"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<mask
|
||||
id="mask1"
|
||||
mask-type="alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="30"
|
||||
y="10"
|
||||
width="100"
|
||||
height="100"
|
||||
>
|
||||
<circle
|
||||
cx="80"
|
||||
cy="60"
|
||||
r="46"
|
||||
fill="#F14343"
|
||||
stroke="#1D8227"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask1)">
|
||||
{generateStripes(props.stripes)}
|
||||
<circle
|
||||
cx="80"
|
||||
cy="60"
|
||||
r="46"
|
||||
stroke={props.circleOuterFill || palette.green200}
|
||||
strokeWidth="8"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="130" height="110" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LogotypeFlag = (props: LogoFlagProps) => (
|
||||
<svg
|
||||
style={props.style}
|
||||
className={props.className}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
data-for={props['data-for']}
|
||||
data-tip={props['data-tip']}
|
||||
viewBox="45 25 400 88"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0)">
|
||||
<g fill={props.typeFill || palette.taupe400}>
|
||||
<path d="M179.855 95.49V96H170.845L154.95 74.495H146.79V96H138.97V40.92H156.905C161.212 40.92 164.838 41.6 167.785 42.96C170.788 44.32 173.027 46.2183 174.5 48.655C176.03 51.0917 176.795 53.925 176.795 57.155C176.795 59.7617 176.285 62.17 175.265 64.38C174.302 66.59 172.828 68.5167 170.845 70.16C168.862 71.7467 166.397 72.9083 163.45 73.645L179.855 95.49ZM146.79 68.035H155.715C159.965 68.035 163.167 67.1283 165.32 65.315C167.53 63.445 168.635 60.8383 168.635 57.495C168.635 54.265 167.615 51.8 165.575 50.1C163.592 48.4 160.645 47.55 156.735 47.55H146.79V68.035Z" />
|
||||
<path d="M197.154 97.02C193.188 97.02 189.873 96.17 187.209 94.47C184.546 92.7133 182.563 90.3617 181.259 87.415C179.956 84.4117 179.304 81.04 179.304 77.3C179.304 72.7667 180.154 69.055 181.854 66.165C183.554 63.2183 185.849 61.0933 188.739 59.79C191.629 58.43 194.916 57.75 198.599 57.75C202.566 57.75 205.881 58.6283 208.544 60.385C211.208 62.085 213.191 64.4083 214.494 67.355C215.798 70.3017 216.449 73.645 216.449 77.385C216.449 81.975 215.599 85.7433 213.899 88.69C212.199 91.58 209.904 93.705 207.014 95.065C204.124 96.3683 200.838 97.02 197.154 97.02ZM197.834 91.07C205.144 91.07 208.799 86.48 208.799 77.3C208.799 73.1633 207.893 69.8767 206.079 67.44C204.323 64.9467 201.659 63.7 198.089 63.7C194.123 63.7 191.261 64.9183 189.504 67.355C187.804 69.735 186.954 73.05 186.954 77.3C186.954 81.4933 187.861 84.8367 189.674 87.33C191.488 89.8233 194.208 91.07 197.834 91.07Z" />
|
||||
<path d="M221.961 96V37.52L229.271 36.67V96H221.961Z" />
|
||||
<path d="M268.08 78.66H242.155C242.212 82.91 243.232 85.9983 245.215 87.925C247.255 89.8517 249.862 90.815 253.035 90.815C255.302 90.815 257.257 90.5317 258.9 89.965C260.6 89.3983 262.47 88.52 264.51 87.33L267.315 92.515C262.669 95.5183 257.795 97.02 252.695 97.02C247.255 97.02 242.892 95.5183 239.605 92.515C236.375 89.455 234.76 84.6383 234.76 78.065C234.76 74.325 235.44 70.925 236.8 67.865C238.16 64.805 240.172 62.3683 242.835 60.555C245.555 58.685 248.87 57.75 252.78 57.75C256.067 57.75 258.872 58.43 261.195 59.79C263.519 61.0933 265.275 62.9067 266.465 65.23C267.655 67.5533 268.25 70.2167 268.25 73.22L268.08 78.66ZM252.78 63.445C249.834 63.445 247.51 64.3233 245.81 66.08C244.167 67.8367 243.09 70.245 242.58 73.305H261.11V72.965C261.11 69.7917 260.374 67.4117 258.9 65.825C257.484 64.2383 255.444 63.445 252.78 63.445Z" />
|
||||
<path d="M305.129 58.77V59.28L290.934 98.635C289.687 102.092 288.356 104.84 286.939 106.88C285.522 108.977 283.907 110.478 282.094 111.385C280.337 112.348 278.241 112.83 275.804 112.83C273.311 112.83 270.846 112.405 268.409 111.555L270.364 105.945C272.007 106.455 273.622 106.71 275.209 106.71C276.512 106.71 277.617 106.512 278.524 106.115C279.431 105.718 280.309 104.953 281.159 103.82C282.066 102.687 282.944 100.987 283.794 98.72L284.814 96H282.859L268.579 59.28V58.77H276.314L285.069 82.57L286.004 85.12L287.364 89.115L289.489 82.57L297.649 58.77H305.129Z" />
|
||||
<path d="M327.505 57.75C331.018 57.75 333.936 58.6 336.26 60.3C338.583 62 340.283 64.2667 341.36 67.1C342.493 69.9333 343.06 73.0217 343.06 76.365C343.06 80.6717 342.21 84.3833 340.51 87.5C338.866 90.6167 336.6 92.9967 333.71 94.64C330.82 96.2267 327.533 97.02 323.85 97.02C321.073 97.02 318.381 96.6517 315.775 95.915V111.81H308.465V58.77L315.775 57.92V61.235C317.815 60.1017 319.826 59.2517 321.81 58.685C323.85 58.0617 325.748 57.75 327.505 57.75ZM323.255 91.24C325.578 91.24 327.618 90.73 329.375 89.71C331.188 88.6333 332.605 86.99 333.625 84.78C334.701 82.57 335.24 79.8217 335.24 76.535C335.24 72.285 334.39 69.14 332.69 67.1C330.99 65.06 328.581 64.04 325.465 64.04C322.178 64.04 318.948 64.975 315.775 66.845V89.965C318.438 90.815 320.931 91.24 323.255 91.24Z" />
|
||||
<path d="M363.436 97.02C359.469 97.02 356.154 96.17 353.491 94.47C350.827 92.7133 348.844 90.3617 347.541 87.415C346.237 84.4117 345.586 81.04 345.586 77.3C345.586 72.7667 346.436 69.055 348.136 66.165C349.836 63.2183 352.131 61.0933 355.021 59.79C357.911 58.43 361.197 57.75 364.881 57.75C368.847 57.75 372.162 58.6283 374.826 60.385C377.489 62.085 379.472 64.4083 380.776 67.355C382.079 70.3017 382.731 73.645 382.731 77.385C382.731 81.975 381.881 85.7433 380.181 88.69C378.481 91.58 376.186 93.705 373.296 95.065C370.406 96.3683 367.119 97.02 363.436 97.02ZM364.116 91.07C371.426 91.07 375.081 86.48 375.081 77.3C375.081 73.1633 374.174 69.8767 372.361 67.44C370.604 64.9467 367.941 63.7 364.371 63.7C360.404 63.7 357.542 64.9183 355.786 67.355C354.086 69.735 353.236 73.05 353.236 77.3C353.236 81.4933 354.142 84.8367 355.956 87.33C357.769 89.8233 360.489 91.07 364.116 91.07Z" />
|
||||
<path d="M388.242 96V37.52L395.552 36.67V96H388.242Z" />
|
||||
<path d="M435.382 58.77V59.28L421.187 98.635C419.94 102.092 418.608 104.84 417.192 106.88C415.775 108.977 414.16 110.478 412.347 111.385C410.59 112.348 408.493 112.83 406.057 112.83C403.563 112.83 401.098 112.405 398.662 111.555L400.617 105.945C402.26 106.455 403.875 106.71 405.462 106.71C406.765 106.71 407.87 106.512 408.777 106.115C409.683 105.718 410.562 104.953 411.412 103.82C412.318 102.687 413.197 100.987 414.047 98.72L415.067 96H413.112L398.832 59.28V58.77H406.567L415.322 82.57L416.257 85.12L417.617 89.115L419.742 82.57L427.902 58.77H435.382Z" />
|
||||
</g>
|
||||
<mask
|
||||
id="mask0"
|
||||
mask-type="alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="45"
|
||||
y="25"
|
||||
width="142"
|
||||
height="142"
|
||||
>
|
||||
<rect
|
||||
x="115.711"
|
||||
y="25"
|
||||
width="100"
|
||||
height="100"
|
||||
transform="rotate(45 115.711 25)"
|
||||
fill="#C4C4C4"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<circle
|
||||
cx="79.9999"
|
||||
cy="60"
|
||||
r="46"
|
||||
fill="#F14343"
|
||||
stroke="#1D8227"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<mask
|
||||
id="mask1"
|
||||
mask-type="alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="30"
|
||||
y="10"
|
||||
width="100"
|
||||
height="100"
|
||||
>
|
||||
<circle
|
||||
cx="80"
|
||||
cy="60"
|
||||
r="46"
|
||||
fill="#F14343"
|
||||
stroke="#1D8227"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask1)">
|
||||
{generateStripes(props.stripes)}
|
||||
<circle
|
||||
cx="80"
|
||||
cy="60"
|
||||
r="46"
|
||||
stroke={props.circleOuterFill || palette.green200}
|
||||
strokeWidth="8"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="487" height="143" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
2
packages/design-system/atoms/branding/index.ts
Normal file
2
packages/design-system/atoms/branding/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './Branding';
|
||||
export * from './DynamicBranding';
|
|
@ -0,0 +1,68 @@
|
|||
import * as React from 'react';
|
||||
import { mediaQueryDefs } from './Breakpoints';
|
||||
import { BreakpointContext, ScreenSize } from './Context';
|
||||
|
||||
const resetScreen: ScreenSize = {
|
||||
onSmallScreen: false,
|
||||
onTablet: false,
|
||||
onDesktop: false,
|
||||
};
|
||||
|
||||
export class BreakpointsProvider extends React.Component<{}, ScreenSize> {
|
||||
public state = {
|
||||
...resetScreen,
|
||||
onSmallScreen: true,
|
||||
};
|
||||
|
||||
private mediaQueries: { [key in keyof ScreenSize]: MediaQueryList } = {
|
||||
onSmallScreen: window.matchMedia(
|
||||
mediaQueryDefs.onSmallScreen.replace('@media screen and', '')
|
||||
),
|
||||
onTablet: window.matchMedia(
|
||||
mediaQueryDefs.onTablet.replace('@media screen and', '')
|
||||
),
|
||||
onDesktop: window.matchMedia(
|
||||
mediaQueryDefs.onDesktop.replace('@media screen and', '')
|
||||
),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
Object.entries(this.mediaQueries).forEach(([key, mediaQuery]) =>
|
||||
mediaQuery.addEventListener('change', this.handleMediaEvent)
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
Object.entries(this.mediaQueries).forEach(([key, mediaQuery]) =>
|
||||
mediaQuery.removeEventListener('change', this.handleMediaEvent)
|
||||
);
|
||||
}
|
||||
|
||||
handleMediaEvent = (event: MediaQueryListEvent) => {
|
||||
console.log('handleMediaEvent', { event });
|
||||
this.setState({
|
||||
...resetScreen,
|
||||
...this.calculateScreen(),
|
||||
});
|
||||
};
|
||||
|
||||
calculateScreen = () => {
|
||||
if (this.mediaQueries.onDesktop.matches) {
|
||||
return { onDesktop: true };
|
||||
}
|
||||
|
||||
if (this.mediaQueries.onTablet.matches) {
|
||||
return { onTablet: true };
|
||||
}
|
||||
|
||||
return { onSmallScreen: true };
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<BreakpointContext.Provider value={{ screenSize: { ...this.state } }}>
|
||||
{this.props.children}
|
||||
</BreakpointContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import * as React from 'react';
|
||||
import { BreakpointsProvider } from './BreakpointProvider';
|
||||
import { BreakpointDebugTool } from './DebugTool';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Breakpoints',
|
||||
decorators: [(story) => <BreakpointsProvider>{story()}</BreakpointsProvider>],
|
||||
component: BreakpointDebugTool,
|
||||
};
|
||||
|
||||
export const DebugTool = () => <BreakpointDebugTool />;
|
34
packages/design-system/atoms/breakpoints/Breakpoints.ts
Normal file
34
packages/design-system/atoms/breakpoints/Breakpoints.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
export const breakpoints = {
|
||||
onTablet: 768,
|
||||
onDesktop: 1024,
|
||||
};
|
||||
|
||||
export const mediaQueryDefs = {
|
||||
onSmallScreen: `@media screen and (max-width: ${breakpoints.onTablet - 1}px)`,
|
||||
onTablet: `@media screen and (min-width: ${breakpoints.onTablet}px)`,
|
||||
onDesktop: `@media screen and (min-width: ${breakpoints.onDesktop}px)`,
|
||||
};
|
||||
|
||||
export const onTablet = (...expressions: any) => {
|
||||
return `
|
||||
${mediaQueryDefs.onTablet} {
|
||||
${expressions.join()}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
export const onDesktop = (...expressions: any) => {
|
||||
return `
|
||||
${mediaQueryDefs.onDesktop} {
|
||||
${expressions.join()}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
export const onSmallScreen = (...expressions: any) => {
|
||||
return `
|
||||
${mediaQueryDefs.onSmallScreen} {
|
||||
${expressions.join()}
|
||||
}
|
||||
`;
|
||||
};
|
27
packages/design-system/atoms/breakpoints/Context.ts
Normal file
27
packages/design-system/atoms/breakpoints/Context.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { withContext } from '@roleypoly/misc-utils/withContext';
|
||||
import * as React from 'react';
|
||||
|
||||
export type ScreenSize = {
|
||||
onSmallScreen: boolean;
|
||||
onTablet: boolean;
|
||||
onDesktop: boolean;
|
||||
};
|
||||
|
||||
export type BreakpointProps = {
|
||||
screenSize: ScreenSize;
|
||||
};
|
||||
|
||||
const defaultScreenSize: BreakpointProps = {
|
||||
screenSize: {
|
||||
onSmallScreen: true,
|
||||
onDesktop: false,
|
||||
onTablet: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const BreakpointContext = React.createContext(defaultScreenSize);
|
||||
|
||||
export const useBreakpointContext = () => React.useContext(BreakpointContext);
|
||||
|
||||
export const withBreakpoints = <T>(Component: React.ComponentType<T>) =>
|
||||
withContext(BreakpointContext, Component as any);
|
56
packages/design-system/atoms/breakpoints/DebugTool.tsx
Normal file
56
packages/design-system/atoms/breakpoints/DebugTool.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { onDesktop, onTablet } from './Breakpoints';
|
||||
import { useBreakpointContext } from './Context';
|
||||
|
||||
const DebuggerPosition = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-family: monospace;
|
||||
& > div {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
const OnSmallScreen = styled.div`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const OnTablet = styled.div`
|
||||
display: none;
|
||||
${onTablet(`display: block;`)}
|
||||
`;
|
||||
|
||||
const OnDesktop = styled.div`
|
||||
display: none;
|
||||
${onDesktop`display: block;`}
|
||||
`;
|
||||
|
||||
const CSSBreakpointDebugger = () => (
|
||||
<div>
|
||||
<OnSmallScreen style={{ backgroundColor: 'red' }}>S</OnSmallScreen>
|
||||
<OnTablet style={{ backgroundColor: 'green' }}>T</OnTablet>
|
||||
<OnDesktop style={{ backgroundColor: 'blue' }}>D</OnDesktop>
|
||||
</div>
|
||||
);
|
||||
|
||||
const JSBreakpointDebugger = () => {
|
||||
const {
|
||||
screenSize: { onTablet, onDesktop, onSmallScreen },
|
||||
} = useBreakpointContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{onSmallScreen && <div style={{ backgroundColor: 'red' }}>S</div>}
|
||||
{onTablet && <div style={{ backgroundColor: 'green' }}>T</div>}
|
||||
{onDesktop && <div style={{ backgroundColor: 'blue' }}>D</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const BreakpointDebugTool = () => (
|
||||
<DebuggerPosition>
|
||||
<JSBreakpointDebugger />
|
||||
<CSSBreakpointDebugger />
|
||||
</DebuggerPosition>
|
||||
);
|
3
packages/design-system/atoms/breakpoints/index.ts
Normal file
3
packages/design-system/atoms/breakpoints/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from './BreakpointProvider';
|
||||
export * from './Breakpoints';
|
||||
export * from './Context';
|
11
packages/design-system/atoms/button/Button.spec.tsx
Normal file
11
packages/design-system/atoms/button/Button.spec.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { shallow } from 'enzyme';
|
||||
import * as React from 'react';
|
||||
import { Button } from './Button';
|
||||
|
||||
it('fires an onClick callback when clicked', () => {
|
||||
const mock = jest.fn();
|
||||
const view = shallow(<Button onClick={mock}>Button</Button>);
|
||||
|
||||
view.simulate('click');
|
||||
expect(mock).toBeCalled();
|
||||
});
|
25
packages/design-system/atoms/button/Button.stories.tsx
Normal file
25
packages/design-system/atoms/button/Button.stories.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import * as React from 'react';
|
||||
import { Button as ButtonComponent } from './Button';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Button',
|
||||
component: ButtonComponent,
|
||||
argTypes: {
|
||||
content: { control: 'text' },
|
||||
},
|
||||
args: {
|
||||
content: 'Press me!',
|
||||
size: 'large',
|
||||
},
|
||||
};
|
||||
|
||||
export const Large = ({ content, ...args }) => (
|
||||
<ButtonComponent {...args}>{content}</ButtonComponent>
|
||||
);
|
||||
|
||||
export const Small = ({ content, ...args }) => (
|
||||
<ButtonComponent {...args}>{content}</ButtonComponent>
|
||||
);
|
||||
Small.args = {
|
||||
size: 'small',
|
||||
};
|
108
packages/design-system/atoms/button/Button.styled.ts
Normal file
108
packages/design-system/atoms/button/Button.styled.ts
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import { fontCSS } from '@roleypoly/design-system/atoms/fonts';
|
||||
import { text300, text400 } from '@roleypoly/design-system/atoms/typography';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
export const IconContainer = styled.div`
|
||||
margin-right: 0.6rem;
|
||||
font-size: 1.75em;
|
||||
`;
|
||||
|
||||
const base = css`
|
||||
${fontCSS}
|
||||
|
||||
appearance: none;
|
||||
display: block;
|
||||
background-color: ${palette.taupe300};
|
||||
color: ${palette.grey500};
|
||||
border-radius: 3px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.55);
|
||||
transition: all 0.15s ease-in-out;
|
||||
outline: 0;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #000;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.25);
|
||||
::after {
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const colors = {
|
||||
primary: css`
|
||||
background-color: ${palette.green400};
|
||||
color: ${palette.taupe100};
|
||||
`,
|
||||
secondary: css``,
|
||||
discord: css`
|
||||
background-color: ${palette.discord400};
|
||||
border: 2px solid ${palette.discord200};
|
||||
`,
|
||||
muted: css`
|
||||
border: 2px solid rgba(0, 0, 0, 0.15);
|
||||
background: none;
|
||||
:hover {
|
||||
background-color: ${palette.taupe200};
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
small: css`
|
||||
${text300}
|
||||
|
||||
padding: 4px 8px;
|
||||
`,
|
||||
large: css`
|
||||
${text400}
|
||||
|
||||
padding: 12px 32px;
|
||||
width: 100%;
|
||||
`,
|
||||
};
|
||||
|
||||
const modifiers = {
|
||||
withIcon: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`,
|
||||
withLoading: css`
|
||||
pointer-events: none;
|
||||
`,
|
||||
};
|
||||
|
||||
export type ButtonComposerOptions = {
|
||||
size: keyof typeof sizes;
|
||||
color: keyof typeof colors;
|
||||
modifiers?: Array<keyof typeof modifiers>;
|
||||
};
|
||||
|
||||
export const Button = styled.button<ButtonComposerOptions>`
|
||||
${base}
|
||||
${(props) => props.size in sizes && sizes[props.size]}
|
||||
${(props) => props.color in colors && colors[props.color]}
|
||||
${(props) => props.modifiers?.map((m) => modifiers[m])}
|
||||
`;
|
38
packages/design-system/atoms/button/Button.tsx
Normal file
38
packages/design-system/atoms/button/Button.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import * as React from 'react';
|
||||
import {
|
||||
Button as StyledButton,
|
||||
ButtonComposerOptions,
|
||||
IconContainer,
|
||||
} from './Button.styled';
|
||||
|
||||
export type ButtonProps = Partial<ButtonComposerOptions> & {
|
||||
children: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const Button = (props: ButtonProps) => {
|
||||
const modifiers: ButtonProps['modifiers'] = [];
|
||||
if (props.loading) {
|
||||
modifiers.push('withLoading');
|
||||
}
|
||||
|
||||
if (props.icon) {
|
||||
modifiers.push('withIcon');
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
size={props.size || 'large'}
|
||||
color={props.color || 'primary'}
|
||||
modifiers={modifiers}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{props.icon && <IconContainer>{props.icon}</IconContainer>}
|
||||
<div>{props.children}</div>
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
1
packages/design-system/atoms/button/index.ts
Normal file
1
packages/design-system/atoms/button/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Button';
|
13
packages/design-system/atoms/collapse/Collapse.stories.tsx
Normal file
13
packages/design-system/atoms/collapse/Collapse.stories.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { SmallTitle } from '@roleypoly/design-system/atoms/typography';
|
||||
import { Collapse } from './Collapse';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Collapse',
|
||||
component: Collapse,
|
||||
};
|
||||
|
||||
export const collapse = (args) => (
|
||||
<SmallTitle>
|
||||
Hello, <Collapse {...args}>small</Collapse> world!
|
||||
</SmallTitle>
|
||||
);
|
10
packages/design-system/atoms/collapse/Collapse.tsx
Normal file
10
packages/design-system/atoms/collapse/Collapse.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import styled, { css } from 'styled-components';
|
||||
import { onSmallScreen } from '../breakpoints';
|
||||
|
||||
export const Collapse = styled.span<{ preventCollapse?: boolean }>`
|
||||
${(props) =>
|
||||
!props.preventCollapse &&
|
||||
onSmallScreen(css`
|
||||
display: none;
|
||||
`)}
|
||||
`;
|
1
packages/design-system/atoms/collapse/index.ts
Normal file
1
packages/design-system/atoms/collapse/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Collapse';
|
162
packages/design-system/atoms/colors/colors.stories.tsx
Normal file
162
packages/design-system/atoms/colors/colors.stories.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
import { AmbientSmall } from '@roleypoly/design-system/atoms/typography';
|
||||
import chroma from 'chroma-js';
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { palette } from './colors';
|
||||
|
||||
type RatioList = {
|
||||
color1: string[];
|
||||
color2: string[];
|
||||
ratio: string;
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Colors',
|
||||
};
|
||||
|
||||
const Swatch = styled.div`
|
||||
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
|
||||
width: 250px;
|
||||
height: 100px;
|
||||
margin: 10px;
|
||||
display: inline-block;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
`;
|
||||
|
||||
const SwatchColor = styled.div`
|
||||
height: 72px;
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px;
|
||||
color: ${palette.taupe100};
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Colors = () => {
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(palette).map(([name, color], i) => (
|
||||
<Swatch key={i}>
|
||||
<SwatchColor style={{ backgroundColor: color }} />
|
||||
<Label>
|
||||
<p>{name}</p>
|
||||
<p>
|
||||
<code>var(--{name})</code>
|
||||
</p>
|
||||
</Label>
|
||||
</Swatch>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ContrastRatios = () => {
|
||||
const allRatios = getAllRatios(palette);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<b>WCAG Contrast Calculations.</b>
|
||||
<br />
|
||||
Marked in <span style={getWCAGStyle(7.1)}>Green</span> is 7.0+ or AAA.
|
||||
Acceptable for Text.
|
||||
<br />
|
||||
Marked in <span style={getWCAGStyle(4.6)}>Orange</span> is 4.5+ or AA.
|
||||
Acceptable for UI.
|
||||
<br />
|
||||
All below 4.5 is unacceptable.
|
||||
<br />
|
||||
<AmbientSmall>WCAG Contrast testing disabled for this page.</AmbientSmall>
|
||||
</p>
|
||||
<ContrastTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2}>Swatch</th>
|
||||
<th>Ratio</th>
|
||||
<th>Color 1</th>
|
||||
<th>Color 2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allRatios.map((ratio, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ backgroundColor: ratio.color1[1] }}> </td>
|
||||
<td style={{ backgroundColor: ratio.color2[1] }}> </td>
|
||||
<td style={getWCAGStyle(+ratio.ratio)}>{ratio.ratio}</td>
|
||||
<td>{ratio.color1[0]}</td>
|
||||
<td>{ratio.color2[0]}</td>
|
||||
<td
|
||||
style={{
|
||||
color: ratio.color1[1],
|
||||
backgroundColor: ratio.color2[1],
|
||||
paddingRight: '0.1em',
|
||||
}}
|
||||
>
|
||||
oh my god my
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
color: ratio.color2[1],
|
||||
backgroundColor: ratio.color1[1],
|
||||
paddingLeft: '0.1em',
|
||||
}}
|
||||
>
|
||||
shin how dare you
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</ContrastTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ContrastTable = styled.table`
|
||||
td,
|
||||
th {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const getWCAGStyle = (ratio: number): React.CSSProperties => {
|
||||
if (ratio >= 7) {
|
||||
return { color: 'green', fontWeight: 'bold' };
|
||||
}
|
||||
|
||||
if (ratio >= 4.5) {
|
||||
return { color: 'orange', fontWeight: 'bold' };
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const getAllRatios = (input: typeof palette) =>
|
||||
Object.entries(input)
|
||||
.filter(([name]) => !name.startsWith('discord'))
|
||||
.reduce((acc, [name, color]) => {
|
||||
return [
|
||||
...acc,
|
||||
...Object.entries(palette)
|
||||
.filter(([name]) => !name.startsWith('discord'))
|
||||
.map(([matchName, matchColor]) => ({
|
||||
color1: [name, color],
|
||||
color2: [matchName, matchColor],
|
||||
ratio: chroma.contrast(color, matchColor).toFixed(2),
|
||||
})),
|
||||
];
|
||||
}, [] as RatioList[])
|
||||
.filter(({ ratio }) => +ratio !== 1)
|
||||
.sort((a, b) => {
|
||||
if (+a.ratio > +b.ratio) {
|
||||
return -1;
|
||||
}
|
||||
return 1;
|
||||
})
|
||||
.filter((_, i) => i % 2 === 0);
|
46
packages/design-system/atoms/colors/colors.tsx
Normal file
46
packages/design-system/atoms/colors/colors.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import chroma from 'chroma-js';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
|
||||
export const palette = {
|
||||
taupe100: '#332D2D',
|
||||
taupe200: '#453E3D',
|
||||
taupe300: '#5D5352',
|
||||
taupe400: '#756867',
|
||||
taupe500: '#AB9B9A',
|
||||
taupe600: '#EBD6D4',
|
||||
|
||||
discord100: '#23272A',
|
||||
discord200: '#2C2F33',
|
||||
discord400: '#7289DA',
|
||||
discord500: '#99AAB5',
|
||||
|
||||
green400: '#46B646',
|
||||
green200: '#1D8227',
|
||||
|
||||
red400: '#E95353',
|
||||
red200: '#F14343',
|
||||
|
||||
gold400: '#EFCF24',
|
||||
|
||||
grey100: '#1C1010',
|
||||
grey500: '#DBD9D9',
|
||||
grey600: '#F2EFEF',
|
||||
};
|
||||
|
||||
const getPaletteCSS = () =>
|
||||
Object.entries(palette).reduce(
|
||||
(acc, [key, color]) => ({ ...acc, [`--${key}`]: color }),
|
||||
{}
|
||||
);
|
||||
|
||||
export const colorVars = css(getPaletteCSS());
|
||||
|
||||
export const GlobalStyleColors = createGlobalStyle`
|
||||
:root {
|
||||
${colorVars}
|
||||
}
|
||||
`;
|
||||
|
||||
export const numberToChroma = (colorInt: number) => {
|
||||
return chroma(colorInt);
|
||||
};
|
2
packages/design-system/atoms/colors/index.ts
Normal file
2
packages/design-system/atoms/colors/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './colors';
|
||||
export * as utils from './withColors';
|
11
packages/design-system/atoms/colors/withColors.tsx
Normal file
11
packages/design-system/atoms/colors/withColors.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { colorVars } from './colors';
|
||||
|
||||
const ColorsContainer = styled.div`
|
||||
${colorVars}
|
||||
`;
|
||||
|
||||
export const withColors = (storyFn: () => React.ReactNode) => (
|
||||
<ColorsContainer>{storyFn()}</ColorsContainer>
|
||||
);
|
|
@ -0,0 +1,9 @@
|
|||
import * as React from 'react';
|
||||
import { DotOverlay } from './DotOverlay';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Dot Overlay',
|
||||
};
|
||||
|
||||
export const Dark = () => <DotOverlay />;
|
||||
export const Light = () => <DotOverlay light />;
|
38
packages/design-system/atoms/dot-overlay/DotOverlay.tsx
Normal file
38
packages/design-system/atoms/dot-overlay/DotOverlay.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const dotOverlayBase = styled.div`
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: -10;
|
||||
background-size: 27px 27px;
|
||||
`;
|
||||
|
||||
const DotOverlayDark = styled(dotOverlayBase)`
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
#332d2d,
|
||||
#332d2d 1px,
|
||||
transparent 1px,
|
||||
transparent
|
||||
);
|
||||
`;
|
||||
|
||||
const DotOverlayLight = styled(dotOverlayBase)`
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
#dbd9d9,
|
||||
#dbd9d9 1px,
|
||||
transparent 1px,
|
||||
transparent
|
||||
);
|
||||
`;
|
||||
|
||||
export const DotOverlay = ({ light }: { light?: boolean }) => {
|
||||
return light ? <DotOverlayLight /> : <DotOverlayDark />;
|
||||
};
|
1
packages/design-system/atoms/dot-overlay/index.ts
Normal file
1
packages/design-system/atoms/dot-overlay/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './DotOverlay';
|
28
packages/design-system/atoms/fader/Fader.stories.tsx
Normal file
28
packages/design-system/atoms/fader/Fader.stories.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { Button } from '@roleypoly/design-system/atoms/button';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import * as React from 'react';
|
||||
import { FaderOpacity, FaderSlide } from './Fader';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Fader',
|
||||
component: FaderSlide,
|
||||
args: {
|
||||
isVisible: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Opacity = (args) => {
|
||||
return (
|
||||
<FaderOpacity {...args}>
|
||||
<Button onClick={action('onClick')}>Click me!</Button>
|
||||
</FaderOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export const Slide = (args) => {
|
||||
return (
|
||||
<FaderSlide {...args}>
|
||||
<Button onClick={action('onClick')}>Click me!</Button>
|
||||
</FaderSlide>
|
||||
);
|
||||
};
|
35
packages/design-system/atoms/fader/Fader.tsx
Normal file
35
packages/design-system/atoms/fader/Fader.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export type FaderProps = {
|
||||
isVisible: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const FaderOpacityStyled = styled.div<Pick<FaderProps, 'isVisible'>>`
|
||||
opacity: ${(props) => (props.isVisible ? 1 : 0)};
|
||||
pointer-events: ${(props) => (props.isVisible ? 'unset' : 'none')};
|
||||
transition: opacity 0.35s ease-in-out;
|
||||
`;
|
||||
|
||||
export const FaderOpacity = (props: FaderProps) => {
|
||||
return (
|
||||
<FaderOpacityStyled isVisible={props.isVisible}>
|
||||
{props.children}
|
||||
</FaderOpacityStyled>
|
||||
);
|
||||
};
|
||||
|
||||
const FaderSlideStyled = styled.div<Pick<FaderProps, 'isVisible'>>`
|
||||
max-height: ${(props) => (props.isVisible ? '4em' : '0')};
|
||||
pointer-events: ${(props) => (props.isVisible ? 'unset' : 'none')};
|
||||
transition: max-height 0.35s ease-in-out;
|
||||
overflow: hidden;
|
||||
transform: translateZ(0);
|
||||
`;
|
||||
|
||||
export const FaderSlide = (props: FaderProps) => {
|
||||
return (
|
||||
<FaderSlideStyled isVisible={props.isVisible}>{props.children}</FaderSlideStyled>
|
||||
);
|
||||
};
|
1
packages/design-system/atoms/fader/index.ts
Normal file
1
packages/design-system/atoms/fader/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Fader';
|
|
@ -0,0 +1,16 @@
|
|||
import { FeatureFlagDecorator } from '@roleypoly/misc-utils/featureFlags/react/storyDecorator';
|
||||
import * as React from 'react';
|
||||
import { FeatureGate } from './FeatureGate';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Feature Gate',
|
||||
decorators: [FeatureFlagDecorator(['AllowListBlockList'])],
|
||||
};
|
||||
|
||||
export const ActiveGate = () => (
|
||||
<FeatureGate featureFlag="AllowListBlockList">{() => <div>hello!</div>}</FeatureGate>
|
||||
);
|
||||
|
||||
export const InactiveGate = () => (
|
||||
<FeatureGate featureFlag="aaa">{() => <div>hello!</div>}</FeatureGate>
|
||||
);
|
20
packages/design-system/atoms/feature-gate/FeatureGate.tsx
Normal file
20
packages/design-system/atoms/feature-gate/FeatureGate.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {
|
||||
FeatureFlag,
|
||||
FeatureFlagsContext,
|
||||
} from '@roleypoly/misc-utils/featureFlags/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export type FeatureGateProps = {
|
||||
featureFlag: FeatureFlag;
|
||||
children: () => React.ReactNode;
|
||||
};
|
||||
|
||||
export const FeatureGate = (props: FeatureGateProps) => {
|
||||
const featureContext = React.useContext(FeatureFlagsContext);
|
||||
|
||||
if (featureContext.has(props.featureFlag)) {
|
||||
return props.children();
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
};
|
1
packages/design-system/atoms/feature-gate/index.ts
Normal file
1
packages/design-system/atoms/feature-gate/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './FeatureGate';
|
62
packages/design-system/atoms/fonts/fonts.stories.tsx
Normal file
62
packages/design-system/atoms/fonts/fonts.stories.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
MediumTitle,
|
||||
Text as TextBlock,
|
||||
} from '@roleypoly/design-system/atoms/typography';
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { UseFontStyled } from './fonts';
|
||||
|
||||
const resetFont = (storyFn: () => React.ReactNode) => <FontReset>{storyFn()}</FontReset>;
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Fonts',
|
||||
decorators: [resetFont],
|
||||
};
|
||||
|
||||
const FontReset = styled.div`
|
||||
font-family: sans-serif;
|
||||
`;
|
||||
|
||||
const CorrectlyFontedH2 = (props: { children: React.ReactNode }) => (
|
||||
<UseFontStyled>
|
||||
<MediumTitle>{props.children}</MediumTitle>
|
||||
</UseFontStyled>
|
||||
);
|
||||
|
||||
const Text = () => (
|
||||
<>
|
||||
<p>
|
||||
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Et facilis alias
|
||||
placeat cumque sapiente ad delectus omnis quae. Reiciendis quibusdam deserunt
|
||||
repellat. Exercitationem modi incidunt autem nemo tempore eaque soluta.
|
||||
</p>
|
||||
<p>
|
||||
帯カノ需混モイ一録43旧百12共ドレ能生ホクユ禁度ヨ材図クほはそ護関ラト郵張エノヨ議件クめざ県読れみとぶ論税クょンど慎転リつぎみ松期ほへド.
|
||||
縦投記ふで覧速っだせあ過先課フ演無ぎぱべ習併相ーす気6元ゆる領気希ぎ投代ラ我関レ森郎由系堂ず.
|
||||
読ケリ夜指ーっトせ認平引ウシ間花ヱクム年6台ぐ山婦ラスエ子著コア掲中ロ像属戸メソユ職諏ルど詐児題たに書希ク幕値長ラそめド.
|
||||
</p>
|
||||
<p>
|
||||
🔸🐕🔺💱🎊👽🐛 👨📼🕦📞 👱👆🍗👚🌈 🔝🔟🍉🔰🍲🏁🕗 🎡🐉🍲📻🔢🔄 💟💲🍻💜💩🔼
|
||||
🎱🌸📛👫🌻 🗽🕜🐥👕🍈. 🐒🍚🔓📱🏦 🎦🌑🔛💙👣🔚 🔆🗻🌿🎳📲🍯 🌞💟🎌🍌 🔪📯🐎💮
|
||||
👌👭🎋🏉🏰 📓🕃🎂💉🔩 🐟🌇👺🌊🌒 📪👅🍂🍁 🌖🐮🔽🌒📊. 🔤🍍🌸📷🎴 💏🍌📎👥👉👒
|
||||
👝💜🔶🍣 💨🗼👈💉💉💰 🍐🕖🌰👝🕓🏊🐕 🏀📅📼📒 🐕🌈👋
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Fonts = () => (
|
||||
<TextBlock>
|
||||
<section>
|
||||
<CorrectlyFontedH2>Unstyled Default</CorrectlyFontedH2>
|
||||
<Text />
|
||||
</section>
|
||||
<section>
|
||||
<CorrectlyFontedH2>
|
||||
Main (Source Han Sans Japanese, Source Sans)
|
||||
</CorrectlyFontedH2>
|
||||
<UseFontStyled>
|
||||
<Text />
|
||||
</UseFontStyled>
|
||||
</section>
|
||||
</TextBlock>
|
||||
);
|
10
packages/design-system/atoms/fonts/fonts.tsx
Normal file
10
packages/design-system/atoms/fonts/fonts.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import styled, { css } from 'styled-components';
|
||||
|
||||
export const fontCSS = css`
|
||||
font-family: 'source-han-sans-japanese', 'Source Sans Pro', sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol' !important;
|
||||
`;
|
||||
|
||||
export const UseFontStyled = styled.div`
|
||||
${fontCSS}
|
||||
`;
|
1
packages/design-system/atoms/fonts/index.ts
Normal file
1
packages/design-system/atoms/fonts/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './fonts';
|
13
packages/design-system/atoms/halfsies/Halfsies.stories.tsx
Normal file
13
packages/design-system/atoms/halfsies/Halfsies.stories.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import * as React from 'react';
|
||||
import { HalfsiesContainer, HalfsiesItem } from './Halfsies';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Halfsies',
|
||||
};
|
||||
|
||||
export const Container = () => (
|
||||
<HalfsiesContainer>
|
||||
<HalfsiesItem>Lefty doo</HalfsiesItem>
|
||||
<HalfsiesItem>Righty doo</HalfsiesItem>
|
||||
</HalfsiesContainer>
|
||||
);
|
17
packages/design-system/atoms/halfsies/Halfsies.tsx
Normal file
17
packages/design-system/atoms/halfsies/Halfsies.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { onTablet } from '@roleypoly/design-system/atoms/breakpoints';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
export const HalfsiesContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const HalfsiesItem = styled.div`
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 100%;
|
||||
${onTablet(css`
|
||||
flex: 1 2 50%;
|
||||
`)}
|
||||
`;
|
1
packages/design-system/atoms/halfsies/index.ts
Normal file
1
packages/design-system/atoms/halfsies/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Halfsies';
|
70
packages/design-system/atoms/hero/Hero.stories.tsx
Normal file
70
packages/design-system/atoms/hero/Hero.stories.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import * as React from 'react';
|
||||
import { Hero as HeroComponent } from './Hero';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Hero',
|
||||
component: HeroComponent,
|
||||
args: {
|
||||
topSpacing: 75,
|
||||
bottomSpacing: 25,
|
||||
},
|
||||
};
|
||||
|
||||
export const Hero = ({ topSpacing, bottomSpacing }) => {
|
||||
return (
|
||||
<StoryWrapper topSpacing={topSpacing} bottomSpacing={bottomSpacing}>
|
||||
<HeroComponent topSpacing={topSpacing} bottomSpacing={bottomSpacing}>
|
||||
<h1>This is it.</h1>
|
||||
</HeroComponent>
|
||||
</StoryWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type WrapperProps = {
|
||||
children: React.ReactNode;
|
||||
topSpacing: number;
|
||||
bottomSpacing: number;
|
||||
};
|
||||
|
||||
const StoryWrapper = ({ topSpacing, bottomSpacing, ...props }: WrapperProps) => (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: topSpacing,
|
||||
backgroundColor: 'rgba(255,0,0,0.25)',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
topSpacing
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: bottomSpacing,
|
||||
backgroundColor: 'rgba(0,0,255,0.25)',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
bottomSpacing
|
||||
</div>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
30
packages/design-system/atoms/hero/Hero.tsx
Normal file
30
packages/design-system/atoms/hero/Hero.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type HeroContainerProps = {
|
||||
topSpacing: number;
|
||||
bottomSpacing: number;
|
||||
};
|
||||
|
||||
type HeroProps = Partial<HeroContainerProps> & {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const HeroContainer = styled.div<HeroContainerProps>`
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: hidden;
|
||||
min-height: calc(100vh - ${(props) => props.topSpacing + props.bottomSpacing}px);
|
||||
margin-top: ${(props) => props.topSpacing}px;
|
||||
`;
|
||||
|
||||
export const Hero = (props: HeroProps) => (
|
||||
<HeroContainer
|
||||
topSpacing={props.topSpacing || 0}
|
||||
bottomSpacing={props.bottomSpacing || 0}
|
||||
>
|
||||
{props.children}
|
||||
</HeroContainer>
|
||||
);
|
1
packages/design-system/atoms/hero/index.ts
Normal file
1
packages/design-system/atoms/hero/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Hero';
|
11
packages/design-system/atoms/horizontal-switch/BUILD.bazel
Normal file
11
packages/design-system/atoms/horizontal-switch/BUILD.bazel
Normal file
|
@ -0,0 +1,11 @@
|
|||
load("//hack/bazel/js:react.bzl", "react_library")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
react_library(
|
||||
name = "horizontal-switch",
|
||||
deps = [
|
||||
"//src/design-system/atoms/colors",
|
||||
"//src/design-system/atoms/timings",
|
||||
],
|
||||
)
|
|
@ -0,0 +1,33 @@
|
|||
import * as React from 'react';
|
||||
import { HorizontalSwitch } from './HorizontalSwitch';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Horizontal Switch',
|
||||
component: HorizontalSwitch,
|
||||
args: {
|
||||
items: ['true', 'false'],
|
||||
value: 'true',
|
||||
},
|
||||
};
|
||||
|
||||
const Story = (args) => {
|
||||
const [value, setValue] = React.useState(args.value);
|
||||
|
||||
return (
|
||||
<HorizontalSwitch
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(a) => {
|
||||
setValue(a);
|
||||
args.onChange(a);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Switch = Story.bind({});
|
||||
export const SwitchThree = Story.bind({});
|
||||
SwitchThree.args = {
|
||||
items: ['aaa', 'bbb', 'ccc'],
|
||||
value: 'aaa',
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import { transitions } from '@roleypoly/design-system/atoms/timings';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
export const Item = styled.div<{ selected: boolean }>`
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
transition: background-color ease-in-out ${transitions.actionable}s;
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
css`
|
||||
background-color: ${palette.taupe300};
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
display: inline-flex;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid ${palette.taupe200};
|
||||
border-radius: calc(1em + 20px);
|
||||
overflow: hidden;
|
||||
`;
|
|
@ -0,0 +1,28 @@
|
|||
import * as React from 'react';
|
||||
import { Item, Wrapper } from './HorizontalSwitch.styled';
|
||||
|
||||
export type SwitchProps = {
|
||||
items: string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const HorizontalSwitch = (props: SwitchProps) => {
|
||||
const handleClick = (item: typeof props.value) => () => {
|
||||
props.onChange?.(item);
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
{props.items.map((item, idx) => (
|
||||
<Item
|
||||
key={idx}
|
||||
selected={item === props.value}
|
||||
onClick={handleClick(item)}
|
||||
>
|
||||
{item}
|
||||
</Item>
|
||||
))}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
1
packages/design-system/atoms/horizontal-switch/index.tsx
Normal file
1
packages/design-system/atoms/horizontal-switch/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './HorizontalSwitch';
|
19
packages/design-system/atoms/key-events/KeyEvents.ts
Normal file
19
packages/design-system/atoms/key-events/KeyEvents.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
export const globalOnKeyUp = (
|
||||
key: string[],
|
||||
action: () => any,
|
||||
isActive: boolean = true
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (isActive && key.includes(event.key)) {
|
||||
action();
|
||||
}
|
||||
};
|
||||
|
||||
document.body.addEventListener('keyup', onKeyUp);
|
||||
|
||||
return () => document.body.removeEventListener('keyup', onKeyUp);
|
||||
}, [key, action, isActive]);
|
||||
};
|
1
packages/design-system/atoms/key-events/index.ts
Normal file
1
packages/design-system/atoms/key-events/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './KeyEvents';
|
34
packages/design-system/atoms/popover/Popover.stories.tsx
Normal file
34
packages/design-system/atoms/popover/Popover.stories.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { Button } from '@roleypoly/design-system/atoms/button';
|
||||
import * as React from 'react';
|
||||
import { Popover as PopoverComponent } from './Popover';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Popover',
|
||||
argTypes: {
|
||||
canDefocus: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
canDefocus: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Popover = ({ canDefocus }) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 50 }}>
|
||||
<Button size="small" onClick={() => setIsOpen(!isOpen)}>
|
||||
{!isOpen ? 'Open' : 'Close'} me!
|
||||
</Button>
|
||||
<PopoverComponent
|
||||
position="top right"
|
||||
active={isOpen}
|
||||
onExit={() => setIsOpen(false)}
|
||||
canDefocus={canDefocus}
|
||||
headContent={<>Hello c:</>}
|
||||
>
|
||||
stuff
|
||||
</PopoverComponent>
|
||||
</div>
|
||||
);
|
||||
};
|
90
packages/design-system/atoms/popover/Popover.styled.ts
Normal file
90
packages/design-system/atoms/popover/Popover.styled.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { onSmallScreen, onTablet } from '@roleypoly/design-system/atoms/breakpoints';
|
||||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import { transitions } from '@roleypoly/design-system/atoms/timings';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
type PopoverStyledProps = {
|
||||
active: boolean;
|
||||
preferredWidth?: number;
|
||||
};
|
||||
|
||||
export const PopoverBase = styled.div<PopoverStyledProps>`
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
background-color: ${palette.taupe100};
|
||||
padding: 5px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 3px;
|
||||
z-index: 10;
|
||||
transition: opacity ${transitions.out2in}s ease-in,
|
||||
transform ${transitions.out2in}s ease-in;
|
||||
min-width: ${(props) => props.preferredWidth || 320}px;
|
||||
max-width: 100vw;
|
||||
${(props) =>
|
||||
!props.active &&
|
||||
css`
|
||||
transform: translateY(-2vh);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
`}
|
||||
${onSmallScreen(
|
||||
css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
min-width: unset;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
`
|
||||
)};
|
||||
`;
|
||||
|
||||
export const DefocusHandler = styled.div<PopoverStyledProps>`
|
||||
background-color: rgba(0, 0, 0, 0.01);
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
${(props) =>
|
||||
!props.active &&
|
||||
css`
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const PopoverHead = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export const PopoverHeadCloser = styled.div`
|
||||
flex: 0;
|
||||
font-size: 2em;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
border-radius: 2em;
|
||||
min-width: 1.4em;
|
||||
height: 1.4em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
${onTablet(
|
||||
css`
|
||||
display: none;
|
||||
`
|
||||
)}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
export const PopoverContent = styled.div`
|
||||
padding: 5px;
|
||||
overflow-y: hidden;
|
||||
`;
|
43
packages/design-system/atoms/popover/Popover.tsx
Normal file
43
packages/design-system/atoms/popover/Popover.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { globalOnKeyUp } from '@roleypoly/design-system/atoms/key-events';
|
||||
import * as React from 'react';
|
||||
import { IoMdClose } from 'react-icons/io';
|
||||
import {
|
||||
DefocusHandler,
|
||||
PopoverBase,
|
||||
PopoverContent,
|
||||
PopoverHead,
|
||||
PopoverHeadCloser,
|
||||
} from './Popover.styled';
|
||||
|
||||
type PopoverProps = {
|
||||
children: () => React.ReactNode;
|
||||
position: 'top left' | 'top right' | 'bottom left' | 'bottom right';
|
||||
active: boolean;
|
||||
canDefocus?: boolean;
|
||||
onExit?: (type: 'escape' | 'defocus' | 'explicit') => void;
|
||||
headContent: React.ReactNode;
|
||||
preferredWidth?: number;
|
||||
};
|
||||
|
||||
export const Popover = (props: PopoverProps) => {
|
||||
globalOnKeyUp(['Escape'], () => props.onExit?.('escape'), props.active);
|
||||
return (
|
||||
<>
|
||||
<PopoverBase active={props.active} preferredWidth={props.preferredWidth}>
|
||||
<PopoverHead>
|
||||
<PopoverHeadCloser onClick={() => props.onExit?.('explicit')}>
|
||||
<IoMdClose />
|
||||
</PopoverHeadCloser>
|
||||
<div>{props.headContent}</div>
|
||||
</PopoverHead>
|
||||
<PopoverContent>{props.children()}</PopoverContent>
|
||||
</PopoverBase>
|
||||
{props.canDefocus && (
|
||||
<DefocusHandler
|
||||
active={props.active}
|
||||
onClick={() => props.onExit?.('defocus')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
1
packages/design-system/atoms/popover/index.ts
Normal file
1
packages/design-system/atoms/popover/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Popover';
|
13
packages/design-system/atoms/role/Role.spec.tsx
Normal file
13
packages/design-system/atoms/role/Role.spec.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { shallow } from 'enzyme';
|
||||
import * as React from 'react';
|
||||
import { roleCategory } from '../../fixtures/storyData';
|
||||
import { Role } from './Role';
|
||||
|
||||
it('fires an OnClick handler when clicked', () => {
|
||||
const onClickMock = jest.fn();
|
||||
const view = shallow(
|
||||
<Role role={roleCategory[0]} selected={true} onClick={onClickMock} />
|
||||
);
|
||||
view.simulate('click');
|
||||
expect(onClickMock).toBeCalledWith(false);
|
||||
});
|
79
packages/design-system/atoms/role/Role.stories.tsx
Normal file
79
packages/design-system/atoms/role/Role.stories.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { withColors } from '@roleypoly/design-system/atoms/colors/withColors';
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { roleCategory } from '../../fixtures/storyData';
|
||||
import { Role as RoleComponent } from './Role';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Role',
|
||||
component: RoleComponent,
|
||||
decorators: [withColors],
|
||||
};
|
||||
|
||||
const Demo = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const RoleWithState = (props: any) => {
|
||||
const [selected, updateSelected] = React.useState(false);
|
||||
return (
|
||||
<div style={{ padding: 5 }}>
|
||||
<RoleComponent
|
||||
{...props}
|
||||
selected={selected}
|
||||
onClick={(next) => updateSelected(next)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Role = () => (
|
||||
<Demo>
|
||||
{roleCategory.map((c, idx) => (
|
||||
<RoleWithState key={idx} role={c} />
|
||||
))}
|
||||
</Demo>
|
||||
);
|
||||
|
||||
export const Selected = () => (
|
||||
<Demo>
|
||||
{roleCategory.map((c, idx) => (
|
||||
<RoleComponent key={idx} role={c} selected={true} />
|
||||
))}
|
||||
</Demo>
|
||||
);
|
||||
|
||||
export const Unselected = () => (
|
||||
<Demo>
|
||||
{roleCategory.map((c, idx) => (
|
||||
<RoleComponent key={idx} role={c} selected={false} />
|
||||
))}
|
||||
</Demo>
|
||||
);
|
||||
|
||||
export const DisabledByPosition = () => (
|
||||
<Demo>
|
||||
{roleCategory.map((c, idx) => (
|
||||
<RoleComponent
|
||||
key={idx}
|
||||
role={{ ...c, safety: 1 }}
|
||||
selected={false}
|
||||
disabled
|
||||
/>
|
||||
))}
|
||||
</Demo>
|
||||
);
|
||||
|
||||
export const DisabledByDanger = () => (
|
||||
<Demo>
|
||||
{roleCategory.map((c, idx) => (
|
||||
<RoleComponent
|
||||
key={idx}
|
||||
role={{ ...c, safety: 2 }}
|
||||
selected={false}
|
||||
disabled
|
||||
/>
|
||||
))}
|
||||
</Demo>
|
||||
);
|
82
packages/design-system/atoms/role/Role.styled.tsx
Normal file
82
packages/design-system/atoms/role/Role.styled.tsx
Normal file
|
@ -0,0 +1,82 @@
|
|||
import { palette } from '@roleypoly/design-system/atoms/colors';
|
||||
import { transitions } from '@roleypoly/design-system/atoms/timings';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
export type StyledProps = {
|
||||
selected: boolean;
|
||||
defaultColor: boolean;
|
||||
disabled: boolean;
|
||||
type?: 'delete';
|
||||
};
|
||||
|
||||
export const Circle = styled.div<StyledProps>`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 25px;
|
||||
background-color: ${(props) =>
|
||||
props.defaultColor && !props.selected ? 'transparent' : 'var(--role-color)'};
|
||||
border: 1px solid
|
||||
${(props) =>
|
||||
props.defaultColor
|
||||
? 'var(--role-color)'
|
||||
: props.selected
|
||||
? 'var(--role-accent)'
|
||||
: 'transparent'};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: border ${transitions.in2in}s ease-in-out,
|
||||
background-color ${transitions.in2in}s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
fill-opacity: ${(props) => (props.selected || props.disabled ? 1 : 0)};
|
||||
transition: fill-opacity ${transitions.in2in}s ease-in-out;
|
||||
fill: ${(props) =>
|
||||
props.disabled && props.defaultColor
|
||||
? 'var(--role-color)'
|
||||
: 'var(--role-contrast)'};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Outer = styled.div<StyledProps>`
|
||||
border-radius: 24px;
|
||||
background-color: ${(props) =>
|
||||
props.selected && !props.defaultColor ? 'var(--role-color)' : palette.taupe100};
|
||||
color: ${(props) => (props.selected ? 'var(--role-contrast)' : palette.grey600)};
|
||||
transition: color ${transitions.in2in}s ease-in-out,
|
||||
background-color ${transitions.in2in}s ease-in-out,
|
||||
transform ${transitions.actionable}s ease-in-out,
|
||||
box-shadow ${transitions.actionable}s ease-in-out;
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
${(props) =>
|
||||
!props.disabled
|
||||
? css`
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
${Circle} svg {
|
||||
fill-opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 0 0 transparent;
|
||||
}
|
||||
`
|
||||
: null};
|
||||
`;
|
||||
|
||||
export const Text = styled.div`
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
100
packages/design-system/atoms/role/Role.tsx
Normal file
100
packages/design-system/atoms/role/Role.tsx
Normal file
|
@ -0,0 +1,100 @@
|
|||
import { numberToChroma } from '@roleypoly/design-system/atoms/colors';
|
||||
import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission';
|
||||
import { Role as RPCRole, RoleSafety } from '@roleypoly/types';
|
||||
import chroma from 'chroma-js';
|
||||
import * as React from 'react';
|
||||
import { FaCheck, FaTimes } from 'react-icons/fa';
|
||||
import * as styled from './Role.styled';
|
||||
|
||||
type Props = {
|
||||
role: RPCRole;
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
onClick?: (newState: boolean) => void;
|
||||
tooltipId?: string;
|
||||
type?: 'delete';
|
||||
};
|
||||
|
||||
const getColorsFromBase = (baseColor: chroma.Color, contrastCheckThrow: number = 5) => {
|
||||
// Which has more contrast? Stepping up or stepping down?
|
||||
const contrastColorUp = baseColor.brighten(contrastCheckThrow);
|
||||
const contrastColorDown = baseColor.darken(contrastCheckThrow);
|
||||
|
||||
if (
|
||||
chroma.contrast(baseColor, contrastColorUp) >
|
||||
chroma.contrast(baseColor, contrastColorDown)
|
||||
) {
|
||||
return {
|
||||
contrastColor: baseColor.brighten(3),
|
||||
accentColor: baseColor.brighten(2),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
contrastColor: baseColor.darken(3),
|
||||
accentColor: baseColor.darken(2),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const Role = (props: Props) => {
|
||||
const colorVars = {
|
||||
'--role-color': 'white',
|
||||
'--role-contrast': 'hsl(0,0,0%)',
|
||||
'--role-accent': 'hsl(0,0,70%)',
|
||||
};
|
||||
|
||||
if (props.role.color !== 0) {
|
||||
const baseColor = numberToChroma(props.role.color);
|
||||
const { accentColor, contrastColor } = getColorsFromBase(baseColor, 5);
|
||||
colorVars['--role-color'] = baseColor.css();
|
||||
colorVars['--role-accent'] = accentColor.css();
|
||||
colorVars['--role-contrast'] = contrastColor.css();
|
||||
}
|
||||
|
||||
const styledProps: styled.StyledProps = {
|
||||
selected: props.selected,
|
||||
defaultColor: props.role.color === 0,
|
||||
disabled: !!props.disabled,
|
||||
type: props.type,
|
||||
};
|
||||
|
||||
const extra = !props.disabled
|
||||
? {}
|
||||
: {
|
||||
'data-tip': disabledReason(props.role),
|
||||
'data-for': props.tooltipId,
|
||||
};
|
||||
|
||||
return (
|
||||
<styled.Outer
|
||||
{...styledProps}
|
||||
style={colorVars as any}
|
||||
onClick={() => !props.disabled && props.onClick?.(!props.selected)}
|
||||
{...extra}
|
||||
>
|
||||
<styled.Circle {...styledProps}>
|
||||
{!props.disabled && props.type !== 'delete' ? <FaCheck /> : <FaTimes />}
|
||||
</styled.Circle>
|
||||
<styled.Text>{props.role.name}</styled.Text>
|
||||
</styled.Outer>
|
||||
);
|
||||
};
|
||||
|
||||
const disabledReason = (role: RPCRole) => {
|
||||
switch (role.safety) {
|
||||
case RoleSafety.HigherThanBot:
|
||||
return `This role is above Roleypoly's own role.`;
|
||||
case RoleSafety.DangerousPermissions:
|
||||
const rolePermissions = BigInt(role.permissions);
|
||||
let permissionHits: string[] = [];
|
||||
|
||||
evaluatePermission(rolePermissions, permissions.ADMINISTRATOR) &&
|
||||
permissionHits.push('Administrator');
|
||||
evaluatePermission(rolePermissions, permissions.MANAGE_ROLES) &&
|
||||
permissionHits.push('Manage Roles');
|
||||
|
||||
return `This role has unsafe permissions: ${permissionHits.join(', ')}`;
|
||||
default:
|
||||
return `This role is disabled.`;
|
||||
}
|
||||
};
|
1
packages/design-system/atoms/role/index.ts
Normal file
1
packages/design-system/atoms/role/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './Role';
|
14
packages/design-system/atoms/space/Space.stories.tsx
Normal file
14
packages/design-system/atoms/space/Space.stories.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import * as React from 'react';
|
||||
import { Space as SpaceComponent } from './Space';
|
||||
|
||||
export default {
|
||||
title: 'Atoms',
|
||||
};
|
||||
|
||||
export const Space = () => (
|
||||
<>
|
||||
hello world
|
||||
<SpaceComponent />
|
||||
but im over here
|
||||
</>
|
||||
);
|
5
packages/design-system/atoms/space/Space.tsx
Normal file
5
packages/design-system/atoms/space/Space.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import styled from 'styled-components';
|
||||
|
||||
export const Space = styled.div`
|
||||
height: 15px;
|
||||
`;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue