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 {