Skip to content

Commit

Permalink
Improve area editing (#1012)
Browse files Browse the repository at this point in the history
- Create a separate edit area dashboard
- Separate edit vs view for ease of development
- Migrate area page to Next13 server components (retiring the old crag page)
  • Loading branch information
vnugent authored Dec 1, 2023
1 parent efb75ff commit 48acddd
Show file tree
Hide file tree
Showing 103 changed files with 3,404 additions and 806 deletions.
2 changes: 1 addition & 1 deletion lighthouserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"startServerCommand": "yarn start",
"url": [
"http://localhost:3000",
"http://localhost:3000/areas/bea6bf11-de53-5046-a5b4-b89217b7e9bc"
"http://localhost:3000/area/db1e8ba-a40e-587c-88a4-64f5ea814b8e/usa"
]
},
"upload": {
Expand Down
1 change: 0 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ module.exports = {
typescript: {
ignoreBuildErrors: false
},
generateEtags: false,
webpack (config) { // required by @svgr/webpack lib
const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.('.svg'))

Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@lexical/react": "^0.7.5",
"@math.gl/web-mercator": "3.6.2",
"@openbeta/sandbag": "^0.0.51",
"@phosphor-icons/react": "^2.0.14",
"@radix-ui/react-alert-dialog": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.0",
"@radix-ui/react-dropdown-menu": "^2.0.1",
Expand All @@ -33,7 +34,7 @@
"axios": "^0.24.0",
"classnames": "^2.3.1",
"csvtojson": "^2.0.10",
"daisyui": "^3.5.0",
"daisyui": "^3.9.4",
"date-fns": "^2.28.0",
"file-saver": "^2.0.5",
"fuse.js": "^6.6.2",
Expand All @@ -48,21 +49,23 @@
"next-auth": "^4.22.1",
"nprogress": "^0.2.0",
"rc-slider": "^10.0.0-alpha.5",
"react": "^18.0.0",
"react": "^18.2.0",
"react-content-loader": "^6.2.0",
"react-dom": "^18.0.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.1",
"react-hook-form": "^7.34.2",
"react-hotkeys-hook": "^3.4.7",
"react-infinite-scroll-component": "^6.1.0",
"react-map-gl": "^7.0.10",
"react-markdown": "^9.0.1",
"react-paginate": "^8.1.3",
"react-responsive": "^9.0.0-beta.6",
"react-swipeable": "^7.0.0",
"react-toastify": "^9.1.1",
"react-use": "^17.4.0",
"recharts": "^2.7.2",
"simple-statistics": "^7.8.3",
"slugify": "^1.6.6",
"swr": "^2.1.5",
"tailwindcss-radix": "^2.5.0",
"typesense": "^1.2.1",
Expand Down
20 changes: 20 additions & 0 deletions src/app/api/updateAreaPage/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath } from 'next/cache'
import { validate } from 'uuid'

export const dynamic = 'force-dynamic'
export const fetchCache = 'force-no-store'

/**
* Endpoint: /api/updateAreaPage
*/
export async function GET (request: NextRequest): Promise<any> {
const uuid = request.nextUrl.searchParams.get('uuid') as string
if (uuid == null || !validate(uuid)) {
return NextResponse.json({ message: 'Missing uuid in query string' })
} else {
revalidatePath(`/area/${uuid}`, 'page')
revalidatePath(`/editArea/${uuid}`, 'layout')
return NextResponse.json({ message: 'OK' }, { status: 200 })
}
}
8 changes: 8 additions & 0 deletions src/app/area/[[...slug]]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { AreaPageContainer } from '@/app/components/ui/AreaPageContainer'

/**
* Loading skeleton for /area/<id> page.
*/
export default function Loading (): JSX.Element {
return (<AreaPageContainer />)
}
202 changes: 202 additions & 0 deletions src/app/area/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { notFound, permanentRedirect } from 'next/navigation'
import Link from 'next/link'
import { Metadata } from 'next'
import { validate } from 'uuid'
import { MapPinLine, Lightbulb, ArrowRight } from '@phosphor-icons/react/dist/ssr'
import 'mapbox-gl/dist/mapbox-gl.css'
import Markdown from 'react-markdown'

import PhotoMontage, { UploadPhotoCTA } from '@/components/media/PhotoMontage'
import { getArea } from '@/js/graphql/getArea'
import { StickyHeaderContainer } from '@/app/components/ui/StickyHeaderContainer'
import { GluttenFreeCrumbs } from '@/components/ui/BreadCrumbs'
import { ArticleLastUpdate } from '@/components/edit/ArticleLastUpdate'
import { getMapHref, getFriendlySlug, getAreaPageFriendlyUrl, sanitizeName } from '@/js/utils'
import { LazyAreaMap } from '@/components/area/areaMap'
import { AreaPageContainer } from '@/app/components/ui/AreaPageContainer'
import { AreaPageActions } from '../../components/AreaPageActions'
import { SubAreasSection } from './sections/SubAreasSection'
import { ClimbListSection } from './sections/ClimbListSection'
import { CLIENT_CONFIG } from '@/js/configs/clientConfig'
/**
* Page cache settings
*/
export const revalidate = 86400 // 24 hours
export const fetchCache = 'force-no-store' // opt out of Nextjs version of 'fetch'

interface PageSlugType {
slug: string []
}
export interface PageWithCatchAllUuidProps {
params: PageSlugType
}

/**
* Area/crag page
*/
export default async function Page ({ params }: PageWithCatchAllUuidProps): Promise<any> {
const areaUuid = parseUuidAsFirstParam({ params })
const pageData = await getArea(areaUuid)
if (pageData == null) {
notFound()
}

const userProvidedSlug = getFriendlySlug(params.slug?.[1] ?? '')

const { area } = pageData

const photoList = area?.media ?? []
const { uuid, pathTokens, ancestors, areaName, content, authorMetadata, metadata } = area
const { description } = content
const { lat, lng } = metadata

const correctSlug = getFriendlySlug(areaName)

if (correctSlug !== userProvidedSlug) {
permanentRedirect(getAreaPageFriendlyUrl(uuid, areaName))
}

return (
<AreaPageContainer
photoGallery={
photoList.length === 0
? <UploadPhotoCTA />
: <PhotoMontage photoList={photoList} />
}
pageActions={<AreaPageActions areaName={areaName} uuid={uuid} />}
breadcrumbs={
<StickyHeaderContainer>
<GluttenFreeCrumbs pathTokens={pathTokens} ancestors={ancestors} />
</StickyHeaderContainer>
}
map={
<LazyAreaMap
focused={null}
selected={area.id}
subAreas={area.children}
area={area}
/>
}
>
<div className='area-climb-page-summary'>
<div className='area-climb-page-summary-left'>
<h1>{areaName}</h1>

<div className='mt-6 flex flex-col text-xs text-secondary border-t border-b divide-y'>
<a
href={getMapHref({
lat,
lng
})}
target='blank'
className='flex items-center gap-2 py-3'
>
<MapPinLine size={20} />
<span className='mt-0.5'>
<b>LAT,LNG</b>&nbsp;
<span className='link-dotted'>
{lat.toFixed(5)}, {lng.toFixed(5)}
</span>
</span>
</a>
<ArticleLastUpdate {...authorMetadata} />
</div>
</div>

<div className='area-climb-page-summary-right'>
<div className='flex items-center gap-2'>
<h3>Description</h3>
<span className='text-xs inline-block align-baseline'>
[
<Link
href={`/editArea/${uuid}/general#description`}
target='_new'
className='hover:underline'
>
Edit
</Link>]
</span>
</div>
{(description == null || description.trim() === '') && <EditDescriptionCTA uuid={uuid} />}
<Markdown>{description}</Markdown>
</div>

</div>

<SubAreasSection area={area} />
<ClimbListSection area={area} />
</AreaPageContainer>
)
}

/**
* Extract and validate uuid as the first param in a catch-all route
*/
const parseUuidAsFirstParam = ({ params }: PageWithCatchAllUuidProps): string => {
if (params.slug.length === 0) {
notFound()
}

const uuid = params.slug[0]
if (!validate(uuid)) {
console.error('Invalid uuid', uuid)
notFound()
}
return uuid
}

const EditDescriptionCTA: React.FC<{ uuid: string }> = ({ uuid }) => (
<div role='alert' className='alert'>
<Lightbulb size={24} />
<div className='text-sm'>No information available. Be the first to&nbsp;
<Link href={`/editArea/${uuid}/general#description`} target='_new' className='link-dotted inline-flex items-center gap-1'>
add a description <ArrowRight size={16} />
</Link>
</div>
</div>
)

/**
* List of area pages to prebuild
*/
export function generateStaticParams (): PageSlugType[] {
return [
{ slug: ['bea6bf11-de53-5046-a5b4-b89217b7e9bc'] }, // Red Rock
{ slug: ['78da26bc-cd94-5ac8-8e1c-815f7f30a28b'] }, // Red River Gorge
{ slug: ['1db1e8ba-a40e-587c-88a4-64f5ea814b8e'] }, // USA
{ slug: ['ab48aed5-2e8d-54bb-b099-6140fe1f098f'] }, // Colorado
{ slug: ['decc1251-4a67-52b9-b23f-3243e10e93d0'] }, // Boulder
{ slug: ['f166e672-4a52-56d3-94f1-14c876feb670'] }, // Indian Creek
{ slug: ['5f0ed4d8-ebb0-5e78-ae15-ba7f1b3b5c51'] }, // Wasatch range
{ slug: ['b1166235-3328-5537-b5ed-92f406ea8495'] }, // Lander
{ slug: ['9abad566-2113-587e-95a5-b3abcfaa28ac'] } // Ten Sleep
]
}

// Page metadata
export async function generateMetadata ({ params }: PageWithCatchAllUuidProps): Promise<Metadata> {
const areaUuid = parseUuidAsFirstParam({ params })

const { area: { areaName, pathTokens, media } } = await getArea(areaUuid, 'cache-first')

let wall = ''
if (pathTokens.length >= 2) {
// Get the ancestor area's name
wall = sanitizeName(pathTokens[pathTokens.length - 2]) + ' • '
}

const name = sanitizeName(areaName)

const previewImage = media.length > 0 ? `${CLIENT_CONFIG.CDN_BASE_URL}/${media[0].mediaUrl}?w=1200q=75` : null

const description = `Community knowledge • ${wall}${name}`

return {
title: `${name} climbing area`,
description,
openGraph: {
description,
...previewImage != null && { images: previewImage }
}
}
}
30 changes: 30 additions & 0 deletions src/app/area/[[...slug]]/sections/ClimbListSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Link from 'next/link'
import { Plus } from '@phosphor-icons/react/dist/ssr'
import { ClimbList } from '@/app/editArea/[slug]/general/components/climb/ClimbListForm'
import { AreaType } from '@/js/types'
/**
* Sub-areas section
*/
export const ClimbListSection: React.FC<{ area: AreaType }> = ({ area }) => {
const { uuid, gradeContext, climbs, metadata } = area
if (!metadata.leaf) return null
return (
<section className='w-full mt-16'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<h3 className='flex items-center gap-4'>{climbs.length} Climbs</h3>
</div>
<div className='flex items-center gap-2'>
<span className='text-sm italic'>Coming soon:</span>
<Link href={`/editArea/${uuid}/general#addArea`} className='btn-disabled btn btn-sm'>
<Plus size={18} weight='bold' /> New Climbs
</Link>
</div>
</div>

<hr className='my-6 border-2 border-base-content' />

<ClimbList gradeContext={gradeContext} areaMetadata={metadata} climbs={climbs} />
</section>
)
}
29 changes: 29 additions & 0 deletions src/app/area/[[...slug]]/sections/SubAreasSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Link from 'next/link'
import { PlusCircle } from '@phosphor-icons/react/dist/ssr'
import { AreaList } from 'app/editArea/[slug]/general/components/AreaList'
import { AreaEntityBullet } from '@/components/cues/Entities'
import { AreaType } from '@/js/types'

/**
* Sub-areas section
*/
export const SubAreasSection: React.FC<{ area: AreaType } > = ({ area }) => {
const { uuid, children, metadata: { leaf } } = area
if (leaf) return null
return (
<section className='w-full mt-16'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<h3 className='flex items-center gap-4'><AreaEntityBullet />{children.length} Areas</h3>
</div>
<Link href={`/editArea/${uuid}/general#addArea`} target='_new' className='btn btn-sm btn-accent'>
<PlusCircle size={16} /> New Areas
</Link>
</div>

<hr className='my-6 border-2 border-base-content' />

<AreaList parentUuid={uuid} areas={children} />
</section>
)
}
27 changes: 27 additions & 0 deletions src/app/components/AreaPageActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Link from 'next/link'
import { PencilSimple, ArrowElbowLeftDown } from '@phosphor-icons/react/dist/ssr'
import { ShareAreaLinkButton } from '@/app/components/ShareAreaLinkButton'
import { UploadPhotoButton } from '@/components/media/PhotoUploadButtons'

/**
* Main action bar for area page
*/
export const AreaPageActions: React.FC<{ uuid: string, areaName: string } > = ({ uuid, areaName }) => (
<ul className='max-w-sm md:max-w-md flex items-center justify-between gap-2 w-full'>
<Link href={`/editArea/${uuid}`} target='_new' className='btn btn-solid btn-accent shadow-md'>
<PencilSimple size={20} weight='duotone' /> Edit
</Link>

<UploadPhotoButton />

<Link href='#map' className='btn'>
<ArrowElbowLeftDown size={20} className='hidden md:inline' /> Map
</Link>
<ShareAreaLinkButton uuid={uuid} areaName={areaName} />
</ul>
)

/**
* Skeleton. Height = actual component's button height.
*/
export const AreaPageActionsSkeleton: React.FC = () => (<div className='w-80 bg-base-200 h-9 rounded-btn' />)
3 changes: 2 additions & 1 deletion src/app/components/InternationalToC.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Link from 'next/link'
import { SectionContainer } from './ui/SectionContainer'
import { INTERNATIONAL_DATA, ToCCountry } from './international-data'
import { ToCAreaEntry } from './USAToC'
import { getAreaPageFriendlyUrl } from '@/js/utils'

/**
* International table of content
Expand All @@ -24,7 +25,7 @@ const CountryCard: React.FC<{ country: ToCCountry }> = ({ country }) => {
const { areaName, uuid, children } = country
return (
<div className='mb-10 break-inside-avoid-column break-inside-avoid'>
<Link href={`/crag/${uuid}`}>
<Link href={getAreaPageFriendlyUrl(uuid, areaName)}>
<span className=' font-semibold'>{areaName}</span>
</Link>
<hr className='mb-2 border-1 border-base-content/60' />
Expand Down
Loading

1 comment on commit 48acddd

@vercel
Copy link

@vercel vercel bot commented on 48acddd Dec 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.