feat(docs): added guide to use the guild's codegen directly with Nhost (#3696)
This commit is contained in:
@@ -220,7 +220,8 @@
|
||||
"pages": [
|
||||
"/products/graphql/guides/react-apollo",
|
||||
"/products/graphql/guides/react-query",
|
||||
"/products/graphql/guides/react-urql"
|
||||
"/products/graphql/guides/react-urql",
|
||||
"/products/graphql/guides/codegen-nhost"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1326,10 +1326,9 @@ After we complete the next tutorial on user authentication, you will be able to
|
||||
|
||||
1. **Server-Side Helpers**: Utilities for handling authentication in Next.js server components and middleware
|
||||
2. **Middleware Route Protection**: Next.js middleware runs before any page renders, automatically redirecting unauthenticated users from protected routes and refreshing tokens
|
||||
3. **AuthProvider**: Client-side provider that manages authentication state using Nhost's client with cookie-based storage for server/client synchronization
|
||||
4. **Protected Pages**: Server components can assume authentication since middleware handles protection, focusing purely on rendering authenticated content
|
||||
5. **Navigation**: Server-side navigation component that adapts its links based on authentication status
|
||||
6. **Automatic Redirects**: All route protection and redirects are handled at the middleware level for optimal performance and security
|
||||
3. **Protected Pages**: Server components can assume authentication since middleware handles protection, focusing purely on rendering authenticated content
|
||||
4. **Navigation**: Server-side navigation component that adapts its links based on authentication status
|
||||
5. **Automatic Redirects**: All route protection and redirects are handled at the middleware level for optimal performance and security
|
||||
|
||||
## Key Features Demonstrated
|
||||
|
||||
|
||||
9
docs/products/graphql/guides/codegen-nhost.mdx
Normal file
9
docs/products/graphql/guides/codegen-nhost.mdx
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Codegen + Nhost"
|
||||
description: "How to use The Guild's codegen with Nhost"
|
||||
icon: U
|
||||
---
|
||||
|
||||
You can use [The Guild's codegen](https://the-guild.dev/graphql/codegen) to generate types and document nodes for your GraphQL operations and use them directly with Nhost's GraphQL client.
|
||||
|
||||
You can find a working example with instructions in our [GitHub repository](https://github.com/nhost/nhost/blob/main/examples/guides/codegen-nhost/README.md).
|
||||
25
examples/guides/codegen-nhost/.gitignore
vendored
Normal file
25
examples/guides/codegen-nhost/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.vite
|
||||
443
examples/guides/codegen-nhost/README.md
Normal file
443
examples/guides/codegen-nhost/README.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# GraphQL Code Generation with Nhost SDK
|
||||
|
||||
This guide demonstrates how to use GraphQL Code Generator with TypedDocumentNode to get full type safety when working with the Nhost SDK.
|
||||
|
||||
Note: While the project uses React to illustrate usage, the generated types and documents can be used in any JavaScript/TypeScript environment.
|
||||
|
||||
## Overview
|
||||
|
||||
The Nhost SDK's GraphQL client supports `TypedDocumentNode` from `@graphql-typed-document-node/core`, allowing you to use generated types and documents for type-safe GraphQL operations. This guide shows you how to set up GraphQL Code Generator to work seamlessly with Nhost.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install @nhost/nhost-js graphql graphql-typed-document-node/core
|
||||
# or
|
||||
yarn add @nhost/nhost-js graphql graphql-typed-document-node/core
|
||||
# or
|
||||
pnpm add @nhost/nhost-js graphql graphql-typed-document-node/core
|
||||
```
|
||||
|
||||
### 2. Install GraphQL CodeGen
|
||||
|
||||
Install the necessary code generation packages:
|
||||
|
||||
```bash
|
||||
npm install -D @graphql-codegen/cli @graphql-codegen/client-preset @graphql-codegen/schema-ast
|
||||
# or
|
||||
pnpm add -D @graphql-codegen/cli @graphql-codegen/client-preset @graphql-codegen/schema-ast
|
||||
```
|
||||
|
||||
### 3. Configure GraphQL CodeGen
|
||||
|
||||
Create a `codegen.ts` file with the client preset configuration:
|
||||
|
||||
```typescript
|
||||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: [
|
||||
{
|
||||
"https://local.graphql.local.nhost.run/v1": {
|
||||
headers: {
|
||||
"x-hasura-admin-secret": "nhost-admin-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
documents: ["src/lib/graphql/**/*.graphql"],
|
||||
ignoreNoDocuments: true,
|
||||
generates: {
|
||||
"./src/lib/graphql/__generated__/": {
|
||||
preset: "client",
|
||||
presetConfig: {
|
||||
persistedDocuments: false,
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
"./add-query-source-plugin.cjs": {},
|
||||
},
|
||||
],
|
||||
config: {
|
||||
scalars: {
|
||||
UUID: "string",
|
||||
uuid: "string",
|
||||
timestamptz: "string",
|
||||
jsonb: "Record<string, any>",
|
||||
bigint: "number",
|
||||
bytea: "Buffer",
|
||||
citext: "string",
|
||||
},
|
||||
useTypeImports: true,
|
||||
},
|
||||
},
|
||||
"./schema.graphql": {
|
||||
plugins: ["schema-ast"],
|
||||
config: {
|
||||
includeDirectives: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### 4. Create the Custom Plugin
|
||||
|
||||
The Nhost SDK expects documents to have a `loc.source.body` property containing the query string. Create a custom plugin to add this:
|
||||
|
||||
**add-query-source-plugin.cjs:**
|
||||
|
||||
```javascript
|
||||
// Custom GraphQL Codegen plugin to add loc.source.body to generated documents
|
||||
// This allows the Nhost SDK to extract the query string without needing the graphql package
|
||||
|
||||
const { print } = require("graphql");
|
||||
|
||||
/**
|
||||
* @type {import('@graphql-codegen/plugin-helpers').PluginFunction}
|
||||
*/
|
||||
const plugin = (_schema, documents, _config) => {
|
||||
let output = "";
|
||||
|
||||
for (const doc of documents) {
|
||||
if (!doc.document) continue;
|
||||
|
||||
for (const definition of doc.document.definitions) {
|
||||
if (definition.kind === "OperationDefinition" && definition.name) {
|
||||
const operationName = definition.name.value;
|
||||
const documentName = `${operationName}Document`;
|
||||
|
||||
// Create a document with just this operation
|
||||
const singleOpDocument = {
|
||||
kind: "Document",
|
||||
definitions: [definition],
|
||||
};
|
||||
|
||||
// Use graphql print to convert AST to string
|
||||
const source = print(singleOpDocument);
|
||||
|
||||
output += `
|
||||
// Add query source to ${documentName}
|
||||
if (${documentName}) {
|
||||
Object.assign(${documentName}, {
|
||||
loc: { source: { body: ${JSON.stringify(source)} } }
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
module.exports = { plugin };
|
||||
```
|
||||
|
||||
## Integration Guide
|
||||
|
||||
### 1. Create an Auth Provider
|
||||
|
||||
Create an authentication context to manage the Nhost client and user session:
|
||||
|
||||
```typescript
|
||||
// src/lib/nhost/AuthProvider.tsx
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createClient, type NhostClient } from "@nhost/nhost-js";
|
||||
import { type Session } from "@nhost/nhost-js/auth";
|
||||
|
||||
interface AuthContextType {
|
||||
user: Session["user"] | null;
|
||||
session: Session | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
nhost: NhostClient;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<Session["user"] | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
|
||||
// Create the nhost client
|
||||
const nhost = useMemo(
|
||||
() =>
|
||||
createClient({
|
||||
region: import.meta.env.VITE_NHOST_REGION || "local",
|
||||
subdomain: import.meta.env.VITE_NHOST_SUBDOMAIN || "local",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const currentSession = nhost.getUserSession();
|
||||
setUser(currentSession?.user || null);
|
||||
setSession(currentSession);
|
||||
setIsAuthenticated(!!currentSession);
|
||||
setIsLoading(false);
|
||||
|
||||
const unsubscribe = nhost.sessionStorage.onChange((currentSession) => {
|
||||
setUser(currentSession?.user || null);
|
||||
setSession(currentSession);
|
||||
setIsAuthenticated(!!currentSession);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [nhost]);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
session,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
nhost,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Set Up Your App Providers
|
||||
|
||||
Wrap your application with the Auth provider:
|
||||
|
||||
```tsx
|
||||
// src/main.tsx
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { AuthProvider } from "./lib/nhost/AuthProvider";
|
||||
|
||||
const Root = () => (
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("Root element not found");
|
||||
|
||||
createRoot(rootElement).render(<Root />);
|
||||
```
|
||||
|
||||
### 3. Define GraphQL Operations
|
||||
|
||||
Create GraphQL files with your queries and mutations:
|
||||
|
||||
```graphql
|
||||
# src/lib/graphql/operations.graphql
|
||||
query GetNinjaTurtlesWithComments {
|
||||
ninjaTurtles {
|
||||
id
|
||||
name
|
||||
description
|
||||
createdAt
|
||||
updatedAt
|
||||
comments {
|
||||
id
|
||||
comment
|
||||
createdAt
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation AddComment($ninjaTurtleId: uuid!, $comment: String!) {
|
||||
insertComment(object: { ninjaTurtleId: $ninjaTurtleId, comment: $comment }) {
|
||||
id
|
||||
comment
|
||||
createdAt
|
||||
ninjaTurtleId
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Generate TypeScript Types
|
||||
|
||||
Run the code generator:
|
||||
|
||||
```bash
|
||||
npx graphql-codegen
|
||||
```
|
||||
|
||||
You can also add a script to your `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"generate": "graphql-codegen --config codegen.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
npm run generate
|
||||
# or
|
||||
pnpm generate
|
||||
```
|
||||
|
||||
### 5. Use in Components
|
||||
|
||||
Use the generated types and documents with the Nhost SDK:
|
||||
|
||||
```tsx
|
||||
// src/pages/Home.tsx
|
||||
import { type JSX, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
AddCommentDocument,
|
||||
GetNinjaTurtlesWithCommentsDocument,
|
||||
type GetNinjaTurtlesWithCommentsQuery,
|
||||
} from "../lib/graphql/__generated__/graphql";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function Home(): JSX.Element {
|
||||
const { isLoading, nhost } = useAuth();
|
||||
const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
|
||||
const [data, setData] = useState<GetNinjaTurtlesWithCommentsQuery | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Fetch ninja turtles data
|
||||
const fetchNinjaTurtles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await nhost.graphql.request(
|
||||
GetNinjaTurtlesWithCommentsDocument,
|
||||
{},
|
||||
);
|
||||
|
||||
if (result.body.errors) {
|
||||
throw new Error(result.body.errors[0]?.message);
|
||||
}
|
||||
|
||||
setData(result.body.data ?? null);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [nhost.graphql]);
|
||||
|
||||
// Load data on mount
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
fetchNinjaTurtles();
|
||||
}
|
||||
}, [isLoading, fetchNinjaTurtles]);
|
||||
|
||||
const addComment = async (ninjaTurtleId: string, comment: string) => {
|
||||
try {
|
||||
const result = await nhost.graphql.request(AddCommentDocument, {
|
||||
ninjaTurtleId,
|
||||
comment,
|
||||
});
|
||||
|
||||
if (result.body.errors) {
|
||||
throw new Error(result.body.errors[0]?.message);
|
||||
}
|
||||
|
||||
// Clear form and refetch data
|
||||
setCommentText("");
|
||||
setActiveCommentId(null);
|
||||
await fetchNinjaTurtles();
|
||||
} catch (err) {
|
||||
console.error("Error adding comment:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// ... rest of component
|
||||
}
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
### Type-Safe GraphQL Requests
|
||||
|
||||
The Nhost SDK's `graphql.request()` method has overloads that support `TypedDocumentNode`:
|
||||
|
||||
```typescript
|
||||
// Type inference works automatically
|
||||
const result = await nhost.graphql.request(
|
||||
GetNinjaTurtlesWithCommentsDocument,
|
||||
{}, // Variables are type-checked
|
||||
);
|
||||
|
||||
// result.body.data is typed as GetNinjaTurtlesWithCommentsQuery | undefined
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **GraphQL Code Generator** creates `TypedDocumentNode` types and documents using the client preset
|
||||
2. **Custom Plugin** adds the `loc.source.body` property to each document at runtime
|
||||
3. **Nhost SDK** detects the `TypedDocumentNode`, extracts the query string from `loc.source.body`, and executes the request
|
||||
4. **TypeScript** infers response types automatically based on the document types
|
||||
|
||||
### Benefits
|
||||
|
||||
- ✅ Full type safety for queries, mutations, and variables
|
||||
- ✅ Automatic type inference - no manual type annotations needed
|
||||
- ✅ Type-checked variables prevent runtime errors
|
||||
- ✅ IntelliSense support in your IDE
|
||||
- ✅ Compile-time errors for invalid queries or mismatched types
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "not a valid graphql query" Error
|
||||
|
||||
If you see this error, make sure:
|
||||
1. The custom plugin (`add-query-source-plugin.cjs`) is in place
|
||||
2. The plugin is configured in your `codegen.ts`
|
||||
3. You've run `pnpm generate` after adding the plugin
|
||||
|
||||
### TypeScript Errors
|
||||
|
||||
If you get type errors:
|
||||
1. Make sure you're not passing explicit generic type parameters to `nhost.graphql.request()`
|
||||
2. Let TypeScript infer types from the document
|
||||
3. Pass an empty object `{}` for queries without variables
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [GraphQL Code Generator Docs](https://the-guild.dev/graphql/codegen)
|
||||
- [Nhost Documentation](https://docs.nhost.io)
|
||||
- [TypedDocumentNode](https://github.com/dotansimha/graphql-typed-document-node)
|
||||
44
examples/guides/codegen-nhost/add-query-source-plugin.cjs
Normal file
44
examples/guides/codegen-nhost/add-query-source-plugin.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
// Custom GraphQL Codegen plugin to add loc.source.body to generated documents
|
||||
// This allows the Nhost SDK to extract the query string without needing the graphql package
|
||||
|
||||
const { print } = require("graphql");
|
||||
|
||||
/**
|
||||
* @type {import('@graphql-codegen/plugin-helpers').PluginFunction}
|
||||
*/
|
||||
const plugin = (_schema, documents, _config) => {
|
||||
let output = "";
|
||||
|
||||
for (const doc of documents) {
|
||||
if (!doc.document) continue;
|
||||
|
||||
for (const definition of doc.document.definitions) {
|
||||
if (definition.kind === "OperationDefinition" && definition.name) {
|
||||
const operationName = definition.name.value;
|
||||
const documentName = `${operationName}Document`;
|
||||
|
||||
// Create a document with just this operation
|
||||
const singleOpDocument = {
|
||||
kind: "Document",
|
||||
definitions: [definition],
|
||||
};
|
||||
|
||||
// Use graphql print to convert AST to string
|
||||
const source = print(singleOpDocument);
|
||||
|
||||
output += `
|
||||
// Add query source to ${documentName}
|
||||
if (${documentName}) {
|
||||
Object.assign(${documentName}, {
|
||||
loc: { source: { body: ${JSON.stringify(source)} } }
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
module.exports = { plugin };
|
||||
7
examples/guides/codegen-nhost/biome.json
Normal file
7
examples/guides/codegen-nhost/biome.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"linter": {
|
||||
"includes": ["**", "!src/lib/graphql/__generated__/*.ts"]
|
||||
}
|
||||
}
|
||||
27
examples/guides/codegen-nhost/codegen-wrapper.sh
Executable file
27
examples/guides/codegen-nhost/codegen-wrapper.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Running GraphQL code generator..."
|
||||
pnpm graphql-codegen --config codegen.ts
|
||||
|
||||
GENERATED_TS_FILE="src/lib/graphql/__generated__/"
|
||||
GENERATED_SCHEMA_FILE="schema.graphql"
|
||||
|
||||
if [ -d "$GENERATED_TS_FILE" ]; then
|
||||
echo "Formatting $GENERATED_TS_FILE..."
|
||||
biome check --write "$GENERATED_TS_FILE"
|
||||
else
|
||||
echo "Error: Generated TypeScript file not found at $GENERATED_TS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
if [ -f "$GENERATED_SCHEMA_FILE" ]; then
|
||||
echo "Formatting $GENERATED_SCHEMA_FILE..."
|
||||
biome check --write "$GENERATED_SCHEMA_FILE"
|
||||
echo "Successfully formatted $GENERATED_SCHEMA_FILE"
|
||||
else
|
||||
echo "Warning: Generated schema file not found at $GENERATED_SCHEMA_FILE"
|
||||
fi
|
||||
|
||||
echo "All tasks completed successfully."
|
||||
48
examples/guides/codegen-nhost/codegen.ts
Normal file
48
examples/guides/codegen-nhost/codegen.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: [
|
||||
{
|
||||
"https://local.graphql.local.nhost.run/v1": {
|
||||
headers: {
|
||||
"x-hasura-admin-secret": "nhost-admin-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
documents: ["src/lib/graphql/**/*.graphql"],
|
||||
ignoreNoDocuments: true,
|
||||
generates: {
|
||||
"./src/lib/graphql/__generated__/": {
|
||||
preset: "client",
|
||||
presetConfig: {
|
||||
persistedDocuments: false,
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
"./add-query-source-plugin.cjs": {},
|
||||
},
|
||||
],
|
||||
config: {
|
||||
scalars: {
|
||||
UUID: "string",
|
||||
uuid: "string",
|
||||
timestamptz: "string",
|
||||
jsonb: "Record<string, any>",
|
||||
bigint: "number",
|
||||
bytea: "Buffer",
|
||||
citext: "string",
|
||||
},
|
||||
useTypeImports: true,
|
||||
},
|
||||
},
|
||||
"./schema.graphql": {
|
||||
plugins: ["schema-ast"],
|
||||
config: {
|
||||
includeDirectives: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
13
examples/guides/codegen-nhost/index.html
Normal file
13
examples/guides/codegen-nhost/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
examples/guides/codegen-nhost/package.json
Normal file
40
examples/guides/codegen-nhost/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "guides/codegen-nhost",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"generate": "bash codegen-wrapper.sh",
|
||||
"test": "pnpm test:typecheck && pnpm test:lint",
|
||||
"test:typecheck": "tsc --noEmit",
|
||||
"test:lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@graphql-typed-document-node/core": "^3.2.0",
|
||||
"@nhost/nhost-js": "workspace:*",
|
||||
"graphql": "^16.11.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^5.0.6",
|
||||
"@graphql-codegen/client-preset": "^5.1.2",
|
||||
"@graphql-codegen/schema-ast": "^4.1.0",
|
||||
"@graphql-codegen/typescript": "^4.1.6",
|
||||
"@graphql-codegen/typescript-operations": "^4.6.1",
|
||||
"@types/node": "^22.15.17",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"js-yaml@<4.1.1": ">=4.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
3826
examples/guides/codegen-nhost/pnpm-lock.yaml
generated
Normal file
3826
examples/guides/codegen-nhost/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
examples/guides/codegen-nhost/public/vite.svg
Normal file
1
examples/guides/codegen-nhost/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
10143
examples/guides/codegen-nhost/schema.graphql
Normal file
10143
examples/guides/codegen-nhost/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
56
examples/guides/codegen-nhost/src/App.tsx
Normal file
56
examples/guides/codegen-nhost/src/App.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { JSX } from "react";
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
RouterProvider,
|
||||
} from "react-router-dom";
|
||||
import Navigation from "./components/Navigation";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import Home from "./pages/Home";
|
||||
import Profile from "./pages/Profile";
|
||||
import SignIn from "./pages/SignIn";
|
||||
import SignUp from "./pages/SignUp";
|
||||
|
||||
// Root layout component to wrap all routes
|
||||
const RootLayout = (): JSX.Element => {
|
||||
return (
|
||||
<div className="flex-col min-h-screen">
|
||||
<Navigation />
|
||||
<main className="max-w-2xl mx-auto p-6 w-full">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer>
|
||||
<p
|
||||
className="text-sm text-center"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
© {new Date().getFullYear()} Nhost Demo
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Create router with routes
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route element={<RootLayout />}>
|
||||
<Route path="signin" element={<SignIn />} />
|
||||
<Route path="signup" element={<SignUp />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="home" element={<Home />} />
|
||||
<Route path="profile" element={<Profile />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Route>,
|
||||
),
|
||||
);
|
||||
|
||||
const App = (): JSX.Element => {
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
85
examples/guides/codegen-nhost/src/components/Navigation.tsx
Normal file
85
examples/guides/codegen-nhost/src/components/Navigation.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { JSX } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function Navigation(): JSX.Element {
|
||||
const { isAuthenticated, nhost, session } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Helper function to determine if a link is active
|
||||
const isActive = (path: string): string => {
|
||||
return location.pathname === path ? "active" : "";
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="navbar-container">
|
||||
<div className="flex items-center">
|
||||
<span className="navbar-brand">Nhost Demo</span>
|
||||
<div className="navbar-links">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link to="/home" className={`nav-link ${isActive("/home")}`}>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/profile"
|
||||
className={`nav-link ${isActive("/profile")}`}
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/signin"
|
||||
className={`nav-link ${isActive("/signin")}`}
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
to="/signup"
|
||||
className={`nav-link ${isActive("/signup")}`}
|
||||
>
|
||||
Sign Up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAuthenticated && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (session) {
|
||||
await nhost.auth.signOut({
|
||||
refreshToken: session.refreshToken,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="icon-button"
|
||||
title="Sign Out"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-label="Sign Out"
|
||||
role="img"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({
|
||||
redirectTo = "/signin",
|
||||
}: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={redirectTo} />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
552
examples/guides/codegen-nhost/src/index.css
Normal file
552
examples/guides/codegen-nhost/src/index.css
Normal file
@@ -0,0 +1,552 @@
|
||||
/* Base styles */
|
||||
:root {
|
||||
--background: #030712;
|
||||
--foreground: #ffffff;
|
||||
--card-bg: #111827;
|
||||
--card-border: #1f2937;
|
||||
--primary: #6366f1;
|
||||
--primary-hover: #4f46e5;
|
||||
--secondary: #10b981;
|
||||
--secondary-hover: #059669;
|
||||
--accent: #8b5cf6;
|
||||
--accent-hover: #7c3aed;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-muted: #9ca3af;
|
||||
--border-color: rgba(31, 41, 55, 0.7);
|
||||
--font-geist-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.max-w-2xl {
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.p-6 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.p-8 {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.py-5 {
|
||||
padding-top: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mr-8 {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.space-y-5 > * + * {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.space-x-4 > * + * {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(to right, var(--primary), var(--accent));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Components */
|
||||
.glass-card {
|
||||
background: rgba(17, 24, 39, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--primary-hover);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--secondary-hover);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: white;
|
||||
background-color: rgba(31, 41, 55, 0.7);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background-color: rgba(31, 41, 55, 0.8);
|
||||
border: 1px solid var(--border-color);
|
||||
color: white;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
border: 1px solid rgba(239, 68, 68, 0.5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
border: 1px solid rgba(34, 197, 94, 0.5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: rgba(17, 24, 39, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--primary);
|
||||
font-weight: bold;
|
||||
font-size: 1.125rem;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.navbar-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: rgba(31, 41, 55, 0.3);
|
||||
}
|
||||
|
||||
/* File upload styles */
|
||||
.file-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
border: 2px dashed rgba(99, 102, 241, 0.3);
|
||||
border-radius: 0.5rem;
|
||||
background-color: rgba(31, 41, 55, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.file-upload:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
padding: 1.25rem 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Link styles */
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre {
|
||||
background-color: rgba(31, 41, 55, 0.8);
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow: auto;
|
||||
font-family: monospace;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Profile data */
|
||||
.profile-item {
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.profile-item strong {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.action-link {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
margin-right: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-link:hover {
|
||||
color: var(--primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-link-danger {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.action-link-danger:hover {
|
||||
color: #f05252;
|
||||
}
|
||||
|
||||
/* Icon button */
|
||||
.icon-button {
|
||||
background-color: transparent;
|
||||
color: var(--primary);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: rgba(99, 102, 241, 0.1);
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.icon-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Table action icons */
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.action-icon:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-icon-view {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.action-icon-view:hover:not(:disabled) {
|
||||
background-color: rgba(99, 102, 241, 0.1);
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.action-icon-delete {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.action-icon-delete:hover:not(:disabled) {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #f05252;
|
||||
}
|
||||
|
||||
/* Tab styles */
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
background-color: rgba(31, 41, 55, 0.5);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tab-button:hover:not(.tab-active) {
|
||||
background-color: rgba(31, 41, 55, 0.8);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-button.tab-active {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.tab-button:first-child {
|
||||
border-top-left-radius: 0.5rem;
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-button:last-child {
|
||||
border-top-right-radius: 0.5rem;
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
110
examples/guides/codegen-nhost/src/lib/graphql/__generated__/fragment-masking.ts
generated
Normal file
110
examples/guides/codegen-nhost/src/lib/graphql/__generated__/fragment-masking.ts
generated
Normal file
@@ -0,0 +1,110 @@
|
||||
/* eslint-disable */
|
||||
import type {
|
||||
DocumentTypeDecoration,
|
||||
ResultOf,
|
||||
TypedDocumentNode,
|
||||
} from "@graphql-typed-document-node/core";
|
||||
import type { FragmentDefinitionNode } from "graphql";
|
||||
import type { Incremental } from "./graphql";
|
||||
|
||||
export type FragmentType<
|
||||
TDocumentType extends DocumentTypeDecoration<any, any>,
|
||||
> = TDocumentType extends DocumentTypeDecoration<infer TType, any>
|
||||
? [TType] extends [{ " $fragmentName"?: infer TKey }]
|
||||
? TKey extends string
|
||||
? { " $fragmentRefs"?: { [key in TKey]: TType } }
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
// return non-nullable if `fragmentType` is non-nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>>,
|
||||
): TType;
|
||||
// return nullable if `fragmentType` is undefined
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | undefined,
|
||||
): TType | undefined;
|
||||
// return nullable if `fragmentType` is nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null,
|
||||
): TType | null;
|
||||
// return nullable if `fragmentType` is nullable or undefined
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType:
|
||||
| FragmentType<DocumentTypeDecoration<TType, any>>
|
||||
| null
|
||||
| undefined,
|
||||
): TType | null | undefined;
|
||||
// return array of non-nullable if `fragmentType` is array of non-nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>>,
|
||||
): Array<TType>;
|
||||
// return array of nullable if `fragmentType` is array of nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType:
|
||||
| Array<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
| null
|
||||
| undefined,
|
||||
): Array<TType> | null | undefined;
|
||||
// return readonly array of non-nullable if `fragmentType` is array of non-nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>,
|
||||
): ReadonlyArray<TType>;
|
||||
// return readonly array of nullable if `fragmentType` is array of nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType:
|
||||
| ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
| null
|
||||
| undefined,
|
||||
): ReadonlyArray<TType> | null | undefined;
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType:
|
||||
| FragmentType<DocumentTypeDecoration<TType, any>>
|
||||
| Array<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
| ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
| null
|
||||
| undefined,
|
||||
): TType | Array<TType> | ReadonlyArray<TType> | null | undefined {
|
||||
return fragmentType as any;
|
||||
}
|
||||
|
||||
export function makeFragmentData<
|
||||
F extends DocumentTypeDecoration<any, any>,
|
||||
FT extends ResultOf<F>,
|
||||
>(data: FT, _fragment: F): FragmentType<F> {
|
||||
return data as FragmentType<F>;
|
||||
}
|
||||
export function isFragmentReady<TQuery, TFrag>(
|
||||
queryNode: DocumentTypeDecoration<TQuery, any>,
|
||||
fragmentNode: TypedDocumentNode<TFrag>,
|
||||
data:
|
||||
| FragmentType<TypedDocumentNode<Incremental<TFrag>, any>>
|
||||
| null
|
||||
| undefined,
|
||||
): data is FragmentType<typeof fragmentNode> {
|
||||
const deferredFields = (
|
||||
queryNode as {
|
||||
__meta__?: { deferredFields: Record<string, (keyof TFrag)[]> };
|
||||
}
|
||||
).__meta__?.deferredFields;
|
||||
|
||||
if (!deferredFields) return true;
|
||||
|
||||
const fragDef = fragmentNode.definitions[0] as
|
||||
| FragmentDefinitionNode
|
||||
| undefined;
|
||||
const fragName = fragDef?.name?.value;
|
||||
|
||||
const fields = (fragName && deferredFields[fragName]) || [];
|
||||
return fields.length > 0 && fields.every((field) => data && field in data);
|
||||
}
|
||||
51
examples/guides/codegen-nhost/src/lib/graphql/__generated__/gql.ts
generated
Normal file
51
examples/guides/codegen-nhost/src/lib/graphql/__generated__/gql.ts
generated
Normal file
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import type { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/core";
|
||||
import * as types from "./graphql";
|
||||
|
||||
/**
|
||||
* Map of all GraphQL operations in the project.
|
||||
*
|
||||
* This map has several performance disadvantages:
|
||||
* 1. It is not tree-shakeable, so it will include all operations in the project.
|
||||
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
|
||||
* 3. It does not support dead code elimination, so it will add unused operations.
|
||||
*
|
||||
* Therefore it is highly recommended to use the babel or swc plugin for production.
|
||||
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
|
||||
*/
|
||||
type Documents = {
|
||||
"query GetNinjaTurtlesWithComments {\n ninjaTurtles {\n id\n name\n description\n createdAt\n updatedAt\n comments {\n id\n comment\n createdAt\n user {\n id\n displayName\n email\n }\n }\n }\n}\n\nmutation AddComment($ninjaTurtleId: uuid!, $comment: String!) {\n insertComment(object: {ninjaTurtleId: $ninjaTurtleId, comment: $comment}) {\n id\n comment\n createdAt\n ninjaTurtleId\n }\n}": typeof types.GetNinjaTurtlesWithCommentsDocument;
|
||||
};
|
||||
const documents: Documents = {
|
||||
"query GetNinjaTurtlesWithComments {\n ninjaTurtles {\n id\n name\n description\n createdAt\n updatedAt\n comments {\n id\n comment\n createdAt\n user {\n id\n displayName\n email\n }\n }\n }\n}\n\nmutation AddComment($ninjaTurtleId: uuid!, $comment: String!) {\n insertComment(object: {ninjaTurtleId: $ninjaTurtleId, comment: $comment}) {\n id\n comment\n createdAt\n ninjaTurtleId\n }\n}":
|
||||
types.GetNinjaTurtlesWithCommentsDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
|
||||
* ```
|
||||
*
|
||||
* The query argument is unknown!
|
||||
* Please regenerate the types.
|
||||
*/
|
||||
export function graphql(source: string): unknown;
|
||||
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: "query GetNinjaTurtlesWithComments {\n ninjaTurtles {\n id\n name\n description\n createdAt\n updatedAt\n comments {\n id\n comment\n createdAt\n user {\n id\n displayName\n email\n }\n }\n }\n}\n\nmutation AddComment($ninjaTurtleId: uuid!, $comment: String!) {\n insertComment(object: {ninjaTurtleId: $ninjaTurtleId, comment: $comment}) {\n id\n comment\n createdAt\n ninjaTurtleId\n }\n}",
|
||||
): (typeof documents)["query GetNinjaTurtlesWithComments {\n ninjaTurtles {\n id\n name\n description\n createdAt\n updatedAt\n comments {\n id\n comment\n createdAt\n user {\n id\n displayName\n email\n }\n }\n }\n}\n\nmutation AddComment($ninjaTurtleId: uuid!, $comment: String!) {\n insertComment(object: {ninjaTurtleId: $ninjaTurtleId, comment: $comment}) {\n id\n comment\n createdAt\n ninjaTurtleId\n }\n}"];
|
||||
|
||||
export function graphql(source: string) {
|
||||
return (documents as any)[source] ?? {};
|
||||
}
|
||||
|
||||
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> =
|
||||
TDocumentNode extends DocumentNode<infer TType, any> ? TType : never;
|
||||
7026
examples/guides/codegen-nhost/src/lib/graphql/__generated__/graphql.ts
generated
Normal file
7026
examples/guides/codegen-nhost/src/lib/graphql/__generated__/graphql.ts
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
examples/guides/codegen-nhost/src/lib/graphql/__generated__/index.ts
generated
Normal file
2
examples/guides/codegen-nhost/src/lib/graphql/__generated__/index.ts
generated
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./fragment-masking";
|
||||
export * from "./gql";
|
||||
@@ -0,0 +1,28 @@
|
||||
query GetNinjaTurtlesWithComments {
|
||||
ninjaTurtles {
|
||||
id
|
||||
name
|
||||
description
|
||||
createdAt
|
||||
updatedAt
|
||||
comments {
|
||||
id
|
||||
comment
|
||||
createdAt
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation AddComment($ninjaTurtleId: uuid!, $comment: String!) {
|
||||
insertComment(object: { ninjaTurtleId: $ninjaTurtleId, comment: $comment }) {
|
||||
id
|
||||
comment
|
||||
createdAt
|
||||
ninjaTurtleId
|
||||
}
|
||||
}
|
||||
175
examples/guides/codegen-nhost/src/lib/nhost/AuthProvider.tsx
Normal file
175
examples/guides/codegen-nhost/src/lib/nhost/AuthProvider.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { createClient, type NhostClient } from "@nhost/nhost-js";
|
||||
import type { Session } from "@nhost/nhost-js/auth";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Authentication context interface providing access to user session state and Nhost client.
|
||||
* Used throughout the React application to access authentication-related data and operations.
|
||||
*/
|
||||
interface AuthContextType {
|
||||
/** Current authenticated user object, null if not authenticated */
|
||||
user: Session["user"] | null;
|
||||
/** Current session object containing tokens and user data, null if no active session */
|
||||
session: Session | null;
|
||||
/** Boolean indicating if user is currently authenticated */
|
||||
isAuthenticated: boolean;
|
||||
/** Boolean indicating if authentication state is still loading */
|
||||
isLoading: boolean;
|
||||
/** Nhost client instance for making authenticated requests */
|
||||
nhost: NhostClient;
|
||||
}
|
||||
|
||||
// Create React context for authentication state and nhost client
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthProvider component that provides authentication context to the React application.
|
||||
*
|
||||
* This component handles:
|
||||
* - Initializing the Nhost client with default EventEmitterStorage
|
||||
* - Managing authentication state (user, session, loading, authenticated status)
|
||||
* - Cross-tab session synchronization using sessionStorage.onChange events
|
||||
* - Page visibility and focus event handling to maintain session consistency
|
||||
* - Client-side only session management (no server-side rendering)
|
||||
*/
|
||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [user, setUser] = useState<Session["user"] | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const lastRefreshTokenIdRef = useRef<string | null>(null);
|
||||
|
||||
// Initialize Nhost client with default SessionStorage (local storage)
|
||||
const nhost = useMemo(
|
||||
() =>
|
||||
createClient({
|
||||
region: import.meta.env.VITE_NHOST_REGION || "local",
|
||||
subdomain: import.meta.env.VITE_NHOST_SUBDOMAIN || "local",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles session reload when refresh token changes.
|
||||
* This detects when the session has been updated from other tabs.
|
||||
* Unlike the Next.js version, this only updates local state without server synchronization.
|
||||
*
|
||||
* @param currentRefreshTokenId - The current refresh token ID to compare against stored value
|
||||
*/
|
||||
const reloadSession = useCallback(
|
||||
(currentRefreshTokenId: string | null) => {
|
||||
if (currentRefreshTokenId !== lastRefreshTokenIdRef.current) {
|
||||
lastRefreshTokenIdRef.current = currentRefreshTokenId;
|
||||
|
||||
// Update local authentication state to match current session
|
||||
const currentSession = nhost.getUserSession();
|
||||
setUser(currentSession?.user || null);
|
||||
setSession(currentSession);
|
||||
setIsAuthenticated(!!currentSession);
|
||||
}
|
||||
},
|
||||
[nhost],
|
||||
);
|
||||
|
||||
// Initialize authentication state and set up cross-tab session synchronization
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Load initial session state from Nhost client
|
||||
const currentSession = nhost.getUserSession();
|
||||
setUser(currentSession?.user || null);
|
||||
setSession(currentSession);
|
||||
setIsAuthenticated(!!currentSession);
|
||||
lastRefreshTokenIdRef.current = currentSession?.refreshTokenId ?? null;
|
||||
setIsLoading(false);
|
||||
|
||||
// Subscribe to session changes from other browser tabs
|
||||
// This enables real-time synchronization when user signs in/out in another tab
|
||||
const unsubscribe = nhost.sessionStorage.onChange((session) => {
|
||||
reloadSession(session?.refreshTokenId ?? null);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [nhost, reloadSession]);
|
||||
|
||||
// Handle session changes from page focus events (for additional session consistency)
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Checks for session changes when page becomes visible or focused.
|
||||
* In the React SPA context, this provides additional consistency checks
|
||||
* though it's less critical than in the Next.js SSR version.
|
||||
*/
|
||||
const checkSessionOnFocus = () => {
|
||||
reloadSession(nhost.getUserSession()?.refreshTokenId ?? null);
|
||||
};
|
||||
|
||||
// Monitor page visibility changes (tab switching, window minimizing)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
checkSessionOnFocus();
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor window focus events (clicking back into the browser window)
|
||||
window.addEventListener("focus", checkSessionOnFocus);
|
||||
|
||||
// Cleanup event listeners on component unmount
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", checkSessionOnFocus);
|
||||
window.removeEventListener("focus", checkSessionOnFocus);
|
||||
};
|
||||
}, [nhost, reloadSession]);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
session,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
nhost,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to access the authentication context.
|
||||
*
|
||||
* Must be used within a component wrapped by AuthProvider.
|
||||
* Provides access to current user session, authentication state, and Nhost client.
|
||||
*
|
||||
* @throws {Error} When used outside of AuthProvider
|
||||
* @returns {AuthContextType} Authentication context containing user, session, and client
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { user, isAuthenticated, nhost } = useAuth();
|
||||
*
|
||||
* if (!isAuthenticated) {
|
||||
* return <div>Please sign in</div>;
|
||||
* }
|
||||
*
|
||||
* return <div>Welcome, {user?.displayName}!</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
19
examples/guides/codegen-nhost/src/main.tsx
Normal file
19
examples/guides/codegen-nhost/src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { AuthProvider } from "./lib/nhost/AuthProvider";
|
||||
|
||||
// Root component that sets up providers
|
||||
const Root = () => (
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("Root element not found");
|
||||
|
||||
createRoot(rootElement).render(<Root />);
|
||||
217
examples/guides/codegen-nhost/src/pages/Home.css
Normal file
217
examples/guides/codegen-nhost/src/pages/Home.css
Normal file
@@ -0,0 +1,217 @@
|
||||
/* Custom styles for Ninja Turtles tabs interface */
|
||||
.ninja-turtles-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.ninja-turtles-title {
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
color: #1a9c44;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Tab navigation */
|
||||
.turtle-tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #1a9c44;
|
||||
margin-bottom: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.turtle-tab {
|
||||
padding: 10px 20px;
|
||||
margin-right: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s ease;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
position: relative;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.turtle-tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(26, 156, 68, 0.1);
|
||||
}
|
||||
|
||||
.turtle-tab.active {
|
||||
color: white;
|
||||
background: #1a9c44;
|
||||
}
|
||||
|
||||
/* Turtle Card Styles */
|
||||
.turtle-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
animation: fadeIn 0.5s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.turtle-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.turtle-name {
|
||||
color: #1a9c44;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.turtle-description {
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.turtle-date {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Comments section */
|
||||
.comments-section {
|
||||
margin-top: 25px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.comments-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: #1a9c44;
|
||||
}
|
||||
|
||||
.comment-card {
|
||||
margin-bottom: 15px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(26, 156, 68, 0.05);
|
||||
border: 1px solid rgba(26, 156, 68, 0.1);
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: #1a9c44;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Comment form */
|
||||
.comment-form {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.comment-textarea {
|
||||
width: 100%;
|
||||
background-color: rgba(31, 41, 55, 0.8);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.comment-textarea:focus {
|
||||
border-color: #1a9c44;
|
||||
box-shadow: 0 0 0 2px rgba(26, 156, 68, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background-color: rgba(31, 41, 55, 0.8);
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background-color: #1a9c44;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
background-color: #148035;
|
||||
}
|
||||
|
||||
.add-comment-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #1a9c44;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 5px 0;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-comment-button:hover {
|
||||
color: #148035;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.add-comment-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.turtle-tabs {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.turtle-tab {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
237
examples/guides/codegen-nhost/src/pages/Home.tsx
Normal file
237
examples/guides/codegen-nhost/src/pages/Home.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { type JSX, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
AddCommentDocument,
|
||||
GetNinjaTurtlesWithCommentsDocument,
|
||||
type GetNinjaTurtlesWithCommentsQuery,
|
||||
} from "../lib/graphql/__generated__/graphql";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
import "./Home.css";
|
||||
|
||||
export default function Home(): JSX.Element {
|
||||
const { isLoading, nhost } = useAuth();
|
||||
const [activeCommentId, setActiveCommentId] = useState<string | null>(null);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
|
||||
const [data, setData] = useState<GetNinjaTurtlesWithCommentsQuery | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Fetch ninja turtles data
|
||||
const fetchNinjaTurtles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await nhost.graphql.request(
|
||||
GetNinjaTurtlesWithCommentsDocument,
|
||||
{},
|
||||
);
|
||||
|
||||
if (result.body.errors) {
|
||||
throw new Error(result.body.errors[0]?.message);
|
||||
}
|
||||
|
||||
setData(result.body.data ?? null);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [nhost.graphql]);
|
||||
|
||||
// Load data on mount
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
fetchNinjaTurtles();
|
||||
}
|
||||
}, [isLoading, fetchNinjaTurtles]);
|
||||
|
||||
const addComment = async (ninjaTurtleId: string, comment: string) => {
|
||||
try {
|
||||
const result = await nhost.graphql.request(AddCommentDocument, {
|
||||
ninjaTurtleId,
|
||||
comment,
|
||||
});
|
||||
|
||||
if (result.body.errors) {
|
||||
throw new Error(result.body.errors[0]?.message);
|
||||
}
|
||||
|
||||
// Clear form and refetch data
|
||||
setCommentText("");
|
||||
setActiveCommentId(null);
|
||||
await fetchNinjaTurtles();
|
||||
} catch (err) {
|
||||
console.error("Error adding comment:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// If authentication is still loading, show a loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAddComment = (turtleId: string) => {
|
||||
if (!commentText.trim()) return;
|
||||
|
||||
addComment(turtleId, commentText);
|
||||
};
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<p>Loading ninja turtles...</p>
|
||||
</div>
|
||||
);
|
||||
if (error)
|
||||
return (
|
||||
<div className="alert alert-error">
|
||||
Error loading ninja turtles: {(error as Error).message}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Access the data using the correct field name from the GraphQL response
|
||||
const ninjaTurtles = data?.ninjaTurtles || [];
|
||||
if (!ninjaTurtles || ninjaTurtles.length === 0) {
|
||||
return (
|
||||
<div className="no-turtles-container">
|
||||
<p>No ninja turtles found. Please add some!</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Set the active tab to the first turtle if there's no active tab and there are turtles
|
||||
if (activeTabId === null) {
|
||||
setActiveTabId(ninjaTurtles[0] ? ninjaTurtles[0].id : null);
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ninja-turtles-container">
|
||||
<h1 className="ninja-turtles-title text-3xl font-bold mb-6">
|
||||
Teenage Mutant Ninja Turtles
|
||||
</h1>
|
||||
|
||||
{/* Tabs navigation */}
|
||||
<div className="turtle-tabs">
|
||||
{ninjaTurtles.map((turtle) => (
|
||||
<button
|
||||
key={turtle.id}
|
||||
type="button"
|
||||
className={`turtle-tab ${activeTabId === turtle.id ? "active" : ""}`}
|
||||
onClick={() => setActiveTabId(turtle.id)}
|
||||
>
|
||||
{turtle.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Display active turtle */}
|
||||
{ninjaTurtles
|
||||
.filter((turtle) => turtle.id === activeTabId)
|
||||
.map((turtle) => (
|
||||
<div key={turtle.id} className="turtle-card glass-card p-6">
|
||||
<div className="turtle-header">
|
||||
<h2 className="turtle-name text-2xl font-semibold">
|
||||
{turtle.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="turtle-description">{turtle.description}</p>
|
||||
|
||||
<div className="turtle-date">
|
||||
Added on {formatDate(turtle.createdAt || turtle.createdAt)}
|
||||
</div>
|
||||
|
||||
<div className="comments-section">
|
||||
<h3 className="comments-title">
|
||||
Comments ({turtle.comments.length})
|
||||
</h3>
|
||||
|
||||
{turtle.comments.map((comment) => (
|
||||
<div key={comment.id} className="comment-card">
|
||||
<p className="comment-text">{comment.comment}</p>
|
||||
<div className="comment-meta">
|
||||
<div className="comment-avatar">
|
||||
{(comment.user?.displayName || comment.user?.email || "?")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<p>
|
||||
{comment.user?.displayName ||
|
||||
comment.user?.email ||
|
||||
"Anonymous"}{" "}
|
||||
- {formatDate(comment.createdAt || comment.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeCommentId === turtle.id ? (
|
||||
<div className="comment-form">
|
||||
<textarea
|
||||
className="comment-textarea"
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder="Add your comment..."
|
||||
rows={3}
|
||||
/>
|
||||
<div className="comment-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveCommentId(null);
|
||||
setCommentText("");
|
||||
}}
|
||||
className="btn cancel-button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddComment(turtle.id)}
|
||||
className="btn submit-button"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCommentId(turtle.id)}
|
||||
className="add-comment-button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-label="Add Comment"
|
||||
role="img"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Add a comment
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
examples/guides/codegen-nhost/src/pages/Profile.tsx
Normal file
66
examples/guides/codegen-nhost/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { JSX } from "react";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function Profile(): JSX.Element {
|
||||
const { user, session } = useAuth();
|
||||
|
||||
// ProtectedRoute component now handles authentication check
|
||||
// We can just focus on the component logic here
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-3xl mb-6 gradient-text">Your Profile</h1>
|
||||
|
||||
<div className="glass-card p-8 mb-6">
|
||||
<div className="space-y-5">
|
||||
<div className="profile-item">
|
||||
<strong>Display Name:</strong>
|
||||
<span className="ml-2">{user?.displayName || "Not set"}</span>
|
||||
</div>
|
||||
|
||||
<div className="profile-item">
|
||||
<strong>Email:</strong>
|
||||
<span className="ml-2">{user?.email || "Not available"}</span>
|
||||
</div>
|
||||
|
||||
<div className="profile-item">
|
||||
<strong>User ID:</strong>
|
||||
<span
|
||||
className="ml-2"
|
||||
style={{
|
||||
fontFamily: "var(--font-geist-mono)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{user?.id || "Not available"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="profile-item">
|
||||
<strong>Roles:</strong>
|
||||
<span className="ml-2">{user?.roles?.join(", ") || "None"}</span>
|
||||
</div>
|
||||
|
||||
<div className="profile-item">
|
||||
<strong>Email Verified:</strong>
|
||||
<span className="ml-2">{user?.emailVerified ? "Yes" : "No"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card p-8 mb-6">
|
||||
<h3 className="text-xl mb-4">Session Information</h3>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
{
|
||||
refreshTokenId: session?.refreshTokenId,
|
||||
accessTokenExpiresIn: session?.accessTokenExpiresIn,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
examples/guides/codegen-nhost/src/pages/SignIn.tsx
Normal file
120
examples/guides/codegen-nhost/src/pages/SignIn.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import { type JSX, useEffect, useId, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function SignIn(): JSX.Element {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(
|
||||
params.get("error") || null,
|
||||
);
|
||||
|
||||
const isVerifying = params.has("fromVerify");
|
||||
|
||||
// Use useEffect for navigation after authentication is confirmed
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !isVerifying) {
|
||||
navigate("/home");
|
||||
}
|
||||
}, [isAuthenticated, isVerifying, navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use the signIn function from auth context
|
||||
const response = await nhost.auth.signInEmailPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
// Check if MFA is required
|
||||
if (response.body?.mfa) {
|
||||
navigate(`/signin/mfa?ticket=${response.body.mfa.ticket}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a session, sign in was successful
|
||||
if (response.body?.session) {
|
||||
navigate("/home");
|
||||
} else {
|
||||
setError("Failed to sign in");
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(`An error occurred during sign in: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h1 className="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
|
||||
|
||||
<div className="glass-card w-full p-8 mb-6">
|
||||
<h2 className="text-2xl mb-6">Sign In</h2>
|
||||
<div>
|
||||
<div className="tabs-container">
|
||||
<button type="button" className="tab-button tab-active">
|
||||
Email + Password
|
||||
</button>
|
||||
</div>
|
||||
<div className="tab-content">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor={emailId}>Email</label>
|
||||
<input
|
||||
id={emailId}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={passwordId}>Password</label>
|
||||
<input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Signing In..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p>
|
||||
Don't have an account? <Link to="/signup">Sign Up</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
examples/guides/codegen-nhost/src/pages/SignUp.tsx
Normal file
127
examples/guides/codegen-nhost/src/pages/SignUp.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { ErrorResponse } from "@nhost/nhost-js/auth";
|
||||
import type { FetchError } from "@nhost/nhost-js/fetch";
|
||||
import { type JSX, useId, useState } from "react";
|
||||
import { Link, Navigate, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/nhost/AuthProvider";
|
||||
|
||||
export default function SignUp(): JSX.Element {
|
||||
const { nhost, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const displayNameId = useId();
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [password, setPassword] = useState<string>("");
|
||||
const [displayName, setDisplayName] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// If already authenticated, redirect to profile
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/home" />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (
|
||||
e: React.FormEvent<HTMLFormElement>,
|
||||
): Promise<void> => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await nhost.auth.signUpEmailPassword({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
displayName,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.body) {
|
||||
// Successfully signed up and automatically signed in
|
||||
navigate("/home");
|
||||
} else {
|
||||
// Verification email sent
|
||||
navigate("/verify");
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as FetchError<ErrorResponse>;
|
||||
setError(`An error occurred during sign up: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h1 className="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
|
||||
|
||||
<div className="glass-card w-full p-8 mb-6">
|
||||
<h2 className="text-2xl mb-6">Sign Up</h2>
|
||||
|
||||
<div>
|
||||
<div className="tabs-container">
|
||||
<button type="button" className="tab-button tab-active">
|
||||
Email + Password
|
||||
</button>
|
||||
</div>
|
||||
<div className="tab-content">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor={displayNameId}>Display Name</label>
|
||||
<input
|
||||
id={displayNameId}
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={emailId}>Email</label>
|
||||
<input
|
||||
id={emailId}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={passwordId}>Password</label>
|
||||
<input
|
||||
id={passwordId}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs mt-1 text-gray-400">
|
||||
Password must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Signing Up..." : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p>
|
||||
Already have an account? <Link to="/signin">Sign In</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
examples/guides/codegen-nhost/src/vite-env.d.ts
vendored
Normal file
11
examples/guides/codegen-nhost/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_NHOST_REGION: string | undefined;
|
||||
readonly VITE_NHOST_SUBDOMAIN: string | undefined;
|
||||
readonly VITE_ENV: string | undefined;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
6
examples/guides/codegen-nhost/tsconfig.json
Normal file
6
examples/guides/codegen-nhost/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../build/configs/tsconfig/frontend.json",
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
4
examples/guides/codegen-nhost/tsconfig.node.json
Normal file
4
examples/guides/codegen-nhost/tsconfig.node.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../build/configs/tsconfig/vite.json"
|
||||
}
|
||||
7
examples/guides/codegen-nhost/vite.config.ts
Normal file
7
examples/guides/codegen-nhost/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
@@ -17,6 +17,8 @@ let
|
||||
"pnpm-lock.yaml"
|
||||
"${submodule}/package.json"
|
||||
"${submodule}/pnpm-lock.yaml"
|
||||
"${submodule}/codegen-nhost/package.json"
|
||||
"${submodule}/codegen-nhost/pnpm-lock.yaml"
|
||||
"${submodule}/react-apollo/package.json"
|
||||
"${submodule}/react-apollo/pnpm-lock.yaml"
|
||||
"${submodule}/react-query/package.json"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "demos/react-apollo",
|
||||
"name": "guides/react-apollo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "demos/react-query",
|
||||
"name": "guides/react-query",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "demos/react-urql",
|
||||
"name": "guides/react-urql",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
|
||||
Reference in New Issue
Block a user