first commit

This commit is contained in:
2024-01-15 23:13:55 +01:00
commit 39705a7b43
286 changed files with 47391 additions and 0 deletions

9
README.md Normal file
View File

@@ -0,0 +1,9 @@
# NEXTjs Server side Rendering
This is intended to be a small and humble presentation about the server side
rendering implementation in NEXTjs and its multiple benefits. This projects
objective is twofold:
1. I want to learn new ways of using this feature in my projects besides
learning how the stuff I know to works under the blanket.
2. To promote its use within my office to enjoy its benefits.

View File

@@ -0,0 +1,156 @@
import { ApiContext, DEFAULT_API_CONTEXT } from '@/common/providers/api-context'
import {
{{ pascalCase name }}ApiResult,
{{ pascalCase name }}CreateApiParams,
{{ pascalCase name }}DeleteApiParams,
{{ pascalCase name }}GetApiParams,
{{ pascalCase name }}Id,
{{ pascalCase name }}ListApiParams,
{{ pascalCase name }}PaginatedApiResult,
{{ pascalCase name }}UpdateApiParams,
} from './{{ kebabCase name }}.types'
export const {{ camelCase name }}ApiProto = (
baseUrl: string = process.env.NEXT_PUBLIC_API_ENDPOINT || '/api',
defaultApiContext = DEFAULT_API_CONTEXT
) => {
const endpointUrl = `${baseUrl}/{{ kebabCase name }}`
type UrlParams = { resourceId?: {{ pascalCase name}}Id }
const endpoint = (
urlParams: UrlParams,
queryParams: Record<string, string>
) => {
const queryParamString = new URLSearchParams(queryParams).toString()
const resourceIdParam =
urlParams.resourceId === undefined ? '' : `/${urlParams.resourceId}`
// TODO: Customize the endpoint url generation here
return `${endpointUrl}${resourceIdParam}?${queryParamString}`
}
return {
async list(
this: ApiContext,
{ page, size, ...otherQueryParams }: {{ pascalCase name }}ListApiParams
): Promise<{{ pascalCase name }}PaginatedApiResult> {
const urlParams: UrlParams = {}
const queryParams = {
// TODO: Map the pagination params as required by the API
page: `${page}`,
size: `${size}`,
// limit: `${size}`,
// offset: `${Math.max((page - 1) * size, 0)}`,
...otherQueryParams,
}
const url = endpoint(urlParams, queryParams)
console.debug(
`Listing {{ pascalCase name }} with page: ${page}, size: ${size}`,
`on url: ${url}`
)
const response = await this.client.get(url)
// TODO: Add code handle the response if needed
return response.data as {{ pascalCase name }}PaginatedApiResult
},
async delete(
this: ApiContext,
{ resourceId, ...queryParams }: {{ pascalCase name }}DeleteApiParams
): Promise<boolean> {
const urlParams: UrlParams = { resourceId }
const url = endpoint(urlParams, queryParams)
console.debug(
`Deleting {{ pascalCase name }} with id:`,
resourceId,
`on url: ${url}`
)
const response = await this.client.delete(url)
// TODO: Add code handle the response if needed
return response.status >= 200 && response.status < 300
},
async create(
this: ApiContext,
{ newResource, ...queryParams }: {{ pascalCase name }}CreateApiParams
): Promise<{{ pascalCase name }}Id> {
const urlParams: UrlParams = {}
const url = endpoint(urlParams, queryParams)
console.debug(
`Creating {{ pascalCase name }} resource:`,
newResource,
`on url: ${url}`
)
const response = await this.client.post(url, newResource)
// TODO: Add code handle the response if needed
// TODO: Adapt code to handle the receiving of the resourceId (if any)
const locationHeader = response.headers.location as
| string
| undefined
if (locationHeader) {
const segments = new URL(locationHeader).pathname.split('/')
const lastIdx = segments.length - 1
const resourceId =
segments[lastIdx] || segments[Math.max(lastIdx - 1, 0)]
if (!resourceId)
console.warn(new Error('Invalid location header received'))
return resourceId as {{ pascalCase name }}Id
}
console.warn(new Error('No location header received'))
return '' as {{ pascalCase name }}Id
},
async update(
this: ApiContext,
{
updatedResource,
// resourceId,
...queryParams
}: {{ pascalCase name }}UpdateApiParams
): Promise<boolean> {
const urlParams: UrlParams = {
// resourceId
}
const url = endpoint(urlParams, queryParams)
console.debug(
`updating {{ pascalCase name }} resource:`,
updatedResource,
`on url: ${url}`
)
const response = await this.client.put(url, updatedResource)
// TODO: Add code handle the response if needed
return response.status >= 200 && response.status < 300
},
async get(
this: ApiContext,
{ resourceId, ...queryParams }: {{ pascalCase name }}GetApiParams
): Promise<{{ pascalCase name }}ApiResult> {
const urlParams: UrlParams = {
resourceId,
}
const url = endpoint(urlParams, queryParams)
console.debug(
`Getting {{ pascalCase name }} with id:`,
resourceId,
`on url: ${url}`
)
const response = await this.client.get(url)
// TODO: Add code handle the response if needed
return response.data as {{ pascalCase name }}ApiResult
},
...defaultApiContext,
}
}
export const {{ camelCase name }}Api = {{ camelCase name }}ApiProto()

View File

@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { Pagination } from '@/hookey'
import { useApiContext } from '@/common/providers/api-context'
import { {{ camelCase name }}Api } from './{{ kebabCase name }}.api'
import { {{ pascalCase name }}GetApiParams } from './{{ kebabCase name }}.types'
export const use{{ pascalCase name }}s = Pagination.makePaginationHook({
cacheKey: '{{ kebabCase name }}-api-list',
clientFn: {{ camelCase name }}Api.list,
useApiContext: useApiContext,
// TODO: Connect getCount and getPageData with the list response data
getCount: (data) => data.count,
getPageData: (data) => data.results,
})
export const use{{ pascalCase name }} = (params: {{ pascalCase name }}GetApiParams) => {
return useQuery(
['{{ kebabCase name }}-api-get', params] as [string, typeof params],
({ queryKey: [_key, params] }) => {{ camelCase name }}Api.get(params)
)
}

