Compare commits

..

28 Commits

Author SHA1 Message Date
65acea9917 Add devcontainer configuration
Some checks failed
build-docker-imge / Build the docker container (push) Failing after 32s
2024-10-26 08:54:06 +02:00
9d72e3443b Bulk-commit
Some checks failed
build-docker-imge / Build the docker container (push) Failing after 1m25s
2024-10-26 08:43:37 +02:00
af0e2919f4 Move callbacks to the default switch blocks...
All checks were successful
build-docker-imge / Build the docker container (push) Successful in 28m24s
2024-10-06 21:10:50 +02:00
beea84a6e9 Remove trailing spaces in link
All checks were successful
build-docker-imge / Build the docker container (push) Successful in 28m7s
2024-10-06 20:38:14 +02:00
b6570b2385 use window instead of global for the RWJS_API_URL Variable
Some checks failed
build-docker-imge / Build the docker container (push) Has been cancelled
2024-10-06 20:13:15 +02:00
d514029e5a Move link-building to the server...
All checks were successful
build-docker-imge / Build the docker container (push) Successful in 28m38s
2024-10-06 17:55:27 +02:00
086db87972 Adjust apiUrl
All checks were successful
build-docker-imge / Build the docker container (push) Successful in 27m50s
2024-10-06 16:47:47 +02:00
e90bf240e8 Reset typescript version in yarn.lock
All checks were successful
build-docker-imge / Build the docker container (push) Successful in 29m35s
2024-10-06 15:32:35 +02:00
3a56a6b892 Add crypto-js to required Modules
Some checks failed
build-docker-imge / Build the docker container (push) Failing after 44s
2024-10-06 14:44:37 +02:00
6c176b3cdb Container-tags: add version, use diffrent names for web, api, and console
All checks were successful
build-docker-imge / Build the docker container (push) Successful in 27m43s
2024-10-06 10:22:54 +02:00
b97c2f7fbc Container-tags: remove version
All checks were successful
build-docker-imge / Build the docker container (push) Successful in 38m47s
2024-10-04 20:36:19 +02:00
13a5edc9ac Build each target container individually
Some checks failed
build-docker-imge / Build the docker container (push) Failing after 8m29s
build-docker-imge / Build the docker container (pull_request) Failing after 8m7s
2024-10-04 19:55:55 +02:00
12e4b46761 Update .gitea/workflows/build-docker-container.yml
All checks were successful
build-docker-imge / Build the docker container (push) Successful in 10m39s
build-docker-imge / Build the docker container (pull_request) Successful in 10m39s
2024-10-04 17:33:08 +00:00
3d911448c4 push to the correct container registry 2024-10-04 17:33:08 +00:00
4f012b6b52 Fix pipeline 3 2024-10-04 17:33:08 +00:00
fd6ffabb89 Fix pipelines 2 2024-10-04 17:33:08 +00:00
02401550fc Fix pipeline builds. 2024-10-04 17:33:08 +00:00
9e8f21ff68 Fix: CI Pipeline 2024-10-04 17:33:08 +00:00
f793bc957b remove debug steps 2024-10-04 17:33:08 +00:00
db82158cc1 Debug Pipelines 2024-10-04 17:33:08 +00:00
14d846a456 Fix: Pipeline push permissions 2024-10-04 17:33:08 +00:00
af0d3d2c53 New: Automatic Docker container builds 2024-10-04 17:33:08 +00:00
fd5aa79278 New: Hero Page und Navigation 2024-10-04 16:46:22 +02:00
e2902457e2 New: Tailwindcss 2024-10-04 10:52:45 +02:00
70afa170ec Implement RBAC 2024-10-04 10:31:09 +02:00
5251a637de Wrap Posts route with a private set 2024-10-04 09:51:20 +02:00
1a12ed6c9c Add a Posts Schema just to test authentication 2024-10-04 09:50:58 +02:00
437e49b842 Fix: oauth cookie (not tested in prod) 2024-10-04 09:35:40 +02:00
122 changed files with 158901 additions and 2124 deletions

14
.devcontainer/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
ARG VARIANT=18-bullseye
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT}
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
# [Optional] Uncomment if you want to install more global node modules
# RUN su node -c "npm install -g <your-package-list-here>"

View File

@ -0,0 +1,29 @@
{
"name": "RedwoosJS & PostgreSQL",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or with the host.
"forwardPorts": [ 8910, 8911, 5432, 5555 ],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bash scripts/setup.sh",
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"ofhumanbondage.react-proptypes-intellisense",
"mgmcdermott.vscode-language-babel",
"editorconfig.editorconfig",
"prisma.prisma",
"graphql.vscode-graphql"
]
}
},
"remoteUser": "node"
}

View File

@ -0,0 +1,64 @@
version: '3.8'
services:
app:
# Using a Dockerfile is optional, but included for completeness.
build:
context: .
dockerfile: Dockerfile
args:
# Update 'VARIANT' to pick an LTS version of Node.js: 18, 16, 14.
# Append -bullseye or -buster to pin to an OS version.
# Use -bullseye variants on local arm64/Apple Silicon.
VARIANT: 18-bullseye
volumes:
# This is where VS Code should expect to find your project's source code and the value of "workspaceFolder" in .devcontainer/devcontainer.json
- ..:/workspace:cached
# Uncomment the next line to use Docker from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker-compose for details.
# - /var/run/docker.sock:/var/run/docker.sock
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
# Runs app on the same network as the service container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:db
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
# Uncomment the next line to use a non-root user for all processes - See https://aka.ms/vscode-remote/containers/non-root for details.
# user: vscode
# Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust.
# cap_add:
# - SYS_PTRACE
# security_opt:
# - seccomp:unconfined
# You can include other services not opened by VS Code as well
db:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_DB: development_db
POSTGRES_PASSWORD: postgres
db-test:
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-test-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_DB: test_db
POSTGRES_PASSWORD: postgres
# As in the "app" service, use "forwardPorts" in **devcontainer.json** to forward an app port locally.
volumes:
postgres-data:
postgres-test-data:

View File

@ -1,7 +1,6 @@
name: build-docker-imge
on:
- push
- pull_request
jobs:
build:
@ -17,12 +16,35 @@ jobs:
- name: "Git checkout"
run: git checkout "${{ gitea.sha }}"
- uses: aevea/action-kaniko@master
name: Run Kaniko to build our docker container.
name: Run Kaniko to build our api docker container.
with:
image: kocoded/nachhilfesystem/nachhilfesystem
tag: ${{ gitea.workflow_sha }}
tag_with_latest: true
image: kocoded/nachhilfesystem/api
tag: ${{ git.workflow_sha }}
tag_with_latest: github.ref == 'refs/heads/master'
registry: git.kocoder.xyz
username: ${{ secrets.CI_RUNNER_USER }}
password: ${{ secrets.CI_RUNNER_TOKEN }}
build_file: Dockerfile
target: api_serve
- uses: aevea/action-kaniko@master
name: Run Kaniko to build our web docker container.
with:
image: kocoded/nachhilfesystem/web
tag: ${{ git.workflow_sha }}
tag_with_latest: github.ref == 'refs/heads/master'
registry: git.kocoder.xyz
username: ${{ secrets.CI_RUNNER_USER }}
password: ${{ secrets.CI_RUNNER_TOKEN }}
build_file: Dockerfile
target: web_serve
- uses: aevea/action-kaniko@master
name: Run Kaniko to build our console docker container.
with:
image: kocoded/nachhilfesystem/console
tag: ${{ git.workflow_sha }}
tag_with_latest: github.ref == 'refs/heads/master'
registry: git.kocoder.xyz
username: ${{ secrets.CI_RUNNER_USER }}
password: ${{ secrets.CI_RUNNER_TOKEN }}
build_file: Dockerfile
target: console

View File

@ -8,7 +8,11 @@
"pflannery.vscode-versionlens",
"editorconfig.editorconfig",
"prisma.prisma",
"graphql.vscode-graphql"
"graphql.vscode-graphql",
"csstools.postcss",
"bradlc.vscode-tailwindcss",
"csstools.postcss",
"bradlc.vscode-tailwindcss"
],
"unwantedRecommendations": []
}
}

View File

@ -7,5 +7,11 @@
},
"[prisma]": {
"editor.formatOnSave": true
}
},
"tailwindCSS.classAttributes": [
"class",
"className",
"activeClassName",
"errorClassName"
]
}

147513
.yarn/releases/yarn-1.22.21.cjs vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,4 +6,4 @@ nmMode: hardlinks-local
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.4.0.cjs
yarnPath: .yarn/releases/yarn-1.22.21.cjs

View File

@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "Post" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);

View File

@ -0,0 +1,22 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"firstName" TEXT,
"lastName" TEXT,
"hashedPassword" TEXT,
"salt" TEXT,
"resetToken" TEXT,
"resetTokenExpiresAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"roles" TEXT NOT NULL DEFAULT 'user'
);
INSERT INTO "new_User" ("createdAt", "email", "firstName", "hashedPassword", "id", "lastName", "resetToken", "resetTokenExpiresAt", "salt", "updatedAt") SELECT "createdAt", "email", "firstName", "hashedPassword", "id", "lastName", "resetToken", "resetTokenExpiresAt", "salt", "updatedAt" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "Nachhilfeangebot" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"subject" TEXT NOT NULL,
"currentClass" TEXT NOT NULL,
"cost" DECIMAL NOT NULL
);

View File

