From 439cba3a6209851e6d2e85cef01d2fdde1f0e7ca Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Thu, 10 Jun 2021 09:54:45 +0800 Subject: [PATCH] Implement browsing of ringtones, update stories The browsing of ringtones are now in a pagination-based format. The response from the backend has now been changed in order to account the total ringtones count, although there can be some optimizations regarding the repetitive queries in the future (such as pagination tokens, caching). The ringtones lack a way to get the composer's user details such as usernames; this will be implemented in the future. --- REQUIREMENTS.md | 7 +- .../molecules/presentation/Card/index.tsx | 40 +++ .../RingtoneCardDisplay/index.tsx | 68 +++++ .../templates/BrowseRingtones/index.tsx | 237 ++++++++++++++++++ .../templates/CreateRingtone/index.tsx | 31 +-- packages/app-web/src/data/layout.ts | 32 +++ .../app-web/src/modules/ringtone/client.ts | 7 + packages/app-web/src/pages/index.tsx | 97 ++++++- .../src/pages/my/create/ringtones/[id].tsx | 5 - .../src/pages/my/create/ringtones/index.tsx | 5 - packages/app-web/src/utils/api/fetch.ts | 4 +- .../app-web/src/utils/presentation/date.ts | 7 + .../src/modules/ringtone/controller.ts | 6 +- .../src/modules/ringtone/service.ts | 175 +++++++------ 14 files changed, 581 insertions(+), 140 deletions(-) create mode 100644 packages/app-web/src/components/molecules/presentation/Card/index.tsx create mode 100644 packages/app-web/src/components/organisms/presentation/RingtoneCardDisplay/index.tsx create mode 100644 packages/app-web/src/components/templates/BrowseRingtones/index.tsx create mode 100644 packages/app-web/src/data/layout.ts create mode 100644 packages/app-web/src/utils/presentation/date.ts diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 9b3ba56..5531f56 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -27,8 +27,13 @@ - As a client, I want to search for ringtones. - [ ] In the front-end, the client requests provides a search keyword to request for ringtones from the back-end. - - [X] In the back-end, the server retrieves the ringtones whose name matches the search keyword provided by the + - [ ] In the back-end, the server retrieves the ringtones whose name matches the search keyword provided by the front-end. + - [X] The server must retrieve ringtones containing the search keywords in the name + - Query `foo bar` must match names containing `foo` _or_ `bar`. + - [ ] The server must retrieve ringtones with note data similar to the search keyword as melody + - The server should match data `8c4 8d#5` and `4c5 4d#5` for the query `c d#`. + - The search should ignore rests. - As a client, I want to search for composers. - [ ] In the front-end, the client requests provides a search keyword to request for composers from the back-end. - [X] In the back-end, the server retrieves the composers whose name matches the search keyword provided by the diff --git a/packages/app-web/src/components/molecules/presentation/Card/index.tsx b/packages/app-web/src/components/molecules/presentation/Card/index.tsx new file mode 100644 index 0000000..1b96ca9 --- /dev/null +++ b/packages/app-web/src/components/molecules/presentation/Card/index.tsx @@ -0,0 +1,40 @@ +import {FC} from 'react'; +import styled from 'styled-components'; + +const Base = styled('div')({ + borderRadius: '0.25rem', + position: 'relative', + padding: '1rem', + boxSizing: 'border-box', + '::after': { + content: "''", + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + boxSizing: 'border-box', + borderRadius: 'inherit', + pointerEvents: 'none', + border: '1px solid', + }, +}) + +type Props = { + className?: string, +} + +const Card: FC = ({ + children, + ...etcProps +}) => { + return ( + + {children} + + ) +} + +export default Card diff --git a/packages/app-web/src/components/organisms/presentation/RingtoneCardDisplay/index.tsx b/packages/app-web/src/components/organisms/presentation/RingtoneCardDisplay/index.tsx new file mode 100644 index 0000000..a991f1c --- /dev/null +++ b/packages/app-web/src/components/organisms/presentation/RingtoneCardDisplay/index.tsx @@ -0,0 +1,68 @@ +import {FC} from 'react'; +import styled from 'styled-components'; +import {formatCreatedAt} from '../../../../utils/presentation/date'; + +const Primary = styled('div')({ + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + justifyContent: 'space-between', + alignItems: 'center', + gap: '0.5rem', + '@media (min-width: 720px)': { + gridTemplateColumns: '1fr', + }, +}) + +const Name = styled('div')({ + fontWeight: 'bolder', +}) + +const CreatedAt = styled('time')({ + fontSize: '0.75rem', + textAlign: 'right', + '@media (min-width: 720px)': { + textAlign: 'left', + }, +}) + +const Data = styled('div')({ + marginTop: '0.5rem', + fontFamily: 'monospace', + fontSize: '0.75rem', + height: '3.75em', + lineHeight: 1.25, + overflow: 'hidden', +}) + +type Props = { + name: string, + data: string, + createdAt: Date, +} + +const RingtoneCardDisplay: FC = ({ + name, + data, + createdAt: rawCreatedAt, +}) => { + const createdAt = new Date(rawCreatedAt) + return ( + <> + + + {name} + + + {formatCreatedAt(createdAt)} + + + + {data} + + + ) +} + +export default RingtoneCardDisplay diff --git a/packages/app-web/src/components/templates/BrowseRingtones/index.tsx b/packages/app-web/src/components/templates/BrowseRingtones/index.tsx new file mode 100644 index 0000000..b82bdff --- /dev/null +++ b/packages/app-web/src/components/templates/BrowseRingtones/index.tsx @@ -0,0 +1,237 @@ +import {FC, FormEventHandler} from 'react'; +import styled from 'styled-components'; +import { LeftSidebarWithMenu } from '@theoryofnekomata/viewfinder' +import Brand from '../../molecules/brand/Brand'; +import Link from '../../molecules/navigation/Link'; +import OmnisearchForm from '../../organisms/forms/Omnisearch'; +import {Session} from '@auth0/nextjs-auth0'; +import {models} from '@tonality/library-common'; +import RingtoneCardDisplay from '../../organisms/presentation/RingtoneCardDisplay'; +import Card from '../../molecules/presentation/Card'; +import {moreLinkMenuItem, sidebarMenuItems} from '../../../data/layout'; +import ActionButton from '../../molecules/forms/ActionButton'; +import NumericInput from '../../molecules/forms/NumericInput'; + +const TopBarComponent = styled('div')({ + backgroundColor: 'var(--color-bg, white)', +}) + +const SidebarMenuComponent = styled('div')({ + backgroundColor: 'var(--color-bg, white)', +}) + +const Avatar = styled('img')({ + objectFit: 'cover', + objectPosition: 'center', + width: '3rem', + height: '3rem', + borderRadius: '50%', +}) + +const CardList = styled('div')({ + display: 'grid', + gap: '1rem', + margin: '2rem 0', + '@media (min-width: 720px)': { + gridTemplateColumns: 'repeat(2, 1fr)', + }, +}) + +const CardLink = styled(Link)({ + display: 'block', + textDecoration: 'none', +}) + +const StyledCard = styled(Card)({ + height: '100%', +}) + +const Footer = styled('div')({ + display: 'grid', + gap: '1rem', + gridTemplateColumns: '1fr', + margin: '2rem 0', + '@media (min-width: 720px)': { + gridTemplateColumns: 'auto 1fr auto', + }, +}) + +const TakeForm = styled('form')({ + display: 'grid', + gap: '1rem', + gridTemplateColumns: '1fr auto', +}) + +const BrowseArea = styled('div')({ + '@media (min-width: 720px)': { + gridColumn: 3, + }, +}) + +const TakeInput = styled(NumericInput)({ + '@media (min-width: 720px)': { + width: '6rem', + }, +}) + +type Props = { + onSearch?: FormEventHandler, + onNextPage?: FormEventHandler, + session?: Partial, + ringtones: models.Ringtone[], + skip?: number, + take?: number, + total?: number, + loading?: boolean, +} + +const BrowseRingtonesTemplate: FC = ({ + onSearch, + session, + ringtones, + skip = 0, + take = 10, + total = 0, + onNextPage, + loading, +}) => { + return ( + + } + userLink={ + + { + session + && ( + + ) + } + + } + topBarComponent={TopBarComponent} + sidebarMenuComponent={SidebarMenuComponent} + topBarCenter={ + + } + linkComponent={({ url, icon, label, }) => ( + + + + {icon} + + {label} + + + )} + moreLinkMenuItem={moreLinkMenuItem} + moreLinkComponent={({ url, icon, label, }) => ( + + + + {icon} + + {label} + + + )} + sidebarMenuItems={sidebarMenuItems} + > + + { + Array.isArray(ringtones) + && ( + <> + + {ringtones.map(r => ( + + + + + + ))} + +
+ + + + Set + + + + { + skip + take < total + && ( +
+ + + + Browse More + +
+ ) + } +
+
+ + ) + } +
+
+ ) +} + +export default BrowseRingtonesTemplate diff --git a/packages/app-web/src/components/templates/CreateRingtone/index.tsx b/packages/app-web/src/components/templates/CreateRingtone/index.tsx index c199407..5bcc1b7 100644 --- a/packages/app-web/src/components/templates/CreateRingtone/index.tsx +++ b/packages/app-web/src/components/templates/CreateRingtone/index.tsx @@ -7,6 +7,7 @@ import CreateRingtoneForm from '../../organisms/forms/CreateRingtone' import Link from '../../molecules/navigation/Link' import OmnisearchForm from '../../organisms/forms/Omnisearch' import Brand from '../../molecules/brand/Brand' +import {moreLinkMenuItem, sidebarMenuItems} from '../../../data/layout'; const TopBarComponent = styled('div')({ backgroundColor: 'var(--color-bg, white)', @@ -17,7 +18,7 @@ const SidebarMenuComponent = styled('div')({ }) const Padding = styled('div')({ - padding: '2rem 0', + margin: '2rem 0', }) const Avatar = styled('img')({ @@ -33,8 +34,6 @@ type Props = { onSubmit?: FormEventHandler, session: Partial, currentRingtone?: models.Ringtone, - composerRingtones: models.Ringtone[], - updateTempo: ({ songRef, dataRef, setTempo, }) => (...args: unknown[]) => void, updateView: ({ setNoteGlyph, setRestGlyph, noteGlyphs, restGlyphs, }) => (...args: unknown[]) => void, addRest: ({ formRef, dataRef, songRef, }) => (...args: unknown[]) => void, @@ -49,7 +48,6 @@ const CreateRingtoneTemplate: FC = ({ onSubmit, session, currentRingtone = {}, - composerRingtones = [], updateTempo, updateView, addRest, @@ -100,11 +98,7 @@ const CreateRingtoneTemplate: FC = ({ )} - moreLinkMenuItem={{ - label: 'More', - icon: 'M', - url: {}, - }} + moreLinkMenuItem={moreLinkMenuItem} moreLinkComponent={({ url, icon, label, }) => ( = ({ )} - sidebarMenuItems={[ - { - id: 'browse', - label: 'Browse', - icon: 'B', - url: { - pathname: '/ringtones', - }, - }, - { - id: 'compose', - label: 'Compose', - icon: 'C', - url: { - pathname: '/my/create/ringtone', - }, - }, - ]} + sidebarMenuItems={sidebarMenuItems} > { + const response = await this.fetchClient(endpoints.browse({ skip, take, })) + if (response.ok) { + return response.json() + } + } } diff --git a/packages/app-web/src/pages/index.tsx b/packages/app-web/src/pages/index.tsx index 1718aa2..2757321 100644 --- a/packages/app-web/src/pages/index.tsx +++ b/packages/app-web/src/pages/index.tsx @@ -1,17 +1,100 @@ import {GetServerSideProps, NextPage} from 'next'; -type Props = {} +import BrowseRingtonesTemplate from '../components/templates/BrowseRingtones'; +import {getSession, Session} from '@auth0/nextjs-auth0'; +import {models} from '@tonality/library-common'; +import RingtoneClient from '../modules/ringtone/client'; +import {useEffect, useState} from 'react'; +import getFormValues from '@theoryofnekomata/formxtra'; +import {useRouter} from 'next/router'; + +type Props = { + session: Partial, + ringtones: models.Ringtone[], + skip: number, + take: number, + total: number, +} + +const IndexPage: NextPage = ({ + session, + ringtones: ringtonesProp, + skip: skipProp, + take: takeProp, + total: totalProp, +}) => { + const [ringtoneClient, setRingtoneClient] = useState(null) + const [ringtones, setRingtones] = useState(ringtonesProp) + const [skip, setSkip] = useState(skipProp) + const [take, setTake] = useState(takeProp) + const [total, setTotal] = useState(totalProp) + const [loading, setLoading] = useState(false) + const router = useRouter() + + const getNextPage = async (e) => { + // e.preventDefault() + // const values = getFormValues(e.target) + // setLoading(true) + // try { + // const {data: ringtones, skip, take, total} = await ringtoneClient.browse({ + // skip: Number(values.skip), + // take: Number(values.take) + // }) + // setRingtones(ringtones) + // setSkip(skip) + // setTake(take) + // setTotal(total) + // router.push({ + // query: { + // skip: Number(values.skip), + // take: Number(values.take), + // }, + // }) + // } catch (err) { + // console.log(err) + // } + // setLoading(false) + } + + useEffect(() => { + setRingtoneClient(new RingtoneClient(process.env.NEXT_PUBLIC_API_BASE_URL, session)) + }, []) -const IndexPage: NextPage = () => { return ( - <> - Landing Page Here - + ); }; -export const getServerSideProps: GetServerSideProps = async (ctx) => { +export const getServerSideProps: GetServerSideProps = async ({ req, res, query }) => { + const authSession = getSession(req, res) + const session = authSession + ? { + idToken: authSession.idToken, + token_type: authSession.token_type, + user: authSession.user, + } + : null + const client = new RingtoneClient(process.env.NEXT_PUBLIC_API_BASE_URL, session) + const { data: ringtones, skip, take, total } = await client.browse({ + skip: query.skip ? Number(query.skip) : undefined, + take: query.take ? Number(query.take) : undefined, + }) + return { - props: {} + props: { + session, + ringtones, + skip, + take, + total, + } } } diff --git a/packages/app-web/src/pages/my/create/ringtones/[id].tsx b/packages/app-web/src/pages/my/create/ringtones/[id].tsx index b8699db..0863ca9 100644 --- a/packages/app-web/src/pages/my/create/ringtones/[id].tsx +++ b/packages/app-web/src/pages/my/create/ringtones/[id].tsx @@ -12,13 +12,11 @@ import {useRouter} from 'next/router'; type Props = { session: Partial, currentRingtone: models.Ringtone, - composerRingtones: models.Ringtone[], } const Page: NextPage = ({ session, currentRingtone, - composerRingtones, }) => { const [hydrated, setHydrated] = useState(false) const [ringtoneClient, setRingtoneClient] = useState(null) @@ -47,7 +45,6 @@ const Page: NextPage = ({ { const { id } = params const { idToken, token_type, user } = getSession(req, res) - const composerRingtones = [] const session = { idToken, token_type, @@ -83,7 +79,6 @@ export const getServerSideProps: GetServerSideProps = withPageAuthRequired({ props: { session, currentRingtone, - composerRingtones, }, } }, diff --git a/packages/app-web/src/pages/my/create/ringtones/index.tsx b/packages/app-web/src/pages/my/create/ringtones/index.tsx index 035b5ac..a337371 100644 --- a/packages/app-web/src/pages/my/create/ringtones/index.tsx +++ b/packages/app-web/src/pages/my/create/ringtones/index.tsx @@ -11,12 +11,10 @@ import {useRouter} from 'next/router'; type Props = { session: Partial, - composerRingtones: models.Ringtone[], } const Page: NextPage = ({ session, - composerRingtones, }) => { const [hydrated, setHydrated] = useState(false) const [ringtoneClient, setRingtoneClient] = useState(null) @@ -44,7 +42,6 @@ const Page: NextPage = ({ return ( = ({ export const getServerSideProps: GetServerSideProps = withPageAuthRequired({ getServerSideProps: async (ctx) => { const { idToken, token_type, user } = getSession(ctx.req, ctx.res) - const composerRingtones = [] const session = { idToken, token_type, @@ -70,7 +66,6 @@ export const getServerSideProps: GetServerSideProps = withPageAuthRequired({ return { props: { session, - composerRingtones, }, } }, diff --git a/packages/app-web/src/utils/api/fetch.ts b/packages/app-web/src/utils/api/fetch.ts index 467fd52..f86b811 100644 --- a/packages/app-web/src/utils/api/fetch.ts +++ b/packages/app-web/src/utils/api/fetch.ts @@ -1,5 +1,3 @@ -import {URLSearchParams} from 'url'; - export enum Method { HEAD = 'HEAD', GET = 'GET', @@ -45,7 +43,7 @@ export const createFetchClient: CreateFetchClient = ({ }) => { const theUrl = new URL(url, baseUrl) if (Boolean(query as unknown)) { - theUrl.search = new URLSearchParams(query).toString() + theUrl.search = new URLSearchParams(query as any).toString() } return ff(theUrl.toString(), { method, diff --git a/packages/app-web/src/utils/presentation/date.ts b/packages/app-web/src/utils/presentation/date.ts new file mode 100644 index 0000000..402445a --- /dev/null +++ b/packages/app-web/src/utils/presentation/date.ts @@ -0,0 +1,7 @@ +export const formatCreatedAt = (d: Date) => { + const month = d.getMonth().toString().padStart(2, '0') + const date = d.getDate().toString().padStart(2, '0') + const year = d.getFullYear().toString().padStart(4, '0') + + return `${year}-${month}-${date}` +} diff --git a/packages/service-core/src/modules/ringtone/controller.ts b/packages/service-core/src/modules/ringtone/controller.ts index d221cad..5d8e0ea 100644 --- a/packages/service-core/src/modules/ringtone/controller.ts +++ b/packages/service-core/src/modules/ringtone/controller.ts @@ -70,10 +70,11 @@ export class RingtoneControllerImpl implements RingtoneController { const skip = !isNaN(skipNumber) ? skipNumber : 0 const take = !isNaN(takeNumber) ? takeNumber : 10 const rawData = await this.ringtoneService.browse(skip, take, request.user?.sub) - const data = rawData.map(serializeRingtone) + const data = rawData.data.map(serializeRingtone) reply.raw.statusMessage = 'Multiple Ringtones Retrieved' return { + total: rawData.total, data, skip, take, @@ -88,10 +89,11 @@ export class RingtoneControllerImpl implements RingtoneController { try { const { 'q': query } = request.query const rawData = await this.ringtoneService.search(query, request.user?.sub) - const data = rawData.map(serializeRingtone) + const data = rawData.data.map(serializeRingtone) reply.raw.statusMessage = 'Search Results Retrieved' return { + total: rawData.total, data, } } catch (err) { diff --git a/packages/service-core/src/modules/ringtone/service.ts b/packages/service-core/src/modules/ringtone/service.ts index 3d62914..506828a 100644 --- a/packages/service-core/src/modules/ringtone/service.ts +++ b/packages/service-core/src/modules/ringtone/service.ts @@ -9,11 +9,16 @@ import { UpdateDeletedRingtoneError, } from './response'; +type Counted = { + total: number, + data: T[], +} + export default interface RingtoneService { get(id: Uuid, requesterUserSub?: string): Promise - browseByComposer(composerUserSub: string, skip?: number, take?: number, requesterUserSub?: string): Promise - browse(skip?: number, take?: number, requesterUserSub?: string): Promise - search(q?: string, requesterUserSub?: string): Promise + browseByComposer(composerUserSub: string, skip?: number, take?: number, requesterUserSub?: string): Promise> + browse(skip?: number, take?: number, requesterUserSub?: string): Promise> + search(q?: string, requesterUserSub?: string): Promise> create(data: Partial, requesterUserSub: string): Promise update(data: Partial, requesterUserSub: string): Promise softDelete(id: Uuid, requesterUserSub: string): Promise @@ -35,35 +40,30 @@ export class RingtoneServiceImpl implements RingtoneService { private readonly prismaClient: PrismaClient, ) {} - async browseByComposer(composerUserSub: string, skip: number = 0, take: number = 10, requesterUserSub?: string): Promise { - let rawData - - if (typeof requesterUserSub === 'string' && composerUserSub === requesterUserSub) { - rawData = await this.prismaClient.ringtone.findMany({ - where: { - composerUserSub: { - equals: composerUserSub, - }, - }, - skip, - take, - }) - } else { - rawData = await this.prismaClient.ringtone.findMany({ - where: { - composerUserSub: { - equals: composerUserSub, - }, - deletedAt: { - equals: null, - }, + async browseByComposer(composerUserSub: string, skip: number = 0, take: number = 10, requesterUserSub?: string): Promise> { + const baseArgs = { + where: { + composerUserSub: { + equals: composerUserSub, }, - skip, - take, - }) + }, + } + if (!(typeof requesterUserSub === 'string' && composerUserSub === requesterUserSub)) { + baseArgs.where['deletedAt'] = { + equals: null, + } + } + const args = { + ...baseArgs, + skip, + take, + } + const rawData = await this.prismaClient.ringtone.findMany(baseArgs) + const total = await this.prismaClient.ringtone.count(args) + return { + data: rawData.map(serializeRingtone), + total, } - - return rawData.map(serializeRingtone) } async get(id: Uuid, requesterUserSub?: string): Promise { @@ -89,77 +89,72 @@ export class RingtoneServiceImpl implements RingtoneService { return serializeRingtone(ringtone) } - async browse(skip: number = 0, take: number = 10, requesterUserSub?: string): Promise { - let rawData - if (typeof requesterUserSub === 'string') { - rawData = await this.prismaClient.ringtone.findMany({ - where: { - OR: [ - { - deletedAt: { - equals: null, - }, + async browse(skip: number = 0, take: number = 10, requesterUserSub?: string): Promise> { + const baseArgs = { + where: { + OR: [ + { + deletedAt: { + equals: null, }, - { - composerUserSub: { - equals: requesterUserSub, - }, - } - ], - }, - skip, - take, - }) - } else { - rawData = await this.prismaClient.ringtone.findMany({ - where: { - deletedAt: { - equals: null, - }, + } as Record,, + ] + }, + } + + if (typeof requesterUserSub === 'string') { + baseArgs.where.OR.push({ + composerUserSub: { + equals: requesterUserSub, }, - skip, - take, }) } - return rawData.map(serializeRingtone) + const args = { + ...baseArgs, + skip, + take, + } + + const rawData = await this.prismaClient.ringtone.findMany(args) + const total = await this.prismaClient.ringtone.count(baseArgs) + + return { + total, + data: rawData.map(serializeRingtone) + } } - async search(q: string = '', requesterUserSub?: string): Promise { - let rawData - if (typeof requesterUserSub === 'string') { - rawData = this.prismaClient.ringtone.findMany({ - where: { - OR: [ - { - deletedAt: { - equals: null, - }, - }, - { - composerUserSub: { - equals: requesterUserSub, - }, + async search(q: string = '', requesterUserSub?: string): Promise> { + const baseArgs = { + where: { + OR: [ + { + deletedAt: { + equals: null, }, - ], - name: { - contains: q, - }, + } as Record, + ], + name: { + contains: q, }, - }) - } else { - rawData = this.prismaClient.ringtone.findMany({ - where: { - name: { - contains: q, - }, - deletedAt: { - equals: null, - }, + }, + } + + if (typeof requesterUserSub === 'string') { + baseArgs.where.OR.push({ + composerUserSub: { + equals: requesterUserSub, }, - }) + },) + } + + const rawData = await this.prismaClient.ringtone.findMany(baseArgs) + const total = await this.prismaClient.ringtone.count(baseArgs) + return { + data: rawData.map(serializeRingtone), + total, } - return rawData.map(serializeRingtone) } async create(data: Partial, requesterUserSub: string): Promise {