View File

@@ -0,0 +1,46 @@
import type { Pagination } from '@/hookey'
export type {{ pascalCase name }} = {
{{ camelCase name }}Id: {{ pascalCase name }}Id
}
// TODO: Set the id type
export type {{ pascalCase name }}Id = string | number
export type {{ pascalCase name }}ApiResult = {
// TODO: Replace with actual get api result
results: {{ pascalCase name }}
}
export type {{ pascalCase name }}PaginatedApiResult = {
// TODO: Replace with actual list api result
results: {{ pascalCase name }}[]
count: number
}
export type {{ pascalCase name }}ListApiParams = Pagination.UsePaginatedQueryParams<{
// TODO: Add other params here
}>
export type {{ pascalCase name }}GetApiParams = {
resourceId: {{ pascalCase name }}Id
// TODO: Add other params here
}
export type {{ pascalCase name }}CreateApiParams = {
newResource: Omit<{{ pascalCase name }}, '{{ camelCase name }}Id'>
// TODO: Add other params here
}
export type {{ pascalCase name }}UpdateApiParams = {
updatedResource: {{ pascalCase name }}
// TODO: Switch params if the api requires an id in the url for updates
// updatedResource: Omit<{{ pascalCase name }}, '{{ camelCase name }}Id'>
// resourceId: {{ pascalCase name }}Id
// TODO: Add other params here
}
export type {{ pascalCase name }}DeleteApiParams = {
resourceId: {{ pascalCase name }}Id
// TODO: Add other params here
}

View File

@@ -0,0 +1,3 @@
export * from './{{ kebabCase name }}.api'
export * from './{{ kebabCase name }}.hooks'
export * from './{{ kebabCase name }}.types'

View File

@@ -0,0 +1,8 @@
import React from 'react'
import styles from './{{ kebabCase name }}.module.css'
export type {{pascalCase name}}Props = {}
export function {{pascalCase name}}(props: {{pascalCase name}}Props) {
return <div data-testid="{{ kebabCase name }}" className={styles.container}></div>
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { {{ pascalCase name }} } from './{{ kebabCase name }}.component'
const meta: Meta<typeof {{ pascalCase name }}> = {
title: '{{ pascalCase name }}',
component: {{ pascalCase name }},
argTypes: {},
}
export default meta
type Story = StoryObj<typeof {{ pascalCase name }}>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('{{ kebabCase name }}')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './{{kebabCase name}}.component'

View File

@@ -0,0 +1,8 @@
import React, { PropsWithChildren } from 'react'
import styles from './{{ kebabCase name }}.module.css'
export type {{pascalCase name}}LayoutProps = PropsWithChildren<{}>
export function {{pascalCase name}}Layout(props: {{pascalCase name}}LayoutProps) {
return <div data-testid="{{ kebabCase name }}-layout" className={styles.container}>{props.children}</div>
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { {{ pascalCase name }}Layout } from './{{ kebabCase name }}.layout'
const meta: Meta<typeof {{ pascalCase name }}Layout> = {
title: '{{ pascalCase name }}Layout',
component: {{ pascalCase name }}Layout,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof {{ pascalCase name }}Layout>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('{{ kebabCase name }}-layout')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1 @@
export * from './{{kebabCase name}}.layout'

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { {{ pascalCase name }}View } from './{{ kebabCase name }}.view'
const meta: Meta<typeof {{ pascalCase name }}View> = {
title: '{{ pascalCase name }}View',
component: {{ pascalCase name }}View,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof {{ pascalCase name }}View>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('{{ kebabCase name }}-view')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,13 @@
import React from 'react'
import styles from './{{ kebabCase name }}.module.css'
type {{pascalCase name}}ViewProps = {
// query parameters
searchParams: { [key: string]: string | string[] | undefined }
// url parameters
params: { [key: string]: string | undefined }
}
export function {{pascalCase name}}View(props: {{pascalCase name}}ViewProps) {
return <div data-testid="{{ kebabCase name }}-view" className={styles.container}></div>
}

View File

@@ -0,0 +1 @@
export * from './{{kebabCase name}}.view'

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { {{ pascalCase name }}Widget } from './{{ kebabCase name }}.widget'
const meta: Meta<typeof {{ pascalCase name }}Widget> = {
title: '{{ pascalCase name }}Widget',
component: {{ pascalCase name }}Widget,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof {{ pascalCase name }}Widget>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('{{ kebabCase name }}-widget')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,8 @@
import React from 'react'
import styles from './{{ kebabCase name }}.module.css'
export type {{pascalCase name}}WidgetProps = {}
export function {{pascalCase name}}Widget(props: {{pascalCase name}}WidgetProps) {
return <div data-testid="{{ kebabCase name }}-widget" className={styles.container}></div>
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './{{kebabCase name}}.widget'

View File

@@ -0,0 +1,5 @@
{
"extends": [
"@commitlint/config-conventional"
]
}

View File

@@ -0,0 +1,10 @@
root = true
# Unix-style newlines with a newline ending every file
[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

View File

@@ -0,0 +1,4 @@
*.[tj]s
*.[tj]sx
!src/**/*

View File

@@ -0,0 +1,89 @@
{
"plugins": [
"sonarjs",
"@typescript-eslint",
"react",
"react-hooks",
"boundaries",
"prettier",
"jest-extended"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:sonarjs/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:boundaries/recommended",
"plugin:prettier/recommended",
"next",
"plugin:storybook/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"@next/next/no-head-element": "off",
"boundaries/element-types": [
2,
{
"default": "allow",
"rules": [
{
"from": ["component"],
"allow": ["page", "globalStyle"],
"message": "Components cannot import pages or global styles"
}
]
}
],
"@typescript-eslint/no-misused-promises": [
2,
{
"checksVoidReturn": {
"attributes": false
}
}
],
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
],
"@next/next/no-html-link-for-pages": "off",
"@typescript-eslint/unbound-method": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ "argsIgnorePattern": "^_|^props$" }
],
"@typescript-eslint/require-await": "off",
"no-unused-vars": "off"
},
"settings": {
"boundaries/include": ["src/**"],
"boundaries/ignore": ["src/**/*.spec.*", "src/**/*.test.*"],
"boundaries/elements": [
{
"type": "component",
"pattern": "components/*",
"mode": "folder",
"capture": ["component"]
},
{
"type": "page",
"pattern": "pages/**/*",
"mode": "file",
"capture": ["route", "elementName"]
},
{
"type": "globalStyle",
"pattern": "styles/*",
"mode": "file",
"capture": ["styleName"]
}
]
}
}

36
examples/data-fetching/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx echo-cli "\033[0;34m>>> Checking commit message\033[0m"
npx commitlint --edit $1
npx echo-cli "\033[0;32mCommit message is OK\033[0m\n"

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx echo-cli "\033[0;34m>>> Linting the code\033[0m"
npm run --silent lint

View File

@@ -0,0 +1,8 @@
{
"editorconfig": true,
"trailingComma": "es5",
"semi": false,
"singleQuote": true,
"endOfLine": "lf",
"tabWidth": 4
}

View File

@@ -0,0 +1,32 @@
import path from 'path'
import type { StorybookConfig } from '@storybook/nextjs'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-styling',
],
framework: {
name: '@storybook/nextjs',
options: {},
},
docs: {
autodocs: 'tag',
},
async webpackFinal(config, options) {
return {
...config,
resolve: {
...config.resolve,
alias: {
...config.resolve?.alias,
'@': path.resolve(__dirname, '../src/modules'),
},
},
}
},
}
export default config

View File

@@ -0,0 +1,16 @@
import '../src/styles/main.css'
import type { Preview } from '@storybook/react'
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
}
export default preview

View File

@@ -0,0 +1,19 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"christian-kohler.npm-intellisense",
"aaron-bond.better-comments",
"formulahendry.auto-rename-tag",
"formulahendry.auto-close-tag",
"christian-kohler.path-intellisense",
"mrmlnc.vscode-scss",
"42crunch.vscode-openapi",
"ms-playwright.playwright",
"clinyong.vscode-css-modules",
"esbenp.prettier-vscode",
"teamchilla.blueprint",
"bradlc.vscode-tailwindcss",
"csstools.postcss",
"editorconfig.editorconfig"
]
}

