Compare commits
6 Commits
aad67720c1
...
ecd1900acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd1900acc | ||
|
|
0b6327e2fb | ||
|
|
06608c7b5d | ||
|
|
333fa32eda | ||
|
|
1881032ed2 | ||
|
|
46e009f888 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/node_modules/
|
/node_modules/
|
||||||
|
.vscode/cspell.json
|
||||||
|
.vscode/.cspell/
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# React Router
|
# React Router
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -26,6 +26,5 @@
|
|||||||
"buttonClassName",
|
"buttonClassName",
|
||||||
"leftNodeClassName",
|
"leftNodeClassName",
|
||||||
"rightNodeClassName"
|
"rightNodeClassName"
|
||||||
],
|
]
|
||||||
"cSpell.words": []
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ const dataResponseSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type TNewsResponse = z.infer<typeof newsResponseSchema>
|
export type TNewsResponse = z.infer<typeof newsResponseSchema>
|
||||||
|
export type TAuthor = z.infer<typeof authorSchema>
|
||||||
|
|
||||||
export const getNews = async (parameters: THttpServer) => {
|
export const getNews = async (parameters: THttpServer) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
import type { JSX, SVGProps } from 'react'
|
|
||||||
|
|
||||||
export const LinkIcon = (
|
|
||||||
properties: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
x="0px"
|
|
||||||
y="0px"
|
|
||||||
width={95}
|
|
||||||
height={95}
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="currentColor"
|
|
||||||
strokeWidth="0"
|
|
||||||
viewBox="0 0 1024 1024"
|
|
||||||
{...properties}
|
|
||||||
>
|
|
||||||
<path d="M574 665.4a8.03 8.03 0 0 0-11.3 0L446.5 781.6c-53.8 53.8-144.6 59.5-204 0-59.5-59.5-53.8-150.2 0-204l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3l-39.8-39.8a8.03 8.03 0 0 0-11.3 0L191.4 526.5c-84.6 84.6-84.6 221.5 0 306s221.5 84.6 306 0l116.2-116.2c3.1-3.1 3.1-8.2 0-11.3L574 665.4zm258.6-474c-84.6-84.6-221.5-84.6-306 0L410.3 307.6a8.03 8.03 0 0 0 0 11.3l39.7 39.7c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c53.8-53.8 144.6-59.5 204 0 59.5 59.5 53.8 150.2 0 204L665.3 562.6a8.03 8.03 0 0 0 0 11.3l39.8 39.8c3.1 3.1 8.2 3.1 11.3 0l116.2-116.2c84.5-84.6 84.5-221.5 0-306.1zM610.1 372.3a8.03 8.03 0 0 0-11.3 0L372.3 598.7a8.03 8.03 0 0 0 0 11.3l39.6 39.6c3.1 3.1 8.2 3.1 11.3 0l226.4-226.4c3.1-3.1 3.1-8.2 0-11.3l-39.5-39.6z"></path>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -84,7 +84,7 @@ export const CarouselSection = (properties: TNews) => {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col justify-between">
|
<div className="flex flex-col justify-between">
|
||||||
<img
|
<img
|
||||||
className="aspect-[5/4] max-h-[280px] w-full rounded-md object-cover"
|
className="aspect-[174/100] max-h-[280px] w-full rounded-md object-cover sm:aspect-[5/4]"
|
||||||
src={featured}
|
src={featured}
|
||||||
alt={title}
|
alt={title}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export const CategorySection = (properties: TNews) => {
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'aspect-[5/4] w-full rounded-md object-cover',
|
'aspect-[174/100] w-full rounded-md object-cover sm:aspect-[5/4]',
|
||||||
)}
|
)}
|
||||||
src={featured}
|
src={featured}
|
||||||
alt={title}
|
alt={title}
|
||||||
|
|||||||
34
app/components/ui/news-author.tsx
Normal file
34
app/components/ui/news-author.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { TAuthor } from '~/apis/admin/get-news'
|
||||||
|
import { ProfileIcon } from '~/components/icons/profile'
|
||||||
|
import { formatDate } from '~/utils/formatter'
|
||||||
|
|
||||||
|
type TDetailNewsAuthor = {
|
||||||
|
author?: TAuthor
|
||||||
|
live_at?: string
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewsAuthor = ({ author, live_at, text }: TDetailNewsAuthor) => {
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex items-center gap-2 align-middle">
|
||||||
|
{author?.profile_picture ? (
|
||||||
|
<img
|
||||||
|
src={author?.profile_picture}
|
||||||
|
alt={author?.name}
|
||||||
|
className="h-12 w-12 rounded-full bg-[#C4C4C4] object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ProfileIcon className="h-12 w-12 rounded-full bg-[#C4C4C4]" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-md">{author?.name}</h4>
|
||||||
|
<p className="flex gap-1 text-sm">
|
||||||
|
<span>{live_at && `${formatDate(live_at)}`}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{text}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,59 +1,72 @@
|
|||||||
import type { FC } from 'react'
|
import { LinkIcon } from '@heroicons/react/20/solid'
|
||||||
import { Link } from 'react-router'
|
import { useState } from 'react'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import {
|
||||||
|
FacebookShareButton,
|
||||||
|
LinkedinShareButton,
|
||||||
|
TwitterShareButton,
|
||||||
|
} from 'react-share'
|
||||||
|
|
||||||
import { FacebookIcon } from '~/components/icons/facebook'
|
import { FacebookIcon } from '~/components/icons/facebook'
|
||||||
import { InstagramIcon } from '~/components/icons/instagram'
|
import { InstagramIcon } from '~/components/icons/instagram'
|
||||||
import { LinkIcon } from '~/components/icons/link-icon'
|
|
||||||
import { LinkedinIcon } from '~/components/icons/linkedin'
|
import { LinkedinIcon } from '~/components/icons/linkedin'
|
||||||
import { XIcon } from '~/components/icons/x'
|
import { XIcon } from '~/components/icons/x'
|
||||||
|
|
||||||
type SocialMediaProperties = {
|
type SocialShareButtonsProperties = {
|
||||||
className?: string
|
url: string
|
||||||
slug?: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataSocialMedia = [
|
export const SocialShareButtons = ({
|
||||||
{
|
url,
|
||||||
type: 'link',
|
title,
|
||||||
url: 'post-id/',
|
}: SocialShareButtonsProperties) => {
|
||||||
icon: LinkIcon,
|
const [showPopup, setShowPopup] = useState(false)
|
||||||
},
|
|
||||||
{
|
const handleCopyLink = () => {
|
||||||
type: 'facebook',
|
navigator.clipboard.writeText(url)
|
||||||
url: 'https://facebook.com/',
|
setShowPopup(true)
|
||||||
icon: FacebookIcon,
|
setTimeout(() => setShowPopup(false), 2000)
|
||||||
},
|
}
|
||||||
{
|
|
||||||
type: 'linkedin',
|
const handleInstagramShare = () => {
|
||||||
url: 'https://linkedin.com/',
|
const instagramUrl = `https://www.instagram.com/direct/new/?text=${encodeURIComponent(title + ' ' + url)}`
|
||||||
icon: LinkedinIcon,
|
window.open(instagramUrl, '_blank')
|
||||||
},
|
}
|
||||||
{
|
|
||||||
type: 'x',
|
|
||||||
url: 'https://x.com/',
|
|
||||||
icon: XIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'instagram',
|
|
||||||
url: 'https://instagram.com/',
|
|
||||||
icon: InstagramIcon,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const IconsSocial: FC<SocialMediaProperties> = ({ className }) => {
|
|
||||||
return (
|
return (
|
||||||
<div className={twMerge('flex gap-2', className)}>
|
<div className="flex items-center space-x-2">
|
||||||
{dataSocialMedia.map(({ url, icon: Icon }, index) => (
|
{showPopup && (
|
||||||
<Link
|
<div className="absolute top-0 rounded-lg border-2 border-gray-400 bg-white p-2 shadow-lg">
|
||||||
key={index}
|
Link berhasil disalin!
|
||||||
to={url}
|
</div>
|
||||||
target="_blank"
|
)}
|
||||||
rel="noopener noreferrer"
|
<button onClick={handleCopyLink}>
|
||||||
>
|
<LinkIcon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
|
||||||
<Icon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
|
</button>
|
||||||
</Link>
|
<FacebookShareButton
|
||||||
))}
|
url={url}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<FacebookIcon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
|
||||||
|
</FacebookShareButton>
|
||||||
|
|
||||||
|
<LinkedinShareButton
|
||||||
|
url={url}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<LinkedinIcon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
|
||||||
|
</LinkedinShareButton>
|
||||||
|
|
||||||
|
<TwitterShareButton
|
||||||
|
url={url}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<XIcon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
|
||||||
|
</TwitterShareButton>
|
||||||
|
|
||||||
|
<button onClick={handleInstagramShare}>
|
||||||
|
<InstagramIcon className="h-8 w-8 rounded-full bg-gray-400 p-2 sm:h-10 sm:w-10" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,21 +3,20 @@ import { useReadingTime } from 'react-hook-reading-time'
|
|||||||
import { useRouteLoaderData } from 'react-router'
|
import { useRouteLoaderData } from 'react-router'
|
||||||
|
|
||||||
import type { TTagResponse } from '~/apis/common/get-tags'
|
import type { TTagResponse } from '~/apis/common/get-tags'
|
||||||
import { ProfileIcon } from '~/components/icons/profile'
|
|
||||||
import { Card } from '~/components/ui/card'
|
import { Card } from '~/components/ui/card'
|
||||||
import { CarouselSection } from '~/components/ui/carousel-section'
|
import { CarouselSection } from '~/components/ui/carousel-section'
|
||||||
import { IconsSocial } from '~/components/ui/social-share'
|
import { NewsAuthor } from '~/components/ui/news-author'
|
||||||
|
import { SocialShareButtons } from '~/components/ui/social-share'
|
||||||
import { BERITA } from '~/data/contents'
|
import { BERITA } from '~/data/contents'
|
||||||
import type { loader } from '~/routes/_news.detail.$slug'
|
import type { loader } from '~/routes/_news.detail.$slug'
|
||||||
import { formatDate } from '~/utils/formatter'
|
|
||||||
|
|
||||||
export const NewsDetailPage = () => {
|
export const NewsDetailPage = () => {
|
||||||
const loaderData = useRouteLoaderData<typeof loader>(
|
const loaderData = useRouteLoaderData<typeof loader>(
|
||||||
'routes/_news.detail.$slug',
|
'routes/_news.detail.$slug',
|
||||||
)
|
)
|
||||||
|
const currentUrl = globalThis.location
|
||||||
const { newsDetailData } = loaderData || {}
|
const { newsDetailData } = loaderData || {}
|
||||||
const { title, content, featured_image, slug, author, live_at, tags } =
|
const { title, content, featured_image, author, live_at, tags } =
|
||||||
newsDetailData || {}
|
newsDetailData || {}
|
||||||
|
|
||||||
const { text } = useReadingTime(content || '')
|
const { text } = useReadingTime(content || '')
|
||||||
@ -30,31 +29,18 @@ export const NewsDetailPage = () => {
|
|||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* START TODO: create component for this section */}
|
|
||||||
<div className="my-5 w-full items-center justify-between gap-2 align-middle sm:flex">
|
<div className="my-5 w-full items-center justify-between gap-2 align-middle sm:flex">
|
||||||
<div className="mb-2 flex items-center gap-2 align-middle">
|
<NewsAuthor
|
||||||
{author?.profile_picture ? (
|
author={author}
|
||||||
<img
|
live_at={live_at}
|
||||||
src={author?.profile_picture}
|
text={text}
|
||||||
alt={author?.name}
|
/>
|
||||||
className="h-12 w-12 rounded-full bg-[#C4C4C4] object-cover"
|
<SocialShareButtons
|
||||||
/>
|
url={`${currentUrl}`}
|
||||||
) : (
|
title={`${title}`}
|
||||||
<ProfileIcon className="h-12 w-12 rounded-full bg-[#C4C4C4]" />
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-md">{author?.name}</h4>
|
|
||||||
<p className="flex gap-1 text-sm">
|
|
||||||
<span>{live_at && `${formatDate(live_at)}`}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{text}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<IconsSocial className="flex-row" />
|
|
||||||
</div>
|
</div>
|
||||||
{/* END TODO: create component for this section */}
|
|
||||||
<div className="w-full bg-amber-200">
|
<div className="w-full bg-amber-200">
|
||||||
<img
|
<img
|
||||||
src={featured_image}
|
src={featured_image}
|
||||||
@ -71,9 +57,10 @@ export const NewsDetailPage = () => {
|
|||||||
<div className="items-end justify-between border-b-gray-300 py-4 sm:flex">
|
<div className="items-end justify-between border-b-gray-300 py-4 sm:flex">
|
||||||
<div className="flex flex-col max-sm:mb-3">
|
<div className="flex flex-col max-sm:mb-3">
|
||||||
<p className="mb-2">Share this post</p>
|
<p className="mb-2">Share this post</p>
|
||||||
<IconsSocial
|
|
||||||
className="a"
|
<SocialShareButtons
|
||||||
slug={slug}
|
url={`${currentUrl}`}
|
||||||
|
title={`${title}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
|||||||
@ -44,6 +44,7 @@
|
|||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-hook-reading-time": "^1.0.0",
|
"react-hook-reading-time": "^1.0.0",
|
||||||
"react-router": "^7.1.3",
|
"react-router": "^7.1.3",
|
||||||
|
"react-share": "^5.2.2",
|
||||||
"remix-hook-form": "^6.1.3",
|
"remix-hook-form": "^6.1.3",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
"xior": "^0.6.3",
|
"xior": "^0.6.3",
|
||||||
|
|||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@ -98,6 +98,9 @@ importers:
|
|||||||
react-router:
|
react-router:
|
||||||
specifier: ^7.1.3
|
specifier: ^7.1.3
|
||||||
version: 7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
version: 7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
react-share:
|
||||||
|
specifier: ^5.2.2
|
||||||
|
version: 5.2.2(react@19.0.0)
|
||||||
remix-hook-form:
|
remix-hook-form:
|
||||||
specifier: ^6.1.3
|
specifier: ^6.1.3
|
||||||
version: 6.1.3(react-dom@19.0.0(react@19.0.0))(react-hook-form@7.54.2(react@19.0.0))(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
|
version: 6.1.3(react-dom@19.0.0(react@19.0.0))(react-hook-form@7.54.2(react@19.0.0))(react-router@7.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
|
||||||
@ -3133,6 +3136,9 @@ packages:
|
|||||||
jsonfile@6.1.0:
|
jsonfile@6.1.0:
|
||||||
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
|
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
|
||||||
|
|
||||||
|
jsonp@0.2.1:
|
||||||
|
resolution: {integrity: sha512-pfog5gdDxPdV4eP7Kg87M8/bHgshlZ5pybl+yKxAnCZ5O7lCIn7Ixydj03wOlnDQesky2BPyA91SQ+5Y/mNwzw==}
|
||||||
|
|
||||||
jsonparse@1.3.1:
|
jsonparse@1.3.1:
|
||||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||||
engines: {'0': node >= 0.2.0}
|
engines: {'0': node >= 0.2.0}
|
||||||
@ -3955,6 +3961,11 @@ packages:
|
|||||||
react-dom:
|
react-dom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
react-share@5.2.2:
|
||||||
|
resolution: {integrity: sha512-z0nbOX6X6vHHWAvXduNkYeJUKTKNpKM5Xpmc5a2BxjJhUWl+sE7AsSEMmYEUj2DuDjZr5m7KFIGF0sQPKcUN6w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^17 || ^18 || ^19
|
||||||
|
|
||||||
react-simple-animate@3.5.3:
|
react-simple-animate@3.5.3:
|
||||||
resolution: {integrity: sha512-Ob+SmB5J1tXDEZyOe2Hf950K4M8VaWBBmQ3cS2BUnTORqHjhK0iKG8fB+bo47ZL15t8d3g/Y0roiqH05UBjG7A==}
|
resolution: {integrity: sha512-Ob+SmB5J1tXDEZyOe2Hf950K4M8VaWBBmQ3cS2BUnTORqHjhK0iKG8fB+bo47ZL15t8d3g/Y0roiqH05UBjG7A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -7904,6 +7915,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
jsonp@0.2.1:
|
||||||
|
dependencies:
|
||||||
|
debug: 2.6.9
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
jsonparse@1.3.1: {}
|
jsonparse@1.3.1: {}
|
||||||
|
|
||||||
jsx-ast-utils@3.3.5:
|
jsx-ast-utils@3.3.5:
|
||||||
@ -8698,6 +8715,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
react-dom: 19.0.0(react@19.0.0)
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
|
|
||||||
|
react-share@5.2.2(react@19.0.0):
|
||||||
|
dependencies:
|
||||||
|
classnames: 2.5.1
|
||||||
|
jsonp: 0.2.1
|
||||||
|
react: 19.0.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
react-simple-animate@3.5.3(react-dom@19.0.0(react@19.0.0)):
|
react-simple-animate@3.5.3(react-dom@19.0.0(react@19.0.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
react-dom: 19.0.0(react@19.0.0)
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user