@ -0,0 +1,22 @@
/*
Warnings:
- Added the required column `userId` to the `Nachhilfeangebot` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Nachhilfeangebot" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"subject" TEXT NOT NULL,
"currentClass" TEXT NOT NULL,
"cost" DECIMAL NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "Nachhilfeangebot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Nachhilfeangebot" ("cost", "currentClass", "id", "subject") SELECT "cost", "currentClass", "id", "subject" FROM "Nachhilfeangebot";
DROP TABLE "Nachhilfeangebot";
ALTER TABLE "new_Nachhilfeangebot" RENAME TO "Nachhilfeangebot";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the `UserExample` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "UserExample";
PRAGMA foreign_keys=on;

View File

@ -14,27 +14,20 @@ generator client {
binaryTargets = "native"
}
// Define your own datamodels here and run `yarn redwood prisma migrate dev`
// to create migrations for them and apply to your dev DB.
// TODO: Please remove the following example:
model UserExample {
id Int @id @default(autoincrement())
email String @unique
name String?
}
model User {
id Int @id @default(autoincrement())
email String @unique
id Int @id @default(autoincrement())
email String @unique
firstName String?
lastName String?
hashedPassword String?
salt String?
identites Identity[]
resetToken String?
resetTokenExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles String @default("user")
identites Identity[]
Nachhilfeangebot Nachhilfeangebot[]
}
model Identity {
@ -52,3 +45,20 @@ model Identity {
@@unique([provider, uid])
@@index(userId)
}
model Post {
id Int @id @default(autoincrement())
title String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Nachhilfeangebot {
id Int @id @default(autoincrement())
subject String
currentClass String
cost Decimal
userId Int
user User @relation(fields: [userId], references: [id])
}

View File

@ -3,9 +3,13 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@redwoodjs/api": "8.3.0",
"@redwoodjs/api-server": "8.3.0",
"@redwoodjs/auth-dbauth-api": "8.3.0",
"@redwoodjs/graphql-server": "8.3.0"
"@redwoodjs/api": "8.4.0",
"@redwoodjs/api-server": "8.4.0",
"@redwoodjs/auth-dbauth-api": "8.4.0",
"@redwoodjs/graphql-server": "8.4.0",
"crypto-js": "^4.2.0"
},
"devDependencies": {
"@types/crypto-js": "^4"
}
}

View File

@ -6,11 +6,26 @@ import type { DbAuthHandlerOptions, UserType } from '@redwoodjs/auth-dbauth-api'
import { cookieName } from 'src/lib/auth'
import { db } from 'src/lib/db'
export const cookie = {
attributes: {
HttpOnly: true,
Path: '/',
SameSite: 'Strict',
Secure: process.env.NODE_ENV !== 'development',
// If you need to allow other domains (besides the api side) access to
// the dbAuth session cookie:
// Domain: 'example.com',
},
name: cookieName,
}
export const handler = async (
event: APIGatewayProxyEvent,
context: Context
) => {
const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = {
enabled: false,
// handler() is invoked after verifying that a user was found with the given
// username. This is where you can send the user an email with a link to
// reset their password. With the default dbAuth routes and field names, the
@ -61,6 +76,7 @@ export const handler = async (
// didn't validate their email yet), throw an error and it will be returned
// by the `logIn()` function from `useAuth()` in the form of:
// `{ message: 'Error message' }`
enabled: false,
handler: (user) => {
return user
},
@ -86,6 +102,7 @@ export const handler = async (
handler: (_user) => {
return true
},
enabled: false,
// If `false` then the new password MUST be different from the current one
allowReusedPassword: true,
@ -125,6 +142,7 @@ export const handler = async (
//
// If this returns anything else, it will be returned by the
// `signUp()` function in the form of: `{ message: 'String here' }`.
enabled: false,
handler: ({
username,
hashedPassword,
@ -183,19 +201,7 @@ export const handler = async (
// Specifies attributes on the cookie that dbAuth sets in order to remember
// who is logged in. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies
cookie: {
attributes: {
HttpOnly: true,
Path: '/',
SameSite: 'Strict',
Secure: process.env.NODE_ENV !== 'development',
// If you need to allow other domains (besides the api side) access to
// the dbAuth session cookie:
// Domain: 'example.com',
},
name: cookieName,
},
cookie,
forgotPassword: forgotPasswordOptions,
login: loginOptions,

View File

@ -27,9 +27,10 @@ export const handler = async (event: APIGatewayEvent, _context: Context) => {
case '/oauth/microsoft/callback':
return await callback(event)
default:
return {
return await callback(event)
/*return {
statusCode: 404,
}
}*/
}
}
@ -107,7 +108,7 @@ const secureCookie = (user) => {
process.env.SESSION_SECRET
).toString()
return [`session=${encrypted}`, ...cookieAttrs].join('; ')
return [`session_8911=${encrypted}`, ...cookieAttrs].join('; ')
}
const getUser = async ({ providerUser, accessToken, scope }) => {

View File

@ -0,0 +1,8 @@
import type { ScenarioData } from '@redwoodjs/testing/api'
export const standard = defineScenario({
// Define the "fixture" to write into your test database here
// See guide: https://redwoodjs.com/docs/testing#scenarios
})
export type StandardScenario = ScenarioData<unknown>

View File

@ -0,0 +1,29 @@
import { mockHttpEvent, mockContext } from '@redwoodjs/testing/api'
import { handler } from './oauthStart'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-functions
describe('oauthStart function', () => {
it('Should respond with 200', async () => {
const httpEvent = mockHttpEvent({
queryStringParameters: {
id: '42', // Add parameters here
},
})
const response = await handler(httpEvent, mockContext())
const { data } = JSON.parse(response.body)
expect(response.statusCode).toBe(200)
expect(data).toBe('oauthStart function')
})
// You can also use scenarios to test your api functions
// See guide here: https://redwoodjs.com/docs/testing#scenarios
//
// scenario('Scenario test', async () => {
//
// })
})

View File

@ -0,0 +1,42 @@
import type { APIGatewayEvent, Context } from 'aws-lambda'
import { logger } from 'src/lib/logger'
/**
* The handler function is your code that processes http request events.
* You can use return and throw to send a response or error, respectively.
*
* Important: When deployed, a custom serverless function is an open API endpoint and
* is your responsibility to secure appropriately.
*
* @see {@link https://redwoodjs.com/docs/serverless-functions#security-considerations|Serverless Function Considerations}
* in the RedwoodJS documentation for more information.
*
* @typedef { import('aws-lambda').APIGatewayEvent } APIGatewayEvent
* @typedef { import('aws-lambda').Context } Context
* @param { APIGatewayEvent } event - an object which contains information from the invoker.
* @param { Context } _context - contains information about the invocation,
* function, and execution environment.
*/
export const handler = async (event: APIGatewayEvent, _context: Context) => {
logger.info(`${event.httpMethod} ${event.path}: oauth function`)
switch (event.path) {
case '/oauthStart/microsoft':
return await callback()
default:
return await callback()
/* return {
statusCode: 404,
} */
}
}
const callback = async () => {
return {
statusCode: 302,
headers: {
Location: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${process.env.MICROSOFT_OAUTH_CLIENT_ID}&grant_type=authorization_code&response_type=code&redirect_uri=${process.env.MICROSOFT_OAUTH_REDIRECT_URI}&scope=${process.env.MICROSOFT_OAUTH_SCOPES.split(' ').join('+')}`,
},
}
}

View File

@ -0,0 +1,36 @@
export const schema = gql`
type Identity {
id: Int!
provider: String!
# uid: String!
userId: Int!
user: User!
# accessToken: String
# scope: String
lastLoginAt: DateTime!
createdAt: DateTime!
updatedAt: DateTime!
}
type Query {
identities: [Identity!]! @requireAuth
}
input CreateIdentityInput {
provider: String!
uid: String!
userId: Int!
accessToken: String
scope: String
lastLoginAt: DateTime!
}
input UpdateIdentityInput {
provider: String
uid: String
userId: Int
accessToken: String
scope: String
lastLoginAt: DateTime
}
`

View File

@ -0,0 +1,40 @@
export const schema = gql`
type Nachhilfeangebot {
id: Int!
subject: String!
currentClass: String!
cost: Float!
userId: Int!
user: User!
}
type Query {
nachhilfeangebots: [Nachhilfeangebot!]! @requireAuth
nachhilfeangebot(id: Int!): Nachhilfeangebot @requireAuth
}
input CreateNachhilfeangebotInput {
subject: String!
currentClass: String!
cost: Float!
userId: Int!
}
input UpdateNachhilfeangebotInput {
subject: String
currentClass: String
cost: Float
userId: Int
}
type Mutation {
createNachhilfeangebot(
input: CreateNachhilfeangebotInput!
): Nachhilfeangebot! @requireAuth
updateNachhilfeangebot(
id: Int!
input: UpdateNachhilfeangebotInput!
): Nachhilfeangebot! @requireAuth
deleteNachhilfeangebot(id: Int!): Nachhilfeangebot! @requireAuth
}
`

View File

@ -0,0 +1,30 @@
export const schema = gql`
type Post {
id: Int!
title: String!
body: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Query {
posts: [Post!]! @requireAuth
post(id: Int!): Post @requireAuth
}
input CreatePostInput {
title: String!
body: String!
}
input UpdatePostInput {
title: String
body: String
}
type Mutation {
createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
deletePost(id: Int!): Post! @requireAuth
}
`

View File

@ -0,0 +1,43 @@
export const schema = gql`
type User {
id: Int!
email: String!
firstName: String
lastName: String
# hashedPassword: String
# salt: String
# resetToken: String
# resetTokenExpiresAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
roles: String!
identites: [Identity]!
Nachhilfeangebot: [Nachhilfeangebot]!
}
type Query {
users: [User!]! @requireAuth
}
input CreateUserInput {
email: String!
firstName: String
lastName: String
hashedPassword: String
salt: String
resetToken: String
resetTokenExpiresAt: DateTime
roles: String!
}
input UpdateUserInput {
email: String
firstName: String
lastName: String
hashedPassword: String
salt: String
resetToken: String
resetTokenExpiresAt: DateTime
roles: String
}
`

View File

@ -36,7 +36,13 @@ export const getCurrentUser = async (session: Decoded) => {
return await db.user.findUnique({
where: { id: session.id },
select: { id: true },
select: {
id: true,
email: true,
roles: true,
firstName: true,
lastName: true,
},
})
}

View File

@ -0,0 +1,35 @@
import type { Prisma, Identity } from '@prisma/client'
import type { ScenarioData } from '@redwoodjs/testing/api'
export const standard = defineScenario<Prisma.IdentityCreateArgs>({
identity: {
one: {
data: {
provider: 'String',
uid: 'String',
updatedAt: '2024-10-04T12:47:55.457Z',
user: {
create: {
email: 'String3211260',
updatedAt: '2024-10-04T12:47:55.457Z',
},
},
},
},
two: {
data: {
provider: 'String',
uid: 'String',
updatedAt: '2024-10-04T12:47:55.457Z',
user: {
create: {
email: 'String2559297',
updatedAt: '2024-10-04T12:47:55.457Z',
},
},
},
},
},
})
export type StandardScenario = ScenarioData<Identity, 'identity'>

View File

@ -0,0 +1,18 @@
import type { Identity } from '@prisma/client'
import { identities } from './identities'
import type { StandardScenario } from './identities.scenarios'
// Generated boilerplate tests do not account for all circumstances
// and can fail without adjustments, e.g. Float.
// Please refer to the RedwoodJS Testing Docs:
// https://redwoodjs.com/docs/testing#testing-services
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
describe('identities', () => {
scenario('returns all identities', async (scenario: StandardScenario) => {
const result = await identities()
expect(result.length).toEqual(Object.keys(scenario.identity).length)
})
})

View File

@ -0,0 +1,19 @@
import type { QueryResolvers, IdentityRelationResolvers } from 'types/graphql'
import { db } from 'src/lib/db'
export const identities: QueryResolvers['identities'] = () => {
return db.identity.findMany()
}
export const identity: QueryResolvers['identity'] = ({ id }) => {
return db.identity.findUnique({
where: { id },
})
}
export const Identity: IdentityRelationResolvers = {
user: (_obj, { root }) => {
return db.identity.findUnique({ where: { id: root?.id } }).user()
},
}

View File

@ -0,0 +1,38 @@
import type { Prisma, Nachhilfeangebot } from '@prisma/client'
import type { ScenarioData } from '@redwoodjs/testing/api'
export const standard = defineScenario<Prisma.NachhilfeangebotCreateArgs>({
nachhilfeangebot: {
one: {
data: {
subject: 'String',
currentClass: 'String',
cost: 3141165.177524339,
user: {
create: {
email: 'String393837',
updatedAt: '2024-10-25T18:31:22.454Z',
},
},
},
},
two: {
data: {
subject: 'String',
currentClass: 'String',
cost: 7812680.539293259,
user: {
create: {
email: 'String8800647',
updatedAt: '2024-10-25T18:31:22.454Z',
},
},
},
},
},
})
export type StandardScenario = ScenarioData<
Nachhilfeangebot,
'nachhilfeangebot'
>

View File

@ -0,0 +1,77 @@
import { Prisma, Nachhilfeangebot } from '@prisma/client'
import {
nachhilfeangebots,
nachhilfeangebot,
createNachhilfeangebot,
updateNachhilfeangebot,
deleteNachhilfeangebot,
} from './nachhilfeangebots'
import type { StandardScenario } from './nachhilfeangebots.scenarios'
// Generated boilerplate tests do not account for all circumstances
// and can fail without adjustments, e.g. Float.
// Please refer to the RedwoodJS Testing Docs:
// https://redwoodjs.com/docs/testing#testing-services
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
describe('nachhilfeangebots', () => {
scenario(
'returns all nachhilfeangebots',
async (scenario: StandardScenario) => {
const result = await nachhilfeangebots()
expect(result.length).toEqual(
Object.keys(scenario.nachhilfeangebot).length
)
}
)
scenario(
'returns a single nachhilfeangebot',
async (scenario: StandardScenario) => {
const result = await nachhilfeangebot({
id: scenario.nachhilfeangebot.one.id,
})
expect(result).toEqual(scenario.nachhilfeangebot.one)
}
)
scenario('creates a nachhilfeangebot', async (scenario: StandardScenario) => {
const result = await createNachhilfeangebot({
input: {
subject: 'String',
currentClass: 'String',
cost: 5231481.901387487,
userId: scenario.nachhilfeangebot.two.userId,
},
})
expect(result.subject).toEqual('String')
expect(result.currentClass).toEqual('String')
expect(result.cost).toEqual(new Prisma.Decimal(5231481.901387487))
expect(result.userId).toEqual(scenario.nachhilfeangebot.two.userId)
})
scenario('updates a nachhilfeangebot', async (scenario: StandardScenario) => {
const original = (await nachhilfeangebot({
id: scenario.nachhilfeangebot.one.id,
})) as Nachhilfeangebot
const result = await updateNachhilfeangebot({
id: original.id,
input: { subject: 'String2' },
})
expect(result.subject).toEqual('String2')
})
scenario('deletes a nachhilfeangebot', async (scenario: StandardScenario) => {
const original = (await deleteNachhilfeangebot({
id: scenario.nachhilfeangebot.one.id,
})) as Nachhilfeangebot
const result = await nachhilfeangebot({ id: original.id })
expect(result).toEqual(null)
})
})

View File

@ -0,0 +1,47 @@
import type {
QueryResolvers,
MutationResolvers,
NachhilfeangebotRelationResolvers,
} from 'types/graphql'
import { db } from 'src/lib/db'
export const nachhilfeangebots: QueryResolvers['nachhilfeangebots'] = () => {
return db.nachhilfeangebot.findMany()
}
export const nachhilfeangebot: QueryResolvers['nachhilfeangebot'] = ({
id,
}) => {
return db.nachhilfeangebot.findUnique({
where: { id },
})
}
export const createNachhilfeangebot: MutationResolvers['createNachhilfeangebot'] =
({ input }) => {
return db.nachhilfeangebot.create({
data: input,
})
}
export const updateNachhilfeangebot: MutationResolvers['updateNachhilfeangebot'] =
({ id, input }) => {
return db.nachhilfeangebot.update({
data: input,
where: { id },
})
}
export const deleteNachhilfeangebot: MutationResolvers['deleteNachhilfeangebot'] =
({ id }) => {
return db.nachhilfeangebot.delete({
where: { id },
})
}
export const Nachhilfeangebot: NachhilfeangebotRelationResolvers = {
user: (_obj, { root }) => {
return db.nachhilfeangebot.findUnique({ where: { id: root?.id } }).user()
},
}

View File

@ -0,0 +1,23 @@
import type { Prisma, Post } from '@prisma/client'
import type { ScenarioData } from '@redwoodjs/testing/api'
export const standard = defineScenario<Prisma.PostCreateArgs>({
post: {
one: {
data: {
title: 'String',
body: 'String',
updatedAt: '2024-10-04T07:38:59.006Z',
},
},
two: {
data: {
title: 'String',
body: 'String',
updatedAt: '2024-10-04T07:38:59.006Z',
},
},
},
})
export type StandardScenario = ScenarioData<Post, 'post'>

View File

@ -0,0 +1,55 @@
import type { Post } from '@prisma/client'
import { posts, post, createPost, updatePost, deletePost } from './posts'
import type { StandardScenario } from './posts.scenarios'
// Generated boilerplate tests do not account for all circumstances
// and can fail without adjustments, e.g. Float.
// Please refer to the RedwoodJS Testing Docs:
// https://redwoodjs.com/docs/testing#testing-services
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
describe('posts', () => {
scenario('returns all posts', async (scenario: StandardScenario) => {
const result = await posts()
expect(result.length).toEqual(Object.keys(scenario.post).length)
})
scenario('returns a single post', async (scenario: StandardScenario) => {
const result = await post({ id: scenario.post.one.id })
expect(result).toEqual(scenario.post.one)
})
scenario('creates a post', async () => {
const result = await createPost({
input: {
title: 'String',
body: 'String',
updatedAt: '2024-10-04T07:38:58.985Z',
},
})
expect(result.title).toEqual('String')
expect(result.body).toEqual('String')
expect(result.updatedAt).toEqual(new Date('2024-10-04T07:38:58.985Z'))
})
scenario('updates a post', async (scenario: StandardScenario) => {
const original = (await post({ id: scenario.post.one.id })) as Post
const result = await updatePost({
id: original.id,
input: { title: 'String2' },
})
expect(result.title).toEqual('String2')
})
scenario('deletes a post', async (scenario: StandardScenario) => {
const original = (await deletePost({ id: scenario.post.one.id })) as Post
const result = await post({ id: original.id })
expect(result).toEqual(null)
})
})

View File

@ -0,0 +1,32 @@
import type { QueryResolvers, MutationResolvers } from 'types/graphql'
import { db } from 'src/lib/db'
export const posts: QueryResolvers['posts'] = () => {
return db.post.findMany()
}
export const post: QueryResolvers['post'] = ({ id }) => {
return db.post.findUnique({
where: { id },
})
}
export const createPost: MutationResolvers['createPost'] = ({ input }) => {
return db.post.create({
data: input,
})
}
export const updatePost: MutationResolvers['updatePost'] = ({ id, input }) => {
return db.post.update({
data: input,
where: { id },
})
}
export const deletePost: MutationResolvers['deletePost'] = ({ id }) => {
return db.post.delete({
where: { id },
})
}

View File

@ -0,0 +1,15 @@
import type { Prisma, User } from '@prisma/client'
import type { ScenarioData } from '@redwoodjs/testing/api'
export const standard = defineScenario<Prisma.UserCreateArgs>({
user: {
one: {
data: { email: 'String5874784', updatedAt: '2024-10-04T12:47:22.490Z' },
},
two: {
data: { email: 'String7499025', updatedAt: '2024-10-04T12:47:22.490Z' },
},
},
})
export type StandardScenario = ScenarioData<User, 'user'>

View File

@ -0,0 +1,18 @@
import type { User } from '@prisma/client'
import { users } from './users'
import type { StandardScenario } from './users.scenarios'
// Generated boilerplate tests do not account for all circumstances
// and can fail without adjustments, e.g. Float.
// Please refer to the RedwoodJS Testing Docs:
// https://redwoodjs.com/docs/testing#testing-services
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
describe('users', () => {
scenario('returns all users', async (scenario: StandardScenario) => {
const result = await users()
expect(result.length).toEqual(Object.keys(scenario.user).length)
})
})

View File

@ -0,0 +1,22 @@
import type { QueryResolvers, UserRelationResolvers } from 'types/graphql'
import { db } from 'src/lib/db'
export const users: QueryResolvers['users'] = () => {
return db.user.findMany()
}
export const user: QueryResolvers['user'] = ({ id }) => {
return db.user.findUnique({
where: { id },
})
}
export const User: UserRelationResolvers = {
identites: (_obj, { root }) => {
return db.user.findUnique({ where: { id: root?.id } }).identites()
},
Nachhilfeangebot: (_obj, { root }) => {
return db.user.findUnique({ where: { id: root?.id } }).Nachhilfeangebot()
},
}

View File

@ -7,9 +7,11 @@
]
},
"devDependencies": {
"@redwoodjs/auth-dbauth-setup": "8.3.0",
"@redwoodjs/core": "8.3.0",
"@redwoodjs/project-config": "8.3.0"
"@redwoodjs/auth-dbauth-setup": "8.4.0",
"@redwoodjs/cli-storybook-vite": "8.4.0",
"@redwoodjs/core": "8.4.0",
"@redwoodjs/project-config": "8.4.0",
"prettier-plugin-tailwindcss": "^0.5.12"
},
"eslintConfig": {
"extends": "@redwoodjs/eslint-config",
@ -21,11 +23,15 @@
"prisma": {
"seed": "yarn rw exec seed"
},
"packageManager": "yarn@4.4.0",
"packageManager": "yarn@1.22.21",
"resolutions": {
"@storybook/react-dom-shim@npm:7.6.17": "https://verdaccio.tobbe.dev/@storybook/react-dom-shim/-/react-dom-shim-8.0.8.tgz"
},
"dependencies": {
"@headlessui/react": "^2.1.9",
"crypto-js": "^4.2.0"
},
"scripts": {
"shad": "cd web && npx shadcn@latest add"
}
}

View File

@ -15,4 +15,6 @@ module.exports = {
},
},
],
tailwindConfig: './web/config/tailwind.config.js',
plugins: ['prettier-plugin-tailwindcss'],
}

View File

@ -8,11 +8,8 @@
[web]
title = "Redwood App"
port = 8910
apiUrl = "/.redwood/functions" # You can customize graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths
apiUrl = "/api" # You can customize graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths
includeEnvironmentVariables = [
"MICROSOFT_OAUTH_CLIENT_ID",
"MICROSOFT_OAUTH_SCOPES",
"MICROSOFT_OAUTH_REDIRECT_URI",
# Add any ENV vars that should be available to the web side to this array
# See https://redwoodjs.com/docs/environment-variables#web
]

19
web/.storybook/main.ts Normal file
View File

@ -0,0 +1,19 @@
import type { StorybookConfig } from 'storybook-framework-redwoodjs-vite'
import { getPaths, importStatementPath } from '@redwoodjs/project-config'
const redwoodProjectPaths = getPaths()
const config: StorybookConfig = {
framework: 'storybook-framework-redwoodjs-vite',
stories: [
`${importStatementPath(
redwoodProjectPaths.web.src
)}/**/*.stories.@(js|jsx|ts|tsx|mdx)`,
],
addons: ['@storybook/addon-essentials'],
}
export default config

View File

@ -0,0 +1 @@
<div id="redwood-app"></div>

18
web/components.json Normal file
View File

@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"aliases": {
"components": "src/components",
"ui": "src/components/ui",
"utils": "src/lib/utils"
},
"style": "default",
"tailwind": {
"baseColor": "neutral",
"config": "./config/tailwind.config.js",
"css": "./src/index.css",
"cssVariables": true,
"prefix": "",
"rsc": false,
"tsx": true
}
}

View File

@ -0,0 +1,9 @@
const path = require('path')
module.exports = {
plugins: [
require('tailwindcss/nesting'),
require('tailwindcss')(path.resolve(__dirname, 'tailwind.config.js')),
require('autoprefixer'),
],
}

View File

@ -0,0 +1,88 @@
module.exports = {
darkMode: ['class'],
content: ['src/**/*.{js,jsx,ts,tsx}'],
theme: {
container: {
center: 'true',
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
},
borderRadius: {
lg: '`var(--radius)`',
md: '`calc(var(--radius) - 2px)`',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [require('tailwindcss-animate')],
}

View File

@ -11,17 +11,37 @@
]
},
"dependencies": {
"@heroicons/react": "^2.1.5",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.3",
"@redwoodjs/auth-dbauth-web": "8.3.0",
"@redwoodjs/forms": "8.3.0",
"@redwoodjs/router": "8.3.0",
"@redwoodjs/web": "8.3.0",
"@redwoodjs/web-server": "8.3.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"humanize-string": "2.1.0",
"lucide-react": "^0.453.0",
"react": "18.3.1",
"react-dom": "18.3.1"
"react-day-picker": "8.10.1",
"react-dom": "18.3.1",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"vite-tsconfig-paths": "^5.0.1"
},
"devDependencies": {
"@redwoodjs/vite": "8.3.0",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19"
"@types/react-dom": "^18.2.19",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"postcss-loader": "^8.1.1",
"tailwindcss": "^3.4.14"
}
}

View File

@ -10,7 +10,6 @@ import { AuthProvider, useAuth } from './auth'
import './index.css'
import './scaffold.css'
interface AppProps {
children?: ReactNode
}
@ -19,7 +18,9 @@ const App = ({ children }: AppProps) => (
<FatalErrorBoundary page={FatalErrorPage}>
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
<AuthProvider>
<RedwoodApolloProvider useAuth={useAuth}>{children}</RedwoodApolloProvider>
<RedwoodApolloProvider useAuth={useAuth}>
{children}
</RedwoodApolloProvider>
</AuthProvider>
</RedwoodProvider>
</FatalErrorBoundary>

View File

@ -7,17 +7,38 @@
// 'src/pages/HomePage/HomePage.js' -> HomePage
// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage
import { Router, Route } from '@redwoodjs/router'
import { Set, Router, Route, PrivateSet } from '@redwoodjs/router'
import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
import SidebarLayout from 'src/layouts/SidebarLayout'
import { useAuth } from './auth'
const Routes = () => {
return (
<Router useAuth={useAuth}>
<Route path="/login" page={LoginPage} name="login" />
<Route path="/signup" page={SignupPage} name="signup" />
<Route path="/forgot-password" page={ForgotPasswordPage} name="forgotPassword" />
<Route path="/reset-password" page={ResetPasswordPage} name="resetPassword" />
<PrivateSet wrap={SidebarLayout} unauthenticated="home">
<Route path="/dashboard" page={DashboardPage} name="dashboard" />
<Route path="/search" page={SearchPage} name="search" />
<Route path="/calendar" page={CalendarPage} name="calendar" />
<Route path="/offer" page={OfferPage} name="offer" />
<PrivateSet unauthenticated="home" roles="admin">
<Set wrap={ScaffoldLayout} title="Posts" titleTo="posts" buttonLabel="New Post" buttonTo="newPost">
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
<Set wrap={ScaffoldLayout} title="Nachhilfeangebots" titleTo="nachhilfeangebots" buttonLabel="New Nachhilfeangebot" buttonTo="newNachhilfeangebot">
<Route path="/nachhilfeangebots/new" page={NachhilfeangebotNewNachhilfeangebotPage} name="newNachhilfeangebot" />
<Route path="/nachhilfeangebots/{id:Int}/edit" page={NachhilfeangebotEditNachhilfeangebotPage} name="editNachhilfeangebot" />
<Route path="/nachhilfeangebots/{id:Int}" page={NachhilfeangebotNachhilfeangebotPage} name="nachhilfeangebot" />
<Route path="/nachhilfeangebots" page={NachhilfeangebotNachhilfeangebotsPage} name="nachhilfeangebots" />
</Set>
</PrivateSet>
</PrivateSet>
<Route path="/" page={HomePage} name="home" />
<Route notfound page={NotFoundPage} />
</Router>

View File

@ -0,0 +1,26 @@
// Pass props to your component by passing an `args` object to your story
//
// ```tsx
// export const Primary: Story = {
// args: {
// propName: propValue
// }
// }
// ```
//
// See https://storybook.js.org/docs/react/writing-stories/args.
import type { Meta, StoryObj } from '@storybook/react'
import Hero from './Hero'
const meta: Meta<typeof Hero> = {
component: Hero,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof Hero>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import Hero from './Hero'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-components
describe('Hero', () => {
it('renders successfully', () => {
expect(() => {
render(<Hero />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,113 @@
import React, { useEffect } from 'react'
import { ChevronRightIcon } from '@heroicons/react/20/solid'
import { navigate, routes } from '@redwoodjs/router'
import { useAuth } from 'src/auth'
export default function Hero() {
const { isAuthenticated } = useAuth()
useEffect(() => {
if (isAuthenticated) {
navigate(routes.dashboard())
}
}, [isAuthenticated])
return (
<div className="bg-white">
<div className="relative isolate overflow-hidden bg-gradient-to-b from-indigo-100/20">
<div className="mx-auto max-w-7xl pb-24 pt-10 sm:pb-32 lg:grid lg:grid-cols-2 lg:gap-x-8 lg:px-8 lg:py-40">
<div className="px-6 lg:px-0 lg:pt-4">
<div className="mx-auto max-w-2xl">
<div className="max-w-lg">
<img
className="h-11"
src="https://tailwindui.com/plus/img/logos/mark.svg?color=indigo&shade=600"
alt="Your Company"
/>
<div className="mt-24 sm:mt-32 lg:mt-16">
<a
href="https://git.kocoder.xyz/kocoded/Nachhilfesystem24/releases"
className="inline-flex space-x-6"
>
<span className="rounded-full bg-indigo-600/10 px-3 py-1 text-sm font-semibold leading-6 text-indigo-600 ring-1 ring-inset ring-indigo-600/10">
What&quot;s new
</span>
<span className="inline-flex items-center space-x-2 text-sm font-medium leading-6 text-gray-600">
<span>Just shipped v0.1.0</span>
<ChevronRightIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</a>
</div>
<h1 className="mt-10 text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">
Nachhilfesystem SZU
</h1>
<p className="mt-6 text-lg leading-8 text-gray-600">
Hier ist das neue Nachhilfesystem für das SZU! Klicke unten
auf den Link, um dich mit deinem Microsoft Konto anzumelden.
</p>
<div className="mt-10 flex items-center gap-x-6">
<a
href={`${window.RWJS_API_URL}/oauthStart/microsoft`}
className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
Anmelden
</a>
<a
href="https://git.kocoder.xyz/kocoded/Nachhilfesystem24"
className="text-sm font-semibold leading-6 text-gray-900"
>
View on Gitea <span aria-hidden="true"></span>
</a>
</div>
</div>
</div>
</div>
<div className="mt-20 sm:mt-24 md:mx-auto md:max-w-2xl lg:mx-0 lg:mt-0 lg:w-screen">
<div
className="absolute inset-y-0 right-1/2 -z-10 -mr-10 w-[200%] skew-x-[-30deg] bg-white shadow-xl shadow-indigo-600/10 ring-1 ring-indigo-50 md:-mr-20 lg:-mr-36"
aria-hidden="true"
/>
<div className="shadow-lg md:rounded-3xl">
<div className="bg-indigo-500 [clip-path:inset(0)] md:[clip-path:inset(0_round_theme(borderRadius.3xl))]">
<div
className="absolute -inset-y-px left-1/2 -z-10 ml-10 w-[200%] skew-x-[-30deg] bg-indigo-100 opacity-20 ring-1 ring-inset ring-white md:ml-20 lg:ml-36"
aria-hidden="true"
/>
<div className="relative px-6 pt-8 sm:pt-16 md:pl-16 md:pr-0">
<div className="mx-auto max-w-2xl md:mx-0 md:max-w-none">
<div className="w-screen overflow-hidden rounded-tl-xl bg-gray-900">
<div className="flex bg-gray-800/40 ring-1 ring-white/5">
<div className="-mb-px flex text-sm font-medium leading-6 text-gray-400">
<div className="border-b border-r border-b-white/20 border-r-white/10 bg-white/5 px-4 py-2 text-white">
NotificationSetting.jsx
</div>
<div className="border-r border-gray-600/10 px-4 py-2">
App.jsx
</div>
</div>
</div>
<div className="px-6 pb-14 pt-6">
{/* Your code example */}
</div>
</div>
</div>
<div
className="pointer-events-none absolute inset-0 ring-1 ring-inset ring-black/10 md:rounded-3xl"
aria-hidden="true"
/>
</div>
</div>
</div>
</div>
</div>
<div className="absolute inset-x-0 bottom-0 -z-10 h-24 bg-gradient-to-t from-white sm:h-32" />
</div>
</div>
)
}

View File

@ -0,0 +1,94 @@
import type {
EditNachhilfeangebotById,
UpdateNachhilfeangebotInput,
UpdateNachhilfeangebotMutationVariables,
} from 'types/graphql'
import { navigate, routes } from '@redwoodjs/router'
import type {
CellSuccessProps,
CellFailureProps,
TypedDocumentNode,
} from '@redwoodjs/web'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import NachhilfeangebotForm from 'src/components/Nachhilfeangebot/NachhilfeangebotForm'
export const QUERY: TypedDocumentNode<EditNachhilfeangebotById> = gql`
query EditNachhilfeangebotById($id: Int!) {
nachhilfeangebot: nachhilfeangebot(id: $id) {
id
subject
currentClass
cost
userId
}
}
`
const UPDATE_NACHHILFEANGEBOT_MUTATION: TypedDocumentNode<
EditNachhilfeangebotById,
UpdateNachhilfeangebotMutationVariables
> = gql`
mutation UpdateNachhilfeangebotMutation(
$id: Int!
$input: UpdateNachhilfeangebotInput!
) {
updateNachhilfeangebot(id: $id, input: $input) {
id
subject
currentClass
cost
userId
}
}
`
export const Loading = () => <div>Loading...</div>
export const Failure = ({ error }: CellFailureProps) => (
<div className="rw-cell-error">{error?.message}</div>
)
export const Success = ({
nachhilfeangebot,
}: CellSuccessProps<EditNachhilfeangebotById>) => {
const [updateNachhilfeangebot, { loading, error }] = useMutation(
UPDATE_NACHHILFEANGEBOT_MUTATION,
{
onCompleted: () => {
toast.success('Nachhilfeangebot updated')
navigate(routes.nachhilfeangebots())
},
onError: (error) => {
toast.error(error.message)
},
}
)
const onSave = (
input: UpdateNachhilfeangebotInput,
id: EditNachhilfeangebotById['nachhilfeangebot']['id']
) => {
updateNachhilfeangebot({ variables: { id, input } })
}
return (
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Edit Nachhilfeangebot {nachhilfeangebot?.id}
</h2>
</header>
<div className="rw-segment-main">
<NachhilfeangebotForm
nachhilfeangebot={nachhilfeangebot}
onSave={onSave}
error={error}
loading={loading}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,103 @@
import type {
DeleteNachhilfeangebotMutation,
DeleteNachhilfeangebotMutationVariables,
FindNachhilfeangebotById,
} from 'types/graphql'
import { Link, routes, navigate } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import {} from 'src/lib/formatters'
const DELETE_NACHHILFEANGEBOT_MUTATION: TypedDocumentNode<
DeleteNachhilfeangebotMutation,
DeleteNachhilfeangebotMutationVariables
> = gql`
mutation DeleteNachhilfeangebotMutation($id: Int!) {
deleteNachhilfeangebot(id: $id) {
id
}
}
`
interface Props {
nachhilfeangebot: NonNullable<FindNachhilfeangebotById['nachhilfeangebot']>
}
const Nachhilfeangebot = ({ nachhilfeangebot }: Props) => {
const [deleteNachhilfeangebot] = useMutation(
DELETE_NACHHILFEANGEBOT_MUTATION,
{
onCompleted: () => {
toast.success('Nachhilfeangebot deleted')
navigate(routes.nachhilfeangebots())
},
onError: (error) => {
toast.error(error.message)
},
}
)
const onDeleteClick = (id: DeleteNachhilfeangebotMutationVariables['id']) => {
if (
confirm('Are you sure you want to delete nachhilfeangebot ' + id + '?')
) {
deleteNachhilfeangebot({ variables: { id } })
}
}
return (
<>
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Nachhilfeangebot {nachhilfeangebot.id} Detail
</h2>
</header>
<table className="rw-table">
<tbody>
<tr>
<th>Id</th>
<td>{nachhilfeangebot.id}</td>
</tr>
<tr>
<th>Subject</th>
<td>{nachhilfeangebot.subject}</td>
</tr>
<tr>
<th>Current class</th>
<td>{nachhilfeangebot.currentClass}</td>
</tr>
<tr>
<th>Cost</th>
<td>{nachhilfeangebot.cost}</td>
</tr>
<tr>
<th>User id</th>
<td>{nachhilfeangebot.userId}</td>
</tr>
</tbody>
</table>
</div>
<nav className="rw-button-group">
<Link
to={routes.editNachhilfeangebot({ id: nachhilfeangebot.id })}
className="rw-button rw-button-blue"
>
Edit
</Link>
<button
type="button"
className="rw-button rw-button-red"
onClick={() => onDeleteClick(nachhilfeangebot.id)}
>
Delete
</button>
</nav>
</>
)
}
export default Nachhilfeangebot

View File

@ -0,0 +1,46 @@
import type {
FindNachhilfeangebotById,
FindNachhilfeangebotByIdVariables,
} from 'types/graphql'
import type {
CellSuccessProps,
CellFailureProps,
TypedDocumentNode,
} from '@redwoodjs/web'
import Nachhilfeangebot from 'src/components/Nachhilfeangebot/Nachhilfeangebot'
export const QUERY: TypedDocumentNode<
FindNachhilfeangebotById,
FindNachhilfeangebotByIdVariables
> = gql`
query FindNachhilfeangebotById($id: Int!) {
nachhilfeangebot: nachhilfeangebot(id: $id) {
id
subject
currentClass
cost
userId
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Nachhilfeangebot not found</div>
export const Failure = ({
error,
}: CellFailureProps<FindNachhilfeangebotByIdVariables>) => (
<div className="rw-cell-error">{error?.message}</div>
)
export const Success = ({
nachhilfeangebot,
}: CellSuccessProps<
FindNachhilfeangebotById,
FindNachhilfeangebotByIdVariables
>) => {
return <Nachhilfeangebot nachhilfeangebot={nachhilfeangebot} />
}

View File

@ -0,0 +1,128 @@
import type {
EditNachhilfeangebotById,
UpdateNachhilfeangebotInput,
} from 'types/graphql'
import type { RWGqlError } from '@redwoodjs/forms'
import {
Form,
FormError,
FieldError,
Label,
TextField,
NumberField,
Submit,
} from '@redwoodjs/forms'
type FormNachhilfeangebot = NonNullable<
EditNachhilfeangebotById['nachhilfeangebot']
>
interface NachhilfeangebotFormProps {
nachhilfeangebot?: EditNachhilfeangebotById['nachhilfeangebot']
onSave: (
data: UpdateNachhilfeangebotInput,
id?: FormNachhilfeangebot['id']
) => void
error: RWGqlError
loading: boolean
}
const NachhilfeangebotForm = (props: NachhilfeangebotFormProps) => {
const onSubmit = (data: FormNachhilfeangebot) => {
props.onSave(data, props?.nachhilfeangebot?.id)
}
return (
<div className="rw-form-wrapper">
<Form<FormNachhilfeangebot> onSubmit={onSubmit} error={props.error}>
<FormError
error={props.error}
wrapperClassName="rw-form-error-wrapper"
titleClassName="rw-form-error-title"
listClassName="rw-form-error-list"
/>
<Label
name="subject"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Subject
</Label>
<TextField
name="subject"
defaultValue={props.nachhilfeangebot?.subject}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
/>
<FieldError name="subject" className="rw-field-error" />
<Label
name="currentClass"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Current class
</Label>
<TextField
name="currentClass"
defaultValue={props.nachhilfeangebot?.currentClass}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
/>
<FieldError name="currentClass" className="rw-field-error" />
<Label
name="cost"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Cost
</Label>
<TextField
name="cost"
defaultValue={props.nachhilfeangebot?.cost}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ valueAsNumber: true, required: true }}
/>
<FieldError name="cost" className="rw-field-error" />
<Label
name="userId"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
User id
</Label>
<NumberField
name="userId"
defaultValue={props.nachhilfeangebot?.userId}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
/>
<FieldError name="userId" className="rw-field-error" />
<div className="rw-button-group">
<Submit disabled={props.loading} className="rw-button rw-button-blue">
Save
</Submit>
</div>
</Form>
</div>
)
}
export default NachhilfeangebotForm

View File

@ -0,0 +1,113 @@
import type {
DeleteNachhilfeangebotMutation,
DeleteNachhilfeangebotMutationVariables,
FindNachhilfeangebots,
} from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { QUERY } from 'src/components/Nachhilfeangebot/NachhilfeangebotsCell'
import { truncate } from 'src/lib/formatters'
const DELETE_NACHHILFEANGEBOT_MUTATION: TypedDocumentNode<
DeleteNachhilfeangebotMutation,
DeleteNachhilfeangebotMutationVariables
> = gql`
mutation DeleteNachhilfeangebotMutation($id: Int!) {
deleteNachhilfeangebot(id: $id) {
id
}
}
`
const NachhilfeangebotsList = ({
nachhilfeangebots,
}: FindNachhilfeangebots) => {
const [deleteNachhilfeangebot] = useMutation(
DELETE_NACHHILFEANGEBOT_MUTATION,
{
onCompleted: () => {
toast.success('Nachhilfeangebot deleted')
},
onError: (error) => {
toast.error(error.message)
},
// This refetches the query on the list page. Read more about other ways to
// update the cache over here:
// https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true,
}
)
const onDeleteClick = (id: DeleteNachhilfeangebotMutationVariables['id']) => {
if (
confirm('Are you sure you want to delete nachhilfeangebot ' + id + '?')
) {
deleteNachhilfeangebot({ variables: { id } })
}
}
return (
<div className="rw-segment rw-table-wrapper-responsive">
<table className="rw-table">
<thead>
<tr>
<th>Id</th>
<th>Subject</th>
<th>Current class</th>
<th>Cost</th>
<th>User id</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{nachhilfeangebots.map((nachhilfeangebot) => (
<tr key={nachhilfeangebot.id}>
<td>{truncate(nachhilfeangebot.id)}</td>
<td>{truncate(nachhilfeangebot.subject)}</td>
<td>{truncate(nachhilfeangebot.currentClass)}</td>
<td>{truncate(nachhilfeangebot.cost)}</td>
<td>{truncate(nachhilfeangebot.userId)}</td>
<td>
<nav className="rw-table-actions">
<Link
to={routes.nachhilfeangebot({ id: nachhilfeangebot.id })}
title={
'Show nachhilfeangebot ' + nachhilfeangebot.id + ' detail'
}
className="rw-button rw-button-small"
>
Show
</Link>
<Link
to={routes.editNachhilfeangebot({
id: nachhilfeangebot.id,
})}
title={'Edit nachhilfeangebot ' + nachhilfeangebot.id}
className="rw-button rw-button-small rw-button-blue"
>
Edit
</Link>
<button
type="button"
title={'Delete nachhilfeangebot ' + nachhilfeangebot.id}
className="rw-button rw-button-small rw-button-red"
onClick={() => onDeleteClick(nachhilfeangebot.id)}
>
Delete
</button>
</nav>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default NachhilfeangebotsList

View File

@ -0,0 +1,51 @@
import type {
FindNachhilfeangebots,
FindNachhilfeangebotsVariables,
} from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import type {
CellSuccessProps,
CellFailureProps,
TypedDocumentNode,
} from '@redwoodjs/web'
import Nachhilfeangebots from 'src/components/Nachhilfeangebot/Nachhilfeangebots'
export const QUERY: TypedDocumentNode<
FindNachhilfeangebots,
FindNachhilfeangebotsVariables
> = gql`
query FindNachhilfeangebots {
nachhilfeangebots {
id
subject
currentClass
cost
userId
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => {
return (
<div className="rw-text-center">
No nachhilfeangebots yet.{' '}
<Link to={routes.newNachhilfeangebot()} className="rw-link">
Create one?
</Link>
</div>
)
}
export const Failure = ({ error }: CellFailureProps<FindNachhilfeangebots>) => (
<div className="rw-cell-error">{error?.message}</div>
)
export const Success = ({
nachhilfeangebots,
}: CellSuccessProps<FindNachhilfeangebots, FindNachhilfeangebotsVariables>) => {
return <Nachhilfeangebots nachhilfeangebots={nachhilfeangebots} />
}

View File

@ -0,0 +1,59 @@
import type {
CreateNachhilfeangebotMutation,
CreateNachhilfeangebotInput,
CreateNachhilfeangebotMutationVariables,
} from 'types/graphql'
import { navigate, routes } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import NachhilfeangebotForm from 'src/components/Nachhilfeangebot/NachhilfeangebotForm'
const CREATE_NACHHILFEANGEBOT_MUTATION: TypedDocumentNode<
CreateNachhilfeangebotMutation,
CreateNachhilfeangebotMutationVariables
> = gql`
mutation CreateNachhilfeangebotMutation(
$input: CreateNachhilfeangebotInput!
) {
createNachhilfeangebot(input: $input) {
id
}
}
`
const NewNachhilfeangebot = () => {
const [createNachhilfeangebot, { loading, error }] = useMutation(
CREATE_NACHHILFEANGEBOT_MUTATION,
{
onCompleted: () => {
toast.success('Nachhilfeangebot created')
navigate(routes.nachhilfeangebots())
},
onError: (error) => {
toast.error(error.message)
},
}
)
const onSave = (input: CreateNachhilfeangebotInput) => {
createNachhilfeangebot({ variables: { input } })
}
return (
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
New Nachhilfeangebot
</h2>
</header>
<div className="rw-segment-main">
<NachhilfeangebotForm onSave={onSave} loading={loading} error={error} />
</div>
</div>
)
}
export default NewNachhilfeangebot

View File

@ -0,0 +1,78 @@
import type {
EditPostById,
UpdatePostInput,
UpdatePostMutationVariables,
} from 'types/graphql'
import { navigate, routes } from '@redwoodjs/router'
import type {
CellSuccessProps,
CellFailureProps,
TypedDocumentNode,
} from '@redwoodjs/web'
import { useMutation } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import PostForm from 'src/components/Post/PostForm'
export const QUERY: TypedDocumentNode<EditPostById> = gql`
query EditPostById($id: Int!) {
post: post(id: $id) {
id
title
body
createdAt
updatedAt
}
}
`
const UPDATE_POST_MUTATION: TypedDocumentNode<
EditPostById,
UpdatePostMutationVariables
> = gql`
mutation UpdatePostMutation($id: Int!, $input: UpdatePostInput!) {
updatePost(id: $id, input: $input) {
id
title
body
createdAt
updatedAt
}
}
`
export const Loading = () => <div>Loading...</div>
export const Failure = ({ error }: CellFailureProps) => (
<div className="rw-cell-error">{error?.message}</div>
)
export const Success = ({ post }: CellSuccessProps<EditPostById>) => {
const [updatePost, { loading, error }] = useMutation(UPDATE_POST_MUTATION, {
onCompleted: () => {
toast.success('Post updated')
navigate(routes.posts())
},
onError: (error) => {
toast.error(error.message)
},
})
const onSave = (input: UpdatePostInput, id: EditPostById['post']['id']) => {
updatePost({ variables: { id, input } })
}
return (
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Edit Post {post?.id}
</h2>
</header>
<div className="rw-segment-main">
<PostForm post={post} onSave={onSave} error={error} loading={loading} />
</div>
</div>
)
}

View File

@ -0,0 +1,52 @@
import type {
CreatePostMutation,
CreatePostInput,
CreatePostMutationVariables,
} from 'types/graphql'
import { navigate, routes } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import PostForm from 'src/components/Post/PostForm'
const CREATE_POST_MUTATION: TypedDocumentNode<
CreatePostMutation,
CreatePostMutationVariables
> = gql`
mutation CreatePostMutation($input: CreatePostInput!) {
createPost(input: $input) {
id
}
}
`
const NewPost = () => {
const [createPost, { loading, error }] = useMutation(CREATE_POST_MUTATION, {
onCompleted: () => {
toast.success('Post created')
navigate(routes.posts())
},
onError: (error) => {
toast.error(error.message)
},
})
const onSave = (input: CreatePostInput) => {
createPost({ variables: { input } })
}
return (
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">New Post</h2>
</header>
<div className="rw-segment-main">
<PostForm onSave={onSave} loading={loading} error={error} />
</div>
</div>
)
}
export default NewPost

View File

@ -0,0 +1,98 @@
import type {
DeletePostMutation,
DeletePostMutationVariables,
FindPostById,
} from 'types/graphql'
import { Link, routes, navigate } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { timeTag } from 'src/lib/formatters'
const DELETE_POST_MUTATION: TypedDocumentNode<
DeletePostMutation,
DeletePostMutationVariables
> = gql`
mutation DeletePostMutation($id: Int!) {
deletePost(id: $id) {
id
}
}
`
interface Props {
post: NonNullable<FindPostById['post']>
}
const Post = ({ post }: Props) => {
const [deletePost] = useMutation(DELETE_POST_MUTATION, {
onCompleted: () => {
toast.success('Post deleted')
navigate(routes.posts())
},
onError: (error) => {
toast.error(error.message)
},
})
const onDeleteClick = (id: DeletePostMutationVariables['id']) => {
if (confirm('Are you sure you want to delete post ' + id + '?')) {
deletePost({ variables: { id } })
}
}
return (
<>
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Post {post.id} Detail
</h2>
</header>
<table className="rw-table">
<tbody>
<tr>
<th>Id</th>
<td>{post.id}</td>
</tr>
<tr>
<th>Title</th>
<td>{post.title}</td>
</tr>
<tr>
<th>Body</th>
<td>{post.body}</td>
</tr>
<tr>
<th>Created at</th>
<td>{timeTag(post.createdAt)}</td>
</tr>
<tr>
<th>Updated at</th>
<td>{timeTag(post.updatedAt)}</td>
</tr>
</tbody>
</table>
</div>
<nav className="rw-button-group">
<Link
to={routes.editPost({ id: post.id })}
className="rw-button rw-button-blue"
>
Edit
</Link>
<button
type="button"
className="rw-button rw-button-red"
onClick={() => onDeleteClick(post.id)}
>
Delete
</button>
</nav>
</>
)
}
export default Post

View File

@ -0,0 +1,36 @@
import type { FindPostById, FindPostByIdVariables } from 'types/graphql'
import type {
CellSuccessProps,
CellFailureProps,
TypedDocumentNode,
} from '@redwoodjs/web'
import Post from 'src/components/Post/Post'
export const QUERY: TypedDocumentNode<FindPostById, FindPostByIdVariables> =
gql`
query FindPostById($id: Int!) {
post: post(id: $id) {
id
title
body
createdAt
updatedAt
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => <div>Post not found</div>
export const Failure = ({ error }: CellFailureProps<FindPostByIdVariables>) => (
<div className="rw-cell-error">{error?.message}</div>
)
export const Success = ({
post,
}: CellSuccessProps<FindPostById, FindPostByIdVariables>) => {
return <Post post={post} />
}

View File

@ -0,0 +1,83 @@
import type { EditPostById, UpdatePostInput } from 'types/graphql'
import type { RWGqlError } from '@redwoodjs/forms'
import {
Form,
FormError,
FieldError,
Label,
TextField,
Submit,
} from '@redwoodjs/forms'
type FormPost = NonNullable<EditPostById['post']>
interface PostFormProps {
post?: EditPostById['post']
onSave: (data: UpdatePostInput, id?: FormPost['id']) => void
error: RWGqlError
loading: boolean
}
const PostForm = (props: PostFormProps) => {
const onSubmit = (data: FormPost) => {
props.onSave(data, props?.post?.id)
}
return (
<div className="rw-form-wrapper">
<Form<FormPost> onSubmit={onSubmit} error={props.error}>
<FormError
error={props.error}
wrapperClassName="rw-form-error-wrapper"
titleClassName="rw-form-error-title"
listClassName="rw-form-error-list"
/>
<Label
name="title"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Title
</Label>
<TextField
name="title"
defaultValue={props.post?.title}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
/>
<FieldError name="title" className="rw-field-error" />
<Label
name="body"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Body
</Label>
<TextField
name="body"
defaultValue={props.post?.body}
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{ required: true }}
/>
<FieldError name="body" className="rw-field-error" />
<div className="rw-button-group">
<Submit disabled={props.loading} className="rw-button rw-button-blue">
Save
</Submit>
</div>
</Form>
</div>
)
}
export default PostForm

View File

@ -0,0 +1,102 @@
import type {
DeletePostMutation,
DeletePostMutationVariables,
FindPosts,
} from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'
import type { TypedDocumentNode } from '@redwoodjs/web'
import { toast } from '@redwoodjs/web/toast'
import { QUERY } from 'src/components/Post/PostsCell'
import { timeTag, truncate } from 'src/lib/formatters'
const DELETE_POST_MUTATION: TypedDocumentNode<
DeletePostMutation,
DeletePostMutationVariables
> = gql`
mutation DeletePostMutation($id: Int!) {
deletePost(id: $id) {
id
}
}
`
const PostsList = ({ posts }: FindPosts) => {
const [deletePost] = useMutation(DELETE_POST_MUTATION, {
onCompleted: () => {
toast.success('Post deleted')
},
onError: (error) => {
toast.error(error.message)
},
// This refetches the query on the list page. Read more about other ways to
// update the cache over here:
// https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
refetchQueries: [{ query: QUERY }],
awaitRefetchQueries: true,
})
const onDeleteClick = (id: DeletePostMutationVariables['id']) => {
if (confirm('Are you sure you want to delete post ' + id + '?')) {
deletePost({ variables: { id } })
}
}
return (
<div className="rw-segment rw-table-wrapper-responsive">
<table className="rw-table">
<thead>
<tr>
<th>Idsss</th>
<th>Title</th>
<th>Body</th>
<th>Created at</th>
<th>Updated at</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id}>
<td>{truncate(post.id)}</td>
<td>{truncate(post.title)}</td>
<td>{truncate(post.body)}</td>
<td>{timeTag(post.createdAt)}</td>
<td>{timeTag(post.updatedAt)}</td>
<td>
<nav className="rw-table-actions">
<Link
to={routes.post({ id: post.id })}
title={'Show post ' + post.id + ' detail'}
className="rw-button rw-button-small"
>
Show
</Link>
<Link
to={routes.editPost({ id: post.id })}
title={'Edit post ' + post.id}
className="rw-button rw-button-small rw-button-blue"
>
Edit
</Link>
<button
type="button"
title={'Delete post ' + post.id}
className="rw-button rw-button-small rw-button-red"
onClick={() => onDeleteClick(post.id)}
>
Delete
</button>
</nav>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default PostsList

View File

@ -0,0 +1,45 @@
import type { FindPosts, FindPostsVariables } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
import type {
CellSuccessProps,
CellFailureProps,
TypedDocumentNode,
} from '@redwoodjs/web'
import Posts from 'src/components/Post/Posts'
export const QUERY: TypedDocumentNode<FindPosts, FindPostsVariables> = gql`
query FindPosts {
posts {
id
title
body
createdAt
updatedAt
}
}
`
export const Loading = () => <div>Loading...</div>
export const Empty = () => {
return (
<div className="rw-text-center">
No posts yet.{' '}
<Link to={routes.newPost()} className="rw-link">
Create one?
</Link>
</div>
)
}
export const Failure = ({ error }: CellFailureProps<FindPosts>) => (
<div className="rw-cell-error">{error?.message}</div>
)
export const Success = ({
posts,
}: CellSuccessProps<FindPosts, FindPostsVariables>) => {
return <Posts posts={posts} />
}

View File

@ -0,0 +1,19 @@
import React from 'react'
import { Calendar } from '@/components/ui/calendar'
import { Sidebar, SidebarHeader } from '@/components/ui/sidebar'
export function CalendarSidebar() {
return (
<Sidebar side="right">
<SidebarHeader>
<Calendar
mode="single"
// selected={date}
// onSelect={setDate}
className="w-full rounded-md border"
/>
</SidebarHeader>
</Sidebar>
)
}

View File

@ -0,0 +1,112 @@
import { Calendar, ChevronUp, Home, Inbox, Search, User2 } from 'lucide-react'
import { Link, routes } from '@redwoodjs/router'
import { useAuth } from 'src/auth'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Sidebar,
SidebarHeader,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarFooter,
} from '@/components/ui/sidebar'
// Menu items.
const items = [
{
title: 'Dashboard',
// url: routes.dashboard(),
url: '/dashboard',
icon: Home,
},
{
title: 'Nachhilfe suchen',
// url: routes.posts(),
url: '/admin/posts',
icon: Search,
},
{
title: 'Nachhilfetermine planen',
url: '#',
icon: Calendar,
},
{
title: 'Nachhilfe anbieten',
url: '#',
icon: Inbox,
},
]
export function AppSidebar() {
const { logOut, currentUser: user } = useAuth()
return (
<Sidebar>
<SidebarHeader>
<Link to={routes.dashboard()}>
<span className="m-2 inline-block aspect-square rounded bg-blue-500 p-2">
NH
</span>
Nachhilfesystem 2024
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton>
<User2 /> {user.firstName + ' ' + user.lastName}
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
className="w-[--radix-popper-anchor-width]"
>
<DropdownMenuItem>Account</DropdownMenuItem>
<DropdownMenuItem>
<Link to={''}>Settings</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button onClick={logOut}>Sign out</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
)
}

View File

@ -0,0 +1,57 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@ -0,0 +1,66 @@
import * as React from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { DayPicker } from 'react-day-picker'
import { buttonVariants } from 'src/components/ui/button'
import { cn } from '@/lib/utils'
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = 'Calendar'
export { Calendar }

View File

@ -0,0 +1,199 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,25 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'border-input bg-background ring-offset-background file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@ -0,0 +1,30 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,141 @@
'use client'
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
)
SheetHeader.displayName = 'SheetHeader'
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
SheetFooter.displayName = 'SheetFooter'
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-foreground text-lg font-semibold', className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,764 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { VariantProps, cva } from 'class-variance-authority'
import { PanelLeft } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from 'src/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
const SIDEBAR_COOKIE_NAME = 'sidebar:state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
type SidebarContext = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
if (setOpenProp) {
return setOpenProp?.(
typeof value === 'function' ? value(open) : value
)
}
_setOpen(value)
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-[[data-variant=inset]]:bg-sidebar flex min-h-svh w-full',
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = 'SidebarProvider'
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}
>(
(
{
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === 'none') {
return (
<div
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-[--sidebar-width] flex-col',
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-[--sidebar-width] p-0 [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="text-sidebar-foreground group peer hidden md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
'relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]'
)}
/>
<div
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = 'Sidebar'
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn('h-7 w-7', className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = 'SidebarTrigger'
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'>
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:hover:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...props}
/>
)
})
SidebarRail.displayName = 'SidebarRail'
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'main'>
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
'bg-background relative flex min-h-svh flex-1 flex-col',
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
className
)}
{...props}
/>
)
})
SidebarInset.displayName = 'SidebarInset'
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
'bg-background focus-visible:ring-sidebar-ring h-8 w-full shadow-none focus-visible:ring-2',
className
)}
{...props}
/>
)
})
SidebarInput.displayName = 'SidebarInput'
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
})
SidebarHeader.displayName = 'SidebarHeader'
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
})
SidebarFooter.displayName = 'SidebarFooter'
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
)
})
SidebarSeparator.displayName = 'SidebarSeparator'
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
{...props}
/>
)
})
SidebarContent.displayName = 'SidebarContent'
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
)
})
SidebarGroup.displayName = 'SidebarGroup'
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'div'
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-none transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = 'SidebarGroupLabel'
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 after:md:hidden',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = 'SidebarGroupAction'
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
))
SidebarGroupContent.displayName = 'SidebarGroupContent'
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
))
SidebarMenu.displayName = 'SidebarMenu'
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
))
SidebarMenuItem.displayName = 'SidebarMenuItem'
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : 'button'
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = 'SidebarMenuButton'
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<'button'> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 after:md:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = 'SidebarMenuAction'
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = 'SidebarMenuBadge'
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton'
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = 'SidebarMenuSub'
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = 'SidebarMenuSubItem'
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<'a'> & {
asChild?: boolean
size?: 'sm' | 'md'
isActive?: boolean
}
>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a'
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = 'SidebarMenuSubButton'
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,15 @@
import { cn } from '@/lib/utils'
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('bg-muted animate-pulse rounded-md', className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,29 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@ -0,0 +1,147 @@
/**
* START --- SETUP TAILWINDCSS EDIT
*
* `yarn rw setup ui tailwindcss` placed these directives here
* to inject Tailwind's styles into your CSS.
* For more information, see: https://tailwindcss.com/docs/installation
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
/**
* END --- SETUP TAILWINDCSS EDIT
*/
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
--radius: 0.5rem;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* @layer base {
:root {
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
} */

View File

@ -7,7 +7,7 @@
<link rel="icon" type="image/png" href="/favicon.png" />
</head>
<body>
<body class="dark">
<!-- Please keep this div empty -->
<div id="redwood-app"></div>
</body>

View File

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react'
import NavigationLayout from './NavigationLayout'
const meta: Meta<typeof NavigationLayout> = {
component: NavigationLayout,
}
export default meta
type Story = StoryObj<typeof NavigationLayout>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import NavigationLayout from './NavigationLayout'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('NavigationLayout', () => {
it('renders successfully', () => {
expect(() => {
render(<NavigationLayout />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,266 @@
type NavigationLayoutProps = {
children?: React.ReactNode
}
import { useState } from 'react'
import {
Dialog,
DialogPanel,
Disclosure,
DisclosureButton,
DisclosurePanel,
Popover,
PopoverButton,
PopoverGroup,
PopoverPanel,
} from '@headlessui/react'
import {
ChevronDownIcon,
PhoneIcon,
PlayCircleIcon,
} from '@heroicons/react/20/solid'
import {
Bars3Icon,
ChartPieIcon,
CursorArrowRaysIcon,
FingerPrintIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'
import { Link, routes } from '@redwoodjs/router'
import { useAuth } from 'src/auth'
const adminsites = [
{
name: 'Posts',
description: 'Get a better understanding of your traffic',
href: '/admin/posts',
icon: ChartPieIcon,
},
{
name: 'Users',
description: 'Speak directly to your customers',
href: '/admin/posts',
icon: CursorArrowRaysIcon,
},
{
name: 'Nachhilfeangebote',
description: 'Your customers&quot; data will be safe and secure',
href: '/admin/nachhilfeangebote',
icon: FingerPrintIcon,
},
]
const callsToAction = [
{ name: 'Watch demo', href: '#', icon: PlayCircleIcon },
{ name: 'Contact sales', href: '#', icon: PhoneIcon },
]
export default function NavigationLayout({ children }: NavigationLayoutProps) {
const { logOut, hasRole } = useAuth()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
return (
<>
<header className="bg-white">
<nav
aria-label="Global"
className="mx-auto flex max-w-7xl items-center justify-between p-6 lg:px-8"
>
<div className="flex lg:flex-1">
<Link to={routes.dashboard()} className="-m-1.5 p-1.5">
<span className="sr-only">Your Company</span>
<img
alt=""
src={`https://tailwindui.com/plus/img/logos/mark.svg?color=${hasRole('admin') ? 'red' : 'indigo'}&shade=600`}
className="h-8 w-auto"
/>
</Link>
</div>
<div className="flex lg:hidden">
<button
type="button"
onClick={() => setMobileMenuOpen(true)}
className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700"
>
<span className="sr-only">Open main menu</span>
<Bars3Icon aria-hidden="true" className="h-6 w-6" />
</button>
</div>
{}
<PopoverGroup className="hidden lg:flex lg:gap-x-12">
{hasRole('admin') && (
<Popover className="relative">
<PopoverButton className="flex items-center gap-x-1 text-sm font-semibold leading-6 text-gray-900">
Admin
<ChevronDownIcon
aria-hidden="true"
className="h-5 w-5 flex-none text-gray-400"
/>
</PopoverButton>
<PopoverPanel
transition
className="absolute -left-8 top-full z-10 mt-3 w-screen max-w-md overflow-hidden rounded-3xl bg-white shadow-lg ring-1 ring-gray-900/5 transition data-[closed]:translate-y-1 data-[closed]:opacity-0 data-[enter]:duration-200 data-[leave]:duration-150 data-[enter]:ease-out data-[leave]:ease-in"
>
<div className="p-4">
{adminsites.map((item) => (
<div
key={item.name}
className="group relative flex items-center gap-x-6 rounded-lg p-4 text-sm leading-6 hover:bg-gray-50"
>
<div className="flex h-11 w-11 flex-none items-center justify-center rounded-lg bg-gray-50 group-hover:bg-white">
<item.icon
aria-hidden="true"
className="h-6 w-6 text-gray-600 group-hover:text-indigo-600"
/>
</div>
<div className="flex-auto">
<a
href={item.href}
className="block font-semibold text-gray-900"
>
{item.name}
<span className="absolute inset-0" />
</a>
<p className="mt-1 text-gray-600">
{item.description}
</p>
</div>
</div>
))}
</div>
<div className="grid grid-cols-2 divide-x divide-gray-900/5 bg-gray-50">
{callsToAction.map((item) => (
<a
key={item.name}
href={item.href}
className="flex items-center justify-center gap-x-2.5 p-3 text-sm font-semibold leading-6 text-gray-900 hover:bg-gray-100"
>
<item.icon
aria-hidden="true"
className="h-5 w-5 flex-none text-gray-400"
/>
{item.name}
</a>
))}
</div>
</PopoverPanel>
</Popover>
)}
<Link
to="#"
className="text-sm font-semibold leading-6 text-gray-900"
>
Features
</Link>
<Link
to="#"
className="text-sm font-semibold leading-6 text-gray-900"
>
Marketplace
</Link>
<Link
to="#"
className="text-sm font-semibold leading-6 text-gray-900"
>
Company
</Link>
</PopoverGroup>
<div className="hidden lg:flex lg:flex-1 lg:justify-end">
<button
onClick={logOut}
className="text-sm font-semibold leading-6 text-gray-900"
>
Log out
</button>
</div>
</nav>
<Dialog
open={mobileMenuOpen}
onClose={setMobileMenuOpen}
className="lg:hidden"
>
<div className="fixed inset-0 z-10" />
<DialogPanel className="fixed inset-y-0 right-0 z-10 w-full overflow-y-auto bg-white px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10">
<div className="flex items-center justify-between">
<Link to="#" className="-m-1.5 p-1.5">
<span className="sr-only">Your Company</span>
<img
alt=""
src={`https://tailwindui.com/plus/img/logos/mark.svg?color=${hasRole('admin') ? 'red' : 'indigo'}&shade=600`}
className="h-8 w-auto"
/>
</Link>
<button
type="button"
onClick={() => setMobileMenuOpen(false)}
className="-m-2.5 rounded-md p-2.5 text-gray-700"
>
<span className="sr-only">Close menu</span>
<XMarkIcon aria-hidden="true" className="h-6 w-6" />
</button>
</div>
<div className="mt-6 flow-root">
<div className="-my-6 divide-y divide-gray-500/10">
<div className="space-y-2 py-6">
{hasRole('admin') && (
<Disclosure as="div" className="-mx-3">
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 pl-3 pr-3.5 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50">
Admin
<ChevronDownIcon
aria-hidden="true"
className="h-5 w-5 flex-none group-data-[open]:rotate-180"
/>
</DisclosureButton>
<DisclosurePanel className="mt-2 space-y-2">
{[...adminsites, ...callsToAction].map((item) => (
<DisclosureButton
key={item.name}
as="a"
href={item.href}
className="block rounded-lg py-2 pl-6 pr-3 text-sm font-semibold leading-7 text-gray-900 hover:bg-gray-50"
>
{item.name}
</DisclosureButton>
))}
</DisclosurePanel>
</Disclosure>
)}
<Link
to="#"
className="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50"
>
Features
</Link>
<Link
to="#"
className="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50"
>
Marketplace
</Link>
<Link
to="#"
className="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50"
>
Company
</Link>
</div>
<div className="py-6">
<button
onClick={logOut}
className="-mx-3 block rounded-lg px-3 py-2.5 text-base font-semibold leading-7 text-gray-900 hover:bg-gray-50"
>
Log out
</button>
</div>
</div>
</div>
</DialogPanel>
</Dialog>
</header>
<>{children}</>
</>
)
}

View File

@ -0,0 +1,37 @@
import { Link, routes } from '@redwoodjs/router'
import { Toaster } from '@redwoodjs/web/toast'
type LayoutProps = {
title: string
titleTo: keyof typeof routes
buttonLabel: string
buttonTo: keyof typeof routes
children: React.ReactNode
}
const ScaffoldLayout = ({
title,
titleTo,
buttonLabel,
buttonTo,
children,
}: LayoutProps) => {
return (
<div className="rw-scaffold">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<header className="rw-header">
<h1 className="rw-heading rw-heading-primary">
<Link to={routes[titleTo]()} className="rw-link">
{title}
</Link>
</h1>
<Link to={routes[buttonTo]()} className="rw-button rw-button-green">
<div className="rw-button-icon">+</div> {buttonLabel}
</Link>
</header>
<main className="rw-main">{children}</main>
</div>
)
}
export default ScaffoldLayout

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react'
import SidebarLayout from './SidebarLayout'
const meta: Meta<typeof SidebarLayout> = {
component: SidebarLayout,
}
export default meta
type Story = StoryObj<typeof SidebarLayout>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import SidebarLayout from './SidebarLayout'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('SidebarLayout', () => {
it('renders successfully', () => {
expect(() => {
render(<SidebarLayout />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,20 @@
import { AppSidebar } from '@/components/navigation/navbar'
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
type SidebarLayoutProps = {
children?: React.ReactNode
}
const SidebarLayout = ({ children }: SidebarLayoutProps) => {
return (
<SidebarProvider>
<AppSidebar />
<main>
<SidebarTrigger />
{children}
</main>
</SidebarProvider>
)
}
export default SidebarLayout

View File

@ -0,0 +1,192 @@
import { render, waitFor, screen } from '@redwoodjs/testing/web'
import {
formatEnum,
jsonTruncate,
truncate,
timeTag,
jsonDisplay,
checkboxInputTag,
} from './formatters'
describe('formatEnum', () => {
it('handles nullish values', () => {
expect(formatEnum(null)).toEqual('')
expect(formatEnum('')).toEqual('')
expect(formatEnum(undefined)).toEqual('')
})
it('formats a list of values', () => {
expect(
formatEnum(['RED', 'ORANGE', 'YELLOW', 'GREEN', 'BLUE', 'VIOLET'])
).toEqual('Red, Orange, Yellow, Green, Blue, Violet')
})
it('formats a single value', () => {
expect(formatEnum('DARK_BLUE')).toEqual('Dark blue')
})
it('returns an empty string for values of the wrong type (for JS projects)', () => {
// @ts-expect-error - Testing JS scenario
expect(formatEnum(5)).toEqual('')
})
})
describe('truncate', () => {
it('truncates really long strings', () => {
expect(truncate('na '.repeat(1000) + 'batman').length).toBeLessThan(1000)
expect(truncate('na '.repeat(1000) + 'batman')).not.toMatch(/batman/)
})
it('does not modify short strings', () => {
expect(truncate('Short strinG')).toEqual('Short strinG')
})
it('adds ... to the end of truncated strings', () => {
expect(truncate('repeat'.repeat(1000))).toMatch(/\w\.\.\.$/)
})
it('accepts numbers', () => {
expect(truncate(123)).toEqual('123')
expect(truncate(0)).toEqual('0')
expect(truncate(0o000)).toEqual('0')
})
it('handles arguments of invalid type', () => {
// @ts-expect-error - Testing JS scenario
expect(truncate(false)).toEqual('false')
expect(truncate(undefined)).toEqual('')
expect(truncate(null)).toEqual('')
})
})
describe('jsonTruncate', () => {
it('truncates large json structures', () => {
expect(
jsonTruncate({
foo: 'foo',
bar: 'bar',
baz: 'baz',
kittens: 'kittens meow',
bazinga: 'Sheldon',
nested: {
foobar: 'I have no imagination',
two: 'Second nested item',
},
five: 5,
bool: false,
})
).toMatch(/.+\n.+\w\.\.\.$/s)
})
})
describe('timeTag', () => {
it('renders a date', async () => {
render(<div>{timeTag(new Date('1970-08-20').toUTCString())}</div>)
await waitFor(() => screen.getByText(/1970.*00:00:00/))
})
it('can take an empty input string', async () => {
expect(timeTag('')).toEqual('')
})
})
describe('jsonDisplay', () => {
it('produces the correct output', () => {
expect(
jsonDisplay({
title: 'TOML Example (but in JSON)',
database: {
data: [['delta', 'phi'], [3.14]],
enabled: true,
ports: [8000, 8001, 8002],
temp_targets: {
case: 72.0,
cpu: 79.5,
},
},
owner: {
dob: '1979-05-27T07:32:00-08:00',
name: 'Tom Preston-Werner',
},
servers: {
alpha: {
ip: '10.0.0.1',
role: 'frontend',
},
beta: {
ip: '10.0.0.2',
role: 'backend',
},
},
})
).toMatchInlineSnapshot(`
<pre>
<code>
{
"title": "TOML Example (but in JSON)",
"database": {
"data": [
[
"delta",
"phi"
],
[
3.14
]
],
"enabled": true,
"ports": [
8000,
8001,
8002
],
"temp_targets": {
"case": 72,
"cpu": 79.5
}
},
"owner": {
"dob": "1979-05-27T07:32:00-08:00",
"name": "Tom Preston-Werner"
},
"servers": {
"alpha": {
"ip": "10.0.0.1",
"role": "frontend"
},
"beta": {
"ip": "10.0.0.2",
"role": "backend"
}
}
}
</code>
</pre>
`)
})
})
describe('checkboxInputTag', () => {
it('can be checked', () => {
render(checkboxInputTag(true))
expect(screen.getByRole('checkbox')).toBeChecked()
})
it('can be unchecked', () => {
render(checkboxInputTag(false))
expect(screen.getByRole('checkbox')).not.toBeChecked()
})
it('is disabled when checked', () => {
render(checkboxInputTag(true))
expect(screen.getByRole('checkbox')).toBeDisabled()
})
it('is disabled when unchecked', () => {
render(checkboxInputTag(false))
expect(screen.getByRole('checkbox')).toBeDisabled()
})
})

View File

@ -0,0 +1,58 @@
import React from 'react'
import humanize from 'humanize-string'
const MAX_STRING_LENGTH = 150
export const formatEnum = (values: string | string[] | null | undefined) => {
let output = ''
if (Array.isArray(values)) {
const humanizedValues = values.map((value) => humanize(value))
output = humanizedValues.join(', ')
} else if (typeof values === 'string') {
output = humanize(values)
}
return output
}
export const jsonDisplay = (obj: unknown) => {
return (
<pre>
<code>{JSON.stringify(obj, null, 2)}</code>
</pre>
)
}
export const truncate = (value: string | number) => {
let output = value?.toString() ?? ''
if (output.length > MAX_STRING_LENGTH) {
output = output.substring(0, MAX_STRING_LENGTH) + '...'
}
return output
}
export const jsonTruncate = (obj: unknown) => {
return truncate(JSON.stringify(obj, null, 2))
}
export const timeTag = (dateTime?: string) => {
let output: string | JSX.Element = ''
if (dateTime) {
output = (
<time dateTime={dateTime} title={dateTime}>
{new Date(dateTime).toUTCString()}
</time>
)
}
return output
}
export const checkboxInputTag = (checked: boolean) => {
return <input type="checkbox" checked={checked} disabled />
}

6
web/src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react'
import AboutPage from './AboutPage'
const meta: Meta<typeof AboutPage> = {
component: AboutPage,
}
export default meta
type Story = StoryObj<typeof AboutPage>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import AboutPage from './AboutPage'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('AboutPage', () => {
it('renders successfully', () => {
expect(() => {
render(<AboutPage />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,21 @@
// import { Link, routes } from '@redwoodjs/router'
import { Metadata } from '@redwoodjs/web'
const AboutPage = () => {
return (
<>
<Metadata title="About" description="About page" />
<h1>AboutPage</h1>
<p>
Find me inasdf <code>./web/src/pages/AboutPage/AboutPage.tsx</code>
</p>
{/*
My default route is named `about`, link to me with:
`<Link to={routes.about()}>About</Link>`
*/}
</>
)
}
export default AboutPage

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react'
import CalendarPage from './CalendarPage'
const meta: Meta<typeof CalendarPage> = {
component: CalendarPage,
}
export default meta
type Story = StoryObj<typeof CalendarPage>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import CalendarPage from './CalendarPage'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('CalendarPage', () => {
it('renders successfully', () => {
expect(() => {
render(<CalendarPage />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,22 @@
// import { Link, routes } from '@redwoodjs/router'
import { Metadata } from '@redwoodjs/web'
import { CalendarSidebar } from '@/components/calendar/sidebar'
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
const CalendarPage = () => {
return (
<SidebarProvider>
<Metadata title="Calendar" description="Calendar page" />
<SidebarTrigger />
<h1>CalendarPage</h1>
<p>
Find me in <code>./web/src/pages/CalendarPage/CalendarPage.tsx</code>
</p>
<CalendarSidebar />
</SidebarProvider>
)
}
export default CalendarPage

View File

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react'
import DashboardPage from './DashboardPage'
const meta: Meta<typeof DashboardPage> = {
component: DashboardPage,
}
export default meta
type Story = StoryObj<typeof DashboardPage>
export const Primary: Story = {}

View File

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import DashboardPage from './DashboardPage'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('DashboardPage', () => {
it('renders successfully', () => {
expect(() => {
render(<DashboardPage />)
}).not.toThrow()
})
})

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