View File

@@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug",
"type": "node-terminal",
"request": "launch",
"command": "npm run start:dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

View File

@@ -0,0 +1,42 @@
{
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"editor.formatOnPaste": true,
"editor.formatOnType": false,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"javascript.validate.enable": false,
"javascript.suggestionActions.enabled": false,
"blueprint.templatesPath": ["./.blueprints"],
"eslint.validate": [
"typescript",
"typescriptreact",
"javascript",
"javascriptreact"
],
"files.eol": "\n",
"files.exclude": {
// "**/.git": true,
// "**/.svn": true,
// "**/.hg": true,
// "**/CVS": true,
// "**/.DS_Store": true,
// "**/Thumbs.db": true,
// "**/next-env.d.ts": true,
// "**/tsconfig.tsbuildinfo": true,
// "**/package-lock.json": true,
// "**/LICENSE": true,
// "**/.next": true,
// "**/.husky": true,
// "**/.commitlintrc*": true,
// "**/.prettierrc*": true,
// "**/.gitignore": true,
// "**/.eslint*": true,
// "**/.vscode": true
},
"typescript.tsdk": "node_modules/typescript/lib",
"css.validate": false,
"files.associations": {
"*.css": "css",
"css": "css"
}
}

View File

@@ -0,0 +1,7 @@
Copyright 2023 Daniel Peña Iglesias
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,38 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

170
examples/data-fetching/global.d.ts vendored Normal file
View File

@@ -0,0 +1,170 @@
/// <reference types="jest-extended" />
declare module '*.module.css' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module '*.module.scss' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module uxcale {
declare type ApiResult<TData extends object> = {
data: TData
}
declare type PaginatedApiResult<TResource extends object> = ApiResult<
TResource[]
> & {
pagination: {
pageSize: number
pageNumber: number
totalPages: number
totalElements: number
}
}
}
declare type EmptyObject = Record<string, never>
/**
* Opaque
* @desc Declares an Opaque type
* @see https://dev.to/stereobooster/pragmatic-types-opaque-types-and-how-they-could-have-saved-mars-climate-orbiter-1551
* @example
* // Expect: "string | null"
* NonUndefined<string | null | undefined>;
*/
declare type Opaque<K, T> = T & { __TYPE__: K }
/**
* NonUndefined
* @desc Exclude undefined from set `A`
* @example
* // Expect: "string | null"
* NonUndefined<string | null | undefined>;
*/
declare type NonUndefined<A> = A extends undefined ? never : A
/**
* FunctionKeys
* @desc Get union type of keys that are functions in object type `T`
* @example
* type MixedProps = {name: string; setName: (name: string) => void; someKeys?: string; someFn?: (...args: any) => any;};
*
* // Expect: "setName | someFn"
* type Keys = FunctionKeys<MixedProps>;
*/
declare type FunctionKeys<T extends object> = {
[K in keyof T]-?: NonUndefined<T[K]> extends Function ? K : never
}[keyof T]
/**
* NonFunctionKeys
* @desc Get union type of keys that are non-functions in object type `T`
* @example
* type MixedProps = {name: string; setName: (name: string) => void; someKeys?: string; someFn?: (...args: any) => any;};
*
* // Expect: "name | someKey"
* type Keys = NonFunctionKeys<MixedProps>;
*/
declare type NonFunctionKeys<T extends object> = {
[K in keyof T]-?: NonUndefined<T[K]> extends Function ? never : K
}[keyof T]
/**
* RequiredKeys
* @desc Get union type of keys that are required in object type `T`
* @see https://stackoverflow.com/questions/52984808/is-there-a-way-to-get-all-required-properties-of-a-typescript-object
* @example
* type Props = { req: number; reqUndef: number | undefined; opt?: string; optUndef?: number | undefined; };
*
* // Expect: "req" | "reqUndef"
* type Keys = RequiredKeys<Props>;
*/
declare type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K
}[keyof T]
/**
* OptionalKeys
* @desc Get union type of keys that are optional in object type `T`
* @see https://stackoverflow.com/questions/52984808/is-there-a-way-to-get-all-required-properties-of-a-typescript-object
* @example
* type Props = { req: number; reqUndef: number | undefined; opt?: string; optUndef?: number | undefined; };
*
* // Expect: "opt" | "optUndef"
* type Keys = OptionalKeys<Props>;
*/
declare type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never
}[keyof T]
/**
* PromiseType
* @desc Obtain Promise resolve type
* @example
* // Expect: string;
* type Response = PromiseType<Promise<string>>;
*/
declare type PromiseType<T extends Promise<any>> = T extends Promise<infer U>
? U
: never
/**
* WithOptional
* @desc From `T` make a set of properties by key `K` become optional
* @example
* type Props = {
* name: string;
* age: number;
* visible: boolean;
* };
*
* // Expect: { name?: string; age?: number; visible?: boolean; }
* type Props = WithOptional<Props>;
*
* // Expect: { name: string; age?: number; visible?: boolean; }
* type Props = WithOptional<Props, 'age' | 'visible'>;
*/
declare type WithOptional<T, K extends keyof T = keyof T> = Omit<T, K> &
Partial<Pick<T, K>>
/**
* WithRequired
* @desc From `T` make a set of properties by key `K` become required
* @example
* type Props = {
* name?: string;
* age?: number;
* visible?: boolean;
* };
*
* // Expect: { name: string; age: number; visible: boolean; }
* type Props = WithRequired<Props>;
*
* // Expect: { name?: string; age: number; visible: boolean; }
* type Props = WithRequired<Props, 'age' | 'visible'>;
*/
declare type WithRequired<T, K extends keyof T = keyof T> = Omit<T, K> &
Required<Pick<T, K>>
/**
* FirstParam
* @desc From a function `T` get the first parameter type
* @example
* type Props = {
* name: string;
* age: number;
* visible: boolean;
* };
*
* // Expect: { name?: string; age?: number; visible?: boolean; }
* type Props = WithOptional<Props>;
*
* // Expect: { name: string; age?: number; visible?: boolean; }
* type Props = WithOptional<Props, 'age' | 'visible'>;
*/
declare type FirstParam<T extends (...args: any) => any> = Parameters<T>[0]

View File

@@ -0,0 +1,18 @@
import type { Config } from 'jest'
import nextJest from 'next/jest'
const createJestConfig = nextJest({
dir: './',
})
const config: Config = {
modulePathIgnorePatterns: ['<rootDir>/.blueprints'],
setupFiles: ['dotenv/config'],
setupFilesAfterEnv: [
'jest-extended/all',
'@testing-library/jest-dom/extend-expect',
],
testEnvironment: 'jest-environment-jsdom',
}
export default createJestConfig(config)

View File

@@ -0,0 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// Avoiding CORS issues
// async rewrites() {
// return [
// {
// source: '/api/:slug*',
// destination: 'http://localhost:3000/api/:slug*'
// }
// ]
// }
}
module.exports = nextConfig

37722
examples/data-fetching/package-lock.json generated Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
{
"name": "front-arch-ssr-react",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "npm run build:prod",
"build:prod": "next build",
"build:storybook": "storybook build",
"serve": "next start",
"lint": "npm run lint:all",
"lint:all": "eslint .",
"start": "npm run start:storybook",
"start:dev": "next dev",
"start:storybook": "storybook dev -p 6006 --no-open",
"prepare": "husky install"
},
"dependencies": {
"@types/leaflet": "^1.9.3",
"@tanstack/react-query": "^4.29.12",
"axios": "^1.4.0",
"date-fns": "^2.30.0",
"leaflet": "^1.9.4",
"next": "^13.4.1",
"next-auth": "^4.22.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-leaflet": "^4.2.1",
"react-table": "^7.8.0",
"tailwindcss": "^3.3.2"
},
"devDependencies": {
"@commitlint/cli": "^17.5.0",
"@commitlint/config-conventional": "^17.4.4",
"@hookform/resolvers": "^3.1.0",
"@mertasan/tailwindcss-variables": "^2.6.1",
"@radix-ui/react-accordion": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.2",
"@radix-ui/react-avatar": "^1.0.2",
"@radix-ui/react-checkbox": "^1.0.3",
"@radix-ui/react-collapsible": "^1.0.2",
"@radix-ui/react-context-menu": "^2.1.3",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-hover-card": "^1.0.5",
"@radix-ui/react-label": "^2.0.1",
"@radix-ui/react-menubar": "^1.0.2",
"@radix-ui/react-navigation-menu": "^1.1.2",
"@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-progress": "^1.0.2",
"@radix-ui/react-radio-group": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.0.3",
"@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-separator": "^1.0.2",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-slot": "^1.0.1",
"@radix-ui/react-switch": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.3",
"@radix-ui/react-toggle": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.5",
"@storybook/addon-essentials": "^7.0.10",
"@storybook/addon-interactions": "^7.0.10",
"@storybook/addon-links": "^7.0.10",
"@storybook/addon-styling": "^1.0.6",
"@storybook/blocks": "^7.0.10",
"@storybook/jest": "^0.1.0",
"@storybook/nextjs": "^7.0.10",
"@storybook/react": "^7.0.10",
"@storybook/testing-library": "^0.0.14-next.2",
"@tanstack/react-query-devtools": "^4.29.12",
"@types/classnames": "^2.3.1",
"@types/node": "18.15.7",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/react-table": "^7.7.14",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"autoprefixer": "^10.4.14",
"class-variance-authority": "^0.6.0",
"classnames": "^2.3.2",
"cmdk": "^0.2.0",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-boundaries": "^3.1.0",
"eslint-plugin-css-modules": "^2.11.0",
"eslint-plugin-jest-extended": "^2.0.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-sonarjs": "^0.19.0",
"eslint-plugin-storybook": "^0.6.12",
"husky": "^8.0.3",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"jest-extended": "^3.2.4",
"lucide-react": "^0.215.0",
"postcss": "^8.4.23",
"postcss-preset-env": "^8.3.2",
"prettier": "^2.8.7",
"react-day-picker": "^8.7.1",
"react-hook-form": "^7.43.9",
"sass": "^1.62.1",
"storybook": "^7.0.10",
"tailwindcss-animate": "^1.0.5",
"typescript": "5.0.2",
"typescript-plugin-css-modules": "^5.0.0",
"zod": "^3.21.4"
}
}

View File

@@ -0,0 +1,8 @@
module.exports = {
plugins: {
'tailwindcss/nesting': {},
'postcss-preset-env': {},
tailwindcss: {},
autoprefixer: {},
},
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1 @@
export { SignInView as default } from '@/auth/views/sign-in'

View File

@@ -0,0 +1,17 @@
import '../styles/main.css'
import { RootLayout } from '@/common/layouts/root'
import { ApplicationLayout } from '@/common/layouts/application'
import { PropsWithChildren } from 'react'
export const metadata = {
title: 'Next SSR Archetype',
description: 'Sample skeleton webpage',
}
export default function Layout(props: PropsWithChildren) {
return (
<RootLayout>
<ApplicationLayout>{props.children}</ApplicationLayout>
</RootLayout>
)
}

View File

@@ -0,0 +1 @@
export { AppMainView as default } from '@/common/views/main'

View File

@@ -0,0 +1 @@
export { UsersMainView as default } from '@/users/views/main'

View File

@@ -0,0 +1,16 @@
import { getRandomWordList } from "@/words/word-list/utils";
const getServerSideProps = async () => {
const wordList = await getRandomWordList();
console.log("Data is being fetch...", wordList)
return { props: { wordList:wordList } }
}
export default async function WordList(){
const wordList = await getServerSideProps();
return (
<div data-testid="word-list-view">
WordList server props: {JSON.stringify(wordList)}
</div>
)
}

View File

@@ -0,0 +1,95 @@
import React from 'react'
import cn from 'classnames'
import { LoadingButton } from '@/common/components/loading-button'
import * as Form from '@/common/components/ui/form'
import { z } from 'zod'
export type AuthFormCredentials = { username: string; password: string }
export type AuthFormProps = Omit<
React.HTMLAttributes<HTMLDivElement>,
'onSubmit'
> & {
onSubmit: (credentials: AuthFormCredentials) => Promise<void | string>
}
export function AuthForm({ className, onSubmit, ...props }: AuthFormProps) {
const form = Form.useZodForm<AuthFormCredentials>({
criteriaMode: 'firstError',
schema: z.object({
username: z
.string()
.min(2, {
message: 'Username must be at least 2 characters',
})
.max(50),
password: z.string().min(8, {
message: 'Password must contain at least 8 characters',
}),
}),
onSubmit: async (data) => {
try {
const error = await onSubmit(data)
if (typeof error === 'string') {
form.setError('root.submit', {
type: 'server',
message: error,
})
}
} catch (e: any) {
console.error('AuthForm.onSubmit error', e)
form.setError('root.submit', {
type: 'unknown',
message: 'Unknown error',
})
}
},
})
return (
<div
data-testid="auth-form"
className={cn('grid gap-6', className)}
{...props}
>
<Form.Root {...form} className="grid gap-4">
<div className="grid gap-2">
<Form.Field
control={form.control}
name="username"
render={({ field }) => (
<Form.Item>
<Form.Label>Username</Form.Label>
<Form.Input placeholder="pibone" {...field} />
<Form.Message />
</Form.Item>
)}
/>
<Form.Field
control={form.control}
name="password"
render={({ field }) => (
<Form.Item>
<Form.Label>Password</Form.Label>
<Form.Input
placeholder="*****"
type="password"
{...field}
/>
<Form.Message />
</Form.Item>
)}
/>
</div>
<Form.CustomMessage isError>
{form.formState.errors.root?.submit?.message || null}
</Form.CustomMessage>
<LoadingButton
loading={form.formState.isSubmitting}
type="submit"
>
Sign In
</LoadingButton>
</Form.Root>
</div>
)
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { AuthForm } from './auth-form.component'
const meta: Meta<typeof AuthForm> = {
title: 'AuthForm',
component: AuthForm,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof AuthForm>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('auth-form')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './auth-form.component'

View File

@@ -0,0 +1,17 @@
import { useApiContext } from '@/common/providers/api-context'
import { useSession } from 'next-auth/react'
import { PropsWithChildren, useLayoutEffect } from 'react'
export const AuthToApiContextConnectionProvider = ({
children,
}: PropsWithChildren) => {
const apiContext = useApiContext()
const { data: session } = useSession()
const apiSession = session?.apiSession
useLayoutEffect(() => {
apiContext.setAuthorizationToken(apiSession?.accessToken)
}, [apiContext, apiSession])
return <>{children}</>
}

View File

@@ -0,0 +1,2 @@
'use client'
export * from './auth-to-apicontext-connection.provider'

View File

@@ -0,0 +1,57 @@
import { getApiContext } from '@/common/providers/api-context/api-context.default'
import { AuthOptions, User } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
export const authOptions: AuthOptions = {
session: {
strategy: 'jwt',
},
pages: {
signIn: '/auth/sign-in',
},
callbacks: {
async session({ session, token }) {
if (token?.accessToken && session) {
// Update server side API_CONTEXT
getApiContext().setAuthorizationToken(token.accessToken)
session.apiSession = {
accessToken: token.accessToken,
refreshToken: token.refreshToken,
}
}
return session
},
async jwt({ token, user }) {
if (user?.apiSession) {
token.accessToken = user.apiSession.accessToken
token.refreshToken = user.apiSession.refreshToken
}
return token
},
},
providers: [
CredentialsProvider({
name: 'custom-credentials',
credentials: {
email: { type: 'email' },
password: { type: 'password' },
},
authorize: async (credentials, _req) => {
// TODO: Connect with login API in the backend
const user: User = {
id: '1',
email: 'dpenai@pibone.com',
name: 'Dani Peña Iglesias',
role: 'admin',
// TODO: Set the incoming api token here, if needed
apiSession: {
accessToken: 'jwt-token',
// refreshToken: 'refresh-token-if-any',
},
}
return credentials?.password === 'safe-password' ? user : null
},
}),
],
}

View File

@@ -0,0 +1,10 @@
import { Session } from 'next-auth'
import { SessionProvider } from 'next-auth/react'
import { PropsWithChildren } from 'react'
export const AuthProvider = ({
children,
session,
}: PropsWithChildren<{ session?: Session }>) => {
return <SessionProvider session={session}>{children}</SessionProvider>
}

View File

@@ -0,0 +1,2 @@
'use client'
export * from './auth.provider'

View File

@@ -0,0 +1,28 @@
import { ApiSession, DefaultSession } from 'next-auth'
import { DefaultJWT } from 'next-auth/jwt'
declare module 'next-auth' {
declare type Role = 'user' | 'admin'
declare interface ApiSession {
accessToken: string
refreshToken?: string
}
declare interface User {
id: string
role: Role
image?: string
name?: string
email?: string
apiSession?: ApiSession
}
declare interface Session extends DefaultSession {
apiSession?: ApiSession
}
}
declare module 'next-auth/jwt' {
declare interface JWT
extends WithOptional<ApiSession, 'accessToken'>,
DefaultJWT {}
}

View File

@@ -0,0 +1 @@
export * from './sign-in.view'

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { SignInView } from './sign-in.view'
const meta: Meta<typeof SignInView> = {
title: 'SignInView',
component: SignInView,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof SignInView>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('sign-in-view')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,40 @@
import React from 'react'
import { LoginWidget } from '../../widgets/login'
type SignInViewProps = {
// query parameters
searchParams: { [key: string]: string | string[] | undefined }
// url parameters
params: { [key: string]: string | undefined }
}
export function SignInView(props: SignInViewProps) {
return (
<>
<div className="container relative md:h-[800px] grid items-center justify-stretch md:justify-center lg:max-w-none p-0">
<div
className="absolute hidden md:block inset-0 bg-cover -z-10"
style={{
backgroundImage:
'url(https://images.unsplash.com/photo-1590069261209-f8e9b8642343?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1376&q=80)',
}}
/>
<div className="relative flex items-center justify-center px-4 lg:px-6 pt-4 pb-6">
<div className="absolute w-full h-full md:rounded-md bg-white p-2 -z-10" />
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold">
Account Sign In
</h1>
<p className="text-sm text-primary">
Enter your username below to create your account
</p>
</div>
<LoginWidget />
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { AccountWidget } from './account.widget'
import { AuthProvider } from '@/auth/providers/auth'
const meta: Meta<typeof AccountWidget> = {
title: 'AccountWidget',
component: AccountWidget,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof AccountWidget>
export const LoggedIn: Story = {
render: (p) => (
<AuthProvider
session={{
user: {
email: 'example@pibone.com',
name: 'Dani Peña Iglesias',
image: 'https://github.com/pibone.png',
},
}}
>
<AccountWidget {...p} />
</AuthProvider>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('account-widget')
expect(container).toBeTruthy()
},
}
export const LoggedOut: Story = {
render: (p) => (
<AuthProvider session={undefined}>
<AccountWidget {...p} />
</AuthProvider>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('account-widget')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,68 @@
import React from 'react'
import styles from './account.module.css'
import classNames from 'classnames'
import { useSession, signIn, signOut } from 'next-auth/react'
import * as Avatar from '@/common/components/ui/avatar'
import * as Popover from '@/common/components/ui/popover'
import { Button } from '@/common/components/ui/button'
export type AccountWidgetProps = {
className: string
}
export function AccountWidget(props: AccountWidgetProps) {
const { status } = useSession()
return (
<div
data-testid="account-widget"
className={classNames(styles.container, props.className)}
>
{selectComponent(status)}
</div>
)
}
function selectComponent(authStatus: ReturnType<typeof useSession>['status']) {
return authStatus === 'authenticated' ? (
<LoggedInUser />
) : (
<Button onClick={() => signIn()}>Sign In</Button>
)
}
function LoggedInUser() {
const { data: session } = useSession()
return (
<Popover.Root>
<Popover.Trigger asChild className="cursor-pointer">
<Avatar.Root>
<Avatar.Image src={session?.user?.image ?? undefined} />
<Avatar.Fallback>
{(session?.user?.name ?? '')
.split(/\s+/)
.map((v) => v[0].toUpperCase())
.join('') || 'Unknown user'}
</Avatar.Fallback>
</Avatar.Root>
</Popover.Trigger>
<Popover.Content>
<LoggedInMenu />
</Popover.Content>
</Popover.Root>
)
}
function LoggedInMenu() {
return (
<Button
onClick={() =>
signOut({
redirect: true,
})
}
>
Sign Out
</Button>
)
}

View File

@@ -0,0 +1,4 @@
'use client'
export type { AccountWidgetProps } from './account.widget'
export { AccountWidget } from './account.widget'

View File

@@ -0,0 +1,3 @@
'use client'
export * from './login.widget'

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { LoginWidget } from './login.widget'
const meta: Meta<typeof LoginWidget> = {
title: 'LoginWidget',
component: LoginWidget,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof LoginWidget>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('login-widget')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,34 @@
import React from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { AuthForm } from '../../components/auth-form'
import styles from './login.module.css'
export type LoginWidgetProps = {}
export function LoginWidget(props: LoginWidgetProps) {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get('callbackUrl') || '/profile'
return (
<div data-testid="login-widget" className={styles.container}>
<AuthForm
onSubmit={async (credentials) => {
const result = await signIn('credentials', {
redirect: false,
...credentials,
callbackUrl,
})
console.debug('Login result:', result)
if (result?.error === 'CredentialsSignin') {
return 'Invalid credentials'
}
router.replace(callbackUrl)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,71 @@
import {
AlertTriangle,
ArrowRight,
Check,
ChevronLeft,
ChevronRight,
ClipboardCheck,
Copy,
File,
HelpCircle,
Image,
Loader2,
Moon,
MoreVertical,
Plus,
Settings,
SunMedium,
Trash,
Twitter,
User,
X,
Minus,
Linkedin,
Facebook,
Instagram,
Youtube,
} from 'lucide-react'
export const Icons = {
logo: (props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="12" cy="12" r="10" />
</svg>
),
// informative icons
spinner: Loader2,
chevronLeft: ChevronLeft,
chevronRight: ChevronRight,
trash: Trash,
file: File,
media: Image,
warning: AlertTriangle,
user: User,
arrowRight: ArrowRight,
check: Check,
// action icons
darkTheme: Moon,
lightTheme: SunMedium,
close: X,
settings: Settings,
options: MoreVertical,
add: Plus,
remove: Minus,
help: HelpCircle,
copy: Copy,
copyDone: ClipboardCheck,
// social icons
twitter: Twitter,
linkedin: Linkedin,
facebook: Facebook,
instagram: Instagram,
youtube: Youtube,
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { Icons } from './icons.component'
const meta: Meta<typeof Icons> = {
title: 'Icons',
component: Icons,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof Icons>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('icons')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1 @@
export * from './icons.component'

View File

@@ -0,0 +1,3 @@
'use client'
export * from './loading-button.component'

View File

@@ -0,0 +1,16 @@
import React, { ComponentPropsWithRef } from 'react'
import { Button } from '../ui/button'
import { Icons } from '../icons'
export function LoadingButton({
loading,
children,
...props
}: ComponentPropsWithRef<typeof Button> & { loading?: boolean }) {
return (
<Button {...props} data-testid="loading-button">
{loading && <Icons.spinner className="animate-spin w-8 h-8 mr-2" />}
{children}
</Button>
)
}

View File

@@ -0,0 +1,3 @@
.container {
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { LoadingButton } from './loading-button.component'
const meta: Meta<typeof LoadingButton> = {
title: 'LoadingButton',
component: LoadingButton,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof LoadingButton>
export const Default: Story = {
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('loading-button')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,70 @@
import React from 'react'
import cn from 'classnames'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDown } from 'lucide-react'
import styles from './accordion.module.css'
export const Root = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Root
data-testid="accordion"
ref={ref}
className={cn(styles.container, className)}
{...props}
>
{children}
</AccordionPrimitive.Root>
))
export const AccordionRoot = Root
Root.displayName = 'AccordionRoot'
export const Item = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn(styles.item, className)}
{...props}
/>
))
export const AccordionItem = Item
Item.displayName = 'AccordionItem'
export const Trigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className={styles.header}>
<AccordionPrimitive.Trigger
ref={ref}
className={cn(styles.trigger, className)}
{...props}
>
{children}
<ChevronDown className={styles.icon} />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
export const AccordionTrigger = Trigger
Trigger.displayName = 'AccordionTrigger'
export const Content = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(styles.content, className)}
{...props}
>
<div className={styles.contentWrapper}>{children}</div>
</AccordionPrimitive.Content>
))
export const AccordionContent = Content
Content.displayName = 'AccordionContent'

View File

@@ -0,0 +1,24 @@
div:where(.container) {
:where(.item) {
@apply border-b;
}
:where(.header) {
@apply flex;
}
:where(.content) {
@apply overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down;
}
:where(.contentWrapper) {
@apply pb-4 pt-0;
}
:where(.trigger) {
@apply flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180;
}
:where(.icon) {
@apply h-4 w-4 transition-transform duration-200;
}
}

View File

@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import * as Accordion from './accordion.component'
const meta: Meta<typeof Accordion.Root> = {
title: 'Accordion',
component: Accordion.Root,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof Accordion.Root>
export const Default: Story = {
render: (props) => (
<Accordion.Root {...props}>
<Accordion.Item value="item-1">
<Accordion.Trigger>Sección 1</Accordion.Trigger>
<Accordion.Content>Contenido 1</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2" onClick={() => console.log('hola')}>
<Accordion.Trigger>Sección 2</Accordion.Trigger>
<Accordion.Content>Contenido 2</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('accordion')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './accordion.component'

View File

@@ -0,0 +1,144 @@
import React, { HTMLProps } from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import cn from 'classnames'
import { buttonVariants } from '../button'
import styles from './alert-dialog.module.css'
export const Root = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Root>
>(({ children, ...props }, ref) => (
<AlertDialogPrimitive.Root ref={ref} {...props}>
{children}
</AlertDialogPrimitive.Root>
))
export const AlertDialogRoot = Root
Root.displayName = 'AlertDialogRoot'
export const Trigger = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Trigger>
>(({ children, ...props }, ref) => (
<AlertDialogPrimitive.Trigger
data-testid="alert-dialog"
ref={ref}
{...props}
>
{children}
</AlertDialogPrimitive.Trigger>
))
export const AlertDialogTrigger = Trigger
Trigger.displayName = 'AlertDialogTrigger'
const AlertDialogPortal = ({
className,
children,
...props
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
<div className={styles.portal}>{children}</div>
</AlertDialogPrimitive.Portal>
)
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(styles.overlay, className)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
export const Modal = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(styles.content, className)}
{...props}
/>
</AlertDialogPortal>
))
export const AlertDialogModal = Modal
Modal.displayName = AlertDialogPrimitive.Content.displayName
export const Header = React.forwardRef<
HTMLDivElement,
HTMLProps<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn(styles.header, className)} {...props} />
))
export const AlertDialogHeader = Header
Header.displayName = 'AlertDialogHeader'
export const Footer = React.forwardRef<
HTMLDivElement,
HTMLProps<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn(styles.footer, className)} {...props} />
))
export const AlertDialogFooter = Footer
Footer.displayName = 'AlertDialogFooter'
export const Title = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn(styles.title, className)}
{...props}
/>
))
export const AlertDialogTitle = Title
Title.displayName = AlertDialogPrimitive.Title.displayName
export const Description = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn(styles.description, className)}
{...props}
/>
))
export const AlertDialogDescription = Description
Description.displayName = AlertDialogPrimitive.Description.displayName
export const Action = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
export const AlertDialogAction = Action
Action.displayName = AlertDialogPrimitive.Action.displayName
export const Cancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
styles.cancel,
className
)}
{...props}
/>
))
export const AlertDialogCancel = Cancel
Cancel.displayName = AlertDialogPrimitive.Cancel.displayName

View File

@@ -0,0 +1,31 @@
/* elements */
div:where(.portal) {
@apply fixed inset-0 z-50 flex items-end justify-center sm:items-center;
:where(.overlay) {
@apply fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-opacity;
}
:where(.content) {
@apply fixed z-50 grid w-full max-w-lg scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg sm:rounded-lg md:w-full;
}
:where(.header) {
@apply flex flex-col space-y-2 text-center sm:text-left;
}
:where(.footer) {
@apply flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2;
}
:where(.title) {
@apply text-lg font-semibold;
}
:where(.description) {
@apply text-sm text-muted-fg;
}
:where(.cancel) {
@apply mt-2 sm:mt-0;
}
}

View File

@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import * as AlertDialog from './alert-dialog.component'
import { Button } from '../button'
const meta: Meta<typeof AlertDialog.Root> = {
title: 'AlertDialog',
component: AlertDialog.Root,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof AlertDialog.Root>
export const Default: Story = {
render: (p) => (
<AlertDialog.Root {...p}>
<AlertDialog.Trigger asChild>
<Button variant="outline">Show Dialog</Button>
</AlertDialog.Trigger>
<AlertDialog.Modal>
<AlertDialog.Header>
<AlertDialog.Title>Are you sure?</AlertDialog.Title>
<AlertDialog.Description>
This action cannot be undone
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action>Continue</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Modal>
</AlertDialog.Root>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('alert-dialog')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './alert-dialog.component'

View File

@@ -0,0 +1,49 @@
import React from 'react'
import { VariantProps, cva } from 'class-variance-authority'
import cn from 'classnames'
import styles from './alert.module.css'
export const alertVariants = cva(styles.container, {
variants: {
variant: {
default: styles.defaultVariant,
danger: styles.danger,
},
},
defaultVariants: {
variant: 'default',
},
})
export const Root = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
data-testid="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
export const AlertRoot = Root
Root.displayName = 'AlertRoot'
export const Title = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5 ref={ref} className={cn(styles.title, className)} {...props} />
))
export const AlertTitle = Title
Title.displayName = 'AlertTitle'
export const Description = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn(styles.description, className)} {...props} />
))
export const AlertDescription = Description
Description.displayName = 'AlertDescription'

View File

@@ -0,0 +1,21 @@
div:where(.container) {
@apply relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11;
/* variants */
:where(.defaultVariant) {
@apply bg-background text-foreground;
}
:where(.danger) {
@apply text-danger border-danger/50 dark:border-danger [&>svg]:text-danger;
}
/* elements */
:where(.title) {
@apply flex mb-1 font-medium leading-none tracking-tight;
}
:where(.description) {
@apply text-sm [&_p]:leading-relaxed;
}
}

View File

@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import * as Alert from './alert.component'
import { Terminal } from 'lucide-react'
const meta: Meta<typeof Alert.Root> = {
title: 'Alert',
component: Alert.Root,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof Alert.Root>
export const Default: Story = {
render: (p) => (
<Alert.Root {...p}>
<Alert.Title>
<Terminal className="h-4 w-4" />
<span className="pl-2">Heads up!</span>
</Alert.Title>
<Alert.Description>
You can add new components to your app using the blueprints.
</Alert.Description>
</Alert.Root>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('alert')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './alert.component'

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import { AspectRatio } from './aspect-ratio.component'
const meta: Meta<typeof AspectRatio> = {
title: 'AspectRatio',
component: AspectRatio,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof AspectRatio>
export const Default: Story = {
render: (p) => (
<AspectRatio
{...p}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'red',
}}
ratio={10}
>
<div style={{ height: 10, width: 10, backgroundColor: 'teal' }} />
</AspectRatio>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('aspect-ratio')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './aspect-ratio.component'

View File

@@ -0,0 +1,44 @@
import React from 'react'
import cn from 'classnames'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import styles from './avatar.module.css'
export const Root = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
data-testid="avatar"
className={cn(styles.container, className)}
{...props}
/>
))
export const AvatarRoot = Root
Root.displayName = AvatarPrimitive.Root.displayName
export const Image = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn(styles.image, className)}
{...props}
/>
))
export const AvatarImage = Image
Image.displayName = AvatarPrimitive.Image.displayName
export const Fallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(styles.fallback, className)}
{...props}
/>
))
export const AvatarFallback = Fallback
Fallback.displayName = AvatarPrimitive.Fallback.displayName

View File

@@ -0,0 +1,10 @@
span:where(.container) {
@apply relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full;
:where(.image) {
@apply aspect-square h-full w-full;
}
:where(.fallback) {
@apply flex h-full w-full items-center justify-center rounded-full bg-muted;
}
}

View File

@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/react'
import { expect } from '@storybook/jest'
import { within } from '@storybook/testing-library'
import * as Avatar from './avatar.component'
const meta: Meta<typeof Avatar.Root> = {
title: 'Avatar',
component: Avatar.Root,
argTypes: {},
}
export default meta
type Story = StoryObj<typeof Avatar.Root>
export const Default: Story = {
render: (p) => (
<Avatar.Root {...p}>
<Avatar.Image src="https://github.com/pibone.png" alt="@pibone" />
<Avatar.Fallback>PI</Avatar.Fallback>
</Avatar.Root>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('avatar')
expect(container).toBeTruthy()
},
}
export const DefaultFallback: Story = {
render: (p) => (
<Avatar.Root {...p}>
<Avatar.Image
src="https://terrible-invalid-url.com/invalid-image.png"
alt="@pibone"
/>
<Avatar.Fallback>PI</Avatar.Fallback>
</Avatar.Root>
),
args: {},
async play({ canvasElement }) {
const canvas = within(canvasElement)
const container = canvas.getByTestId('avatar')
expect(container).toBeTruthy()
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export * from './avatar.component'

Some files were not shown because too many files have changed in this diff Show More