Merge branch '@feature/kirimase' into develop
@@ -17,6 +17,7 @@ const config = {
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-non-null-asserted-optional-chain": ["off"],
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
|
||||
61
.idea/codeStyles/Project.xml
generated
@@ -1,61 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<HTMLCodeStyleSettings>
|
||||
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
||||
<option name="HTML_KEEP_WHITESPACES_INSIDE" value="" />
|
||||
</HTMLCodeStyleSettings>
|
||||
<JSCodeStyleSettings version="0">
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||
</JSCodeStyleSettings>
|
||||
<TypeScriptCodeStyleSettings version="0">
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||
</TypeScriptCodeStyleSettings>
|
||||
<VueCodeStyleSettings>
|
||||
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
|
||||
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
|
||||
</VueCodeStyleSettings>
|
||||
<codeStyleSettings language="HTML">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JavaScript">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="TypeScript">
|
||||
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Vue">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
12
.idea/dataSources.xml
generated
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="parentgrine@localhost" uuid="da0ccf76-47f7-499f-8f35-82c39250995f">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:5432/parentgrine</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/modules.xml
generated
@@ -2,7 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/parentgrine-server.iml" filepath="$PROJECT_DIR$/.idea/parentgrine-server.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/kidarr-server.iml" filepath="$PROJECT_DIR$/.idea/kidarr-server.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/prettier.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PrettierConfiguration">
|
||||
<option name="myConfigurationMode" value="AUTOMATIC" />
|
||||
<option name="myRunOnSave" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/vcs.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
14
.vscode/launch.json
vendored
@@ -1,14 +0,0 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "NODE_ENV=development next dev -p 3002 & local-ssl-proxy --config ./ssl-proxy.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
.vscode/settings.json
vendored
@@ -1,4 +1,26 @@
|
||||
{
|
||||
"cmake.configureOnOpen": false,
|
||||
"workbench.editor.labelFormat": "short"
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
"**/.hg": true,
|
||||
"**/CVS": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/Thumbs.db": true,
|
||||
".vscode": true,
|
||||
".next": true,
|
||||
"node_modules": true
|
||||
},
|
||||
"workbench.colorTheme": "poimandres-noitalics",
|
||||
"sqltools.connections": [
|
||||
{
|
||||
"previewLimit": 50,
|
||||
"server": "localhost",
|
||||
"port": 5432,
|
||||
"driver": "PostgreSQL",
|
||||
"name": "kidarr (local)",
|
||||
"database": "kidarr",
|
||||
"username": "postgres",
|
||||
"password": "hackme"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
70
README.md
@@ -1,64 +1,28 @@
|
||||
# Kidarr Server🚀
|
||||
# Create T3 App
|
||||
|
||||
Welcome to Kidarr, the ultimate child location tracking app that prioritises safety and peace of mind for parents!
|
||||
🌟
|
||||
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/kid-arr/kidarr-server/issues)
|
||||
[](https://github.com/kid-arr/kidarr-server/stargazers)
|
||||
[](https://github.com/kid-arr/kidarr-server/network)
|
||||
## What's next? How do I make an app with this?
|
||||
|
||||
## Overview
|
||||
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
|
||||
|
||||
Kidarr is a secure and user-friendly mobile application designed to help parents keep track of their children's
|
||||
whereabouts in real-time. With advanced location tracking features, intuitive UI, and robust security measures,
|
||||
Kidarr provides parents with the peace of mind they deserve.
|
||||
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
|
||||
|
||||
## Features
|
||||
- [Next.js](https://nextjs.org)
|
||||
- [NextAuth.js](https://next-auth.js.org)
|
||||
- [Prisma](https://prisma.io)
|
||||
- [Tailwind CSS](https://tailwindcss.com)
|
||||
- [tRPC](https://trpc.io)
|
||||
|
||||
- **Real-time Location Tracking**: Stay informed about your child's location at all times.
|
||||
## Learn More
|
||||
|
||||
- **Geofencing**: Set up safe zones and receive notifications when your child enters or leaves predefined areas.
|
||||
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
|
||||
|
||||
- **SOS Alert**: In case of an emergency, your child can send an SOS alert with their current location.
|
||||
- [Documentation](https://create.t3.gg/)
|
||||
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
|
||||
|
||||
- **History Tracking**: View a detailed history of your child's movement over time.
|
||||
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome!
|
||||
|
||||
- **Privacy and Security**: We prioritize the privacy and security of your data. All communication is encrypted, and
|
||||
location data is accessible only to authorized users.
|
||||
## How do I deploy this?
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- iOS or Android device for your child
|
||||
- Web, iOS or Android device for the parent
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository: `git clone https://github.com/kid-arr/kidarr-server.git`
|
||||
2. Follow the installation instructions in the [documentation](docs/INSTALL.md).
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open the Kidarr app on your child's device.
|
||||
2. Log in with your parent account.
|
||||
3. Enjoy peace of mind by tracking your child's location.
|
||||
|
||||
For more detailed instructions, check out our [User Guide](docs/UserGuide.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community! Whether you're a developer, designer, or tester, your input is valuable.
|
||||
Check out our [contribution guidelines](CONTRIBUTING.md) for more information.
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter any issues or have questions, feel free
|
||||
to [open an issue](https://github.com/kid-arr/kidarr-server/issues). We're here to help!
|
||||
|
||||
## License
|
||||
|
||||
Kidarr is licensed under the [MIT License](LICENSE).
|
||||
|
||||
Happy tracking! 🌍✨
|
||||
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { Config } from 'drizzle-kit';
|
||||
import { env } from '@/env';
|
||||
import type { Config } from "drizzle-kit";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
if (!env.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL is missing');
|
||||
}
|
||||
export default {
|
||||
// schema: './src/db',
|
||||
schema: './src/server/db/schema.ts',
|
||||
out: './drizzle',
|
||||
driver: 'pg',
|
||||
schema: "./src/server/db/schema",
|
||||
out: "./src/server/db/migrations",
|
||||
driver: "pg",
|
||||
dbCredentials: {
|
||||
connectionString: env.DATABASE_URL,
|
||||
},
|
||||
} satisfies Config;
|
||||
}
|
||||
} satisfies Config;
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "pg",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1705356382861,
|
||||
"tag": "0000_wide_gravity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
18
kirimase.config.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"hasSrc": true,
|
||||
"packages": [
|
||||
"trpc",
|
||||
"shadcn-ui",
|
||||
"drizzle",
|
||||
"next-auth"
|
||||
],
|
||||
"preferredPackageManager": "bun",
|
||||
"t3": true,
|
||||
"alias": "@",
|
||||
"rootPath": "src/",
|
||||
"componentLib": "shadcn-ui",
|
||||
"driver": "pg",
|
||||
"provider": "postgresjs",
|
||||
"orm": "drizzle",
|
||||
"auth": "next-auth"
|
||||
}
|
||||
80
package.json
@@ -1,19 +1,26 @@
|
||||
{
|
||||
"name": "kidarr",
|
||||
"version": "0.1.5",
|
||||
"name": "kidarr-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"db:push": "dotenv drizzle-kit push:postgres",
|
||||
"db:studio": "dotenv drizzle-kit studio",
|
||||
"_dev": "next dev",
|
||||
"dev": "NODE_ENV=development next dev -p 3002 & local-ssl-proxy --config ./ssl-proxy.json",
|
||||
"lint": "next lint",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"db:generate": "drizzle-kit generate:pg",
|
||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
||||
"db:seed": "tsx src/server/db/seed/seed.ts && tsx src/server/db/seed/auth.ts",
|
||||
"db:drop": "drizzle-kit drop",
|
||||
"db:pull": "drizzle-kit introspect:pg",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:check": "drizzle-kit check:pg"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^0.3.14",
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@auth/core": "^0.22.0",
|
||||
"@auth/drizzle-adapter": "^0.3.16",
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
@@ -44,26 +51,26 @@
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@t3-oss/env-nextjs": "^0.7.3",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@trpc/client": "^10.45.0",
|
||||
"@trpc/next": "^10.45.0",
|
||||
"@trpc/react-query": "^10.45.0",
|
||||
"@trpc/server": "^10.45.0",
|
||||
"@vercel/analytics": "^1.1.1",
|
||||
"@trpc/client": "^10.43.6",
|
||||
"@trpc/next": "^10.43.6",
|
||||
"@trpc/react-query": "^10.43.6",
|
||||
"@trpc/server": "^10.43.6",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/react-leaflet": "^3.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"date-fns": "^3.3.1",
|
||||
"drizzle-orm": "^0.29.3",
|
||||
"embla-carousel-react": "^8.0.0-rc19",
|
||||
"generate-api-key": "^1.0.2",
|
||||
"drizzle-zod": "^0.5.1",
|
||||
"embla-carousel-react": "^8.0.0-rc20",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"local-ssl-proxy": "^2.0.5",
|
||||
"lucide-react": "^0.309.0",
|
||||
"lucide-react": "^0.314.0",
|
||||
"next": "^14.0.4",
|
||||
"next-auth": "^4.24.5",
|
||||
"next-themes": "^0.2.1",
|
||||
"pg": "^8.11.3",
|
||||
"postgres": "^3.4.3",
|
||||
"react": "18.2.0",
|
||||
"react-day-picker": "^8.10.0",
|
||||
@@ -79,29 +86,30 @@
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.8.0",
|
||||
"vaul": "^0.8.9",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@next/eslint-plugin-next": "^14.0.4",
|
||||
"@types/eslint": "^8.56.2",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
||||
"@typescript-eslint/parser": "^6.18.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"drizzle-kit": "^0.20.12",
|
||||
"eslint": "^8.56.0",
|
||||
"postcss": "^8.4.33",
|
||||
"prettier": "^3.2.2",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
"@types/eslint": "^8.44.7",
|
||||
"@types/node": "^18.17.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.11.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"dotenv": "^16.3.2",
|
||||
"drizzle-kit": "^0.20.13",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-config-next": "^14.0.4",
|
||||
"pg": "^8.11.3",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^3.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.7",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.25.0"
|
||||
"initVersion": "7.25.2"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 332 B |
|
Before Width: | Height: | Size: 737 B |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "Kidarr - radar for your kids",
|
||||
"short_name": "kidarr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
1
scripts/queries.sql
Normal file
@@ -0,0 +1 @@
|
||||
select * From pings
|
||||
@@ -11,9 +11,8 @@ dropdb -f --if-exists kidarr
|
||||
echo "Creating db"
|
||||
createdb kidarr
|
||||
|
||||
bunx drizzle-kit generate:pg --config=./drizzle.config.ts
|
||||
bunx drizzle-kit push:pg --config=./drizzle.config.ts
|
||||
bun db:generate
|
||||
bun db:migrate
|
||||
|
||||
# bun run src/db/migrate.ts
|
||||
bun run ./src/server/db/scripts/seed.ts
|
||||
bun run ./src/server/db/scripts/auth.ts
|
||||
bun db:seed
|
||||
|
||||
19
src/app/(app)/children/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import ChildList from "@/components/children/child-list";
|
||||
import NewChildModal from "@/components/children/child-modal";
|
||||
import { api } from "@/trpc/server";
|
||||
import { checkAuth } from "@/lib/auth/utils";
|
||||
|
||||
export default async function Children() {
|
||||
await checkAuth();
|
||||
const { children } = await api.children.getChildren.query();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex justify-between">
|
||||
<h1 className="my-2 text-2xl font-semibold">Here are your children.</h1>
|
||||
<NewChildModal />
|
||||
</div>
|
||||
<ChildList children={children} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
10
src/app/(app)/dashboard/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import DashboardPage from "@/components/pages/dashboard-page";
|
||||
import { checkAuth } from "@/lib/auth/utils";
|
||||
import React from "react";
|
||||
|
||||
const Dashboard = async () => {
|
||||
await checkAuth();
|
||||
return <DashboardPage />;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
19
src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { SiteHeader } from '@/components/header/site-header';
|
||||
import { SocketProvider } from '@/lib/services/realtime/socket-provider';
|
||||
|
||||
type DashboardLayoutProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
const AppLayout = async ({ children }: DashboardLayoutProps) => {
|
||||
return (
|
||||
<SocketProvider>
|
||||
<div className='flex min-h-screen flex-col space-y-6'>
|
||||
<SiteHeader />
|
||||
<div className='mx-6'>{children}</div>
|
||||
</div>
|
||||
</SocketProvider>
|
||||
);
|
||||
|
||||
};
|
||||
export default AppLayout;
|
||||
@@ -1,18 +0,0 @@
|
||||
import { SecureDebugDetails } from "@/components/debug/SecureDebugDetails";
|
||||
import React from "react";
|
||||
import HeadersPrinter from "@/components/debug/HeadersPrinter";
|
||||
|
||||
const DebugPage = async () => {
|
||||
return (
|
||||
<>
|
||||
<h1 className="hidden text-center text-3xl font-bold leading-tight tracking-tighter md:block md:text-6xl lg:leading-[1.1]">
|
||||
This is what we know
|
||||
</h1>
|
||||
<div className="flex flex-col space-y-2 px-2">
|
||||
<HeadersPrinter />
|
||||
<SecureDebugDetails />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default DebugPage;
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
import ChildrenList from '@/components/children/children-list';
|
||||
import { api } from '@/trpc/server';
|
||||
|
||||
const ChildrenPage = async () => {
|
||||
const kids = await api.child.mine.query();
|
||||
return <ChildrenList kids={kids} />;
|
||||
};
|
||||
|
||||
export default ChildrenPage;
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import { api } from '@/trpc/server';
|
||||
import ChildrenFilter from '@/components/children/children-filter';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { MapViewTypeSelector } from '@/components/maps/map-viewtype-selector';
|
||||
|
||||
const Dashboard = async () => {
|
||||
const kids = await api.child.mine.query();
|
||||
const Map = dynamic(() => import('@/components/maps/main-map'), {
|
||||
ssr: false,
|
||||
});
|
||||
return <div>
|
||||
<div className="flex flex-row justify-between">
|
||||
<ChildrenFilter kids={kids} />
|
||||
<MapViewTypeSelector />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Map kids={kids} />
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { SiteHeader } from '@/components/header/site-header';
|
||||
import React from 'react';
|
||||
import { SocketProvider } from '@/lib/services/realtime/socket-provider';
|
||||
|
||||
type DashboardLayoutProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
const DashboardLayout = async ({ children }: DashboardLayoutProps) => {
|
||||
return (
|
||||
<SocketProvider>
|
||||
<div className='flex min-h-screen flex-col space-y-6'>
|
||||
<SiteHeader />
|
||||
<div className='mx-6'>{children}</div>
|
||||
</div>
|
||||
</SocketProvider>
|
||||
);
|
||||
|
||||
};
|
||||
export default DashboardLayout;
|
||||
45
src/app/account/AccountCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface AccountCardProps {
|
||||
params: {
|
||||
header: string;
|
||||
description: string;
|
||||
price?: number;
|
||||
};
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AccountCard({ params, children }: AccountCardProps) {
|
||||
const { header, description } = params;
|
||||
return (
|
||||
<Card>
|
||||
<div id="body" className="p-4 ">
|
||||
<h3 className="text-xl font-semibold">{header}</h3>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountCardBody({ children }: { children: React.ReactNode }) {
|
||||
return <div className="p-4">{children}</div>;
|
||||
}
|
||||
|
||||
export function AccountCardFooter({
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="bg-muted p-4 border dark:bg-card flex justify-between items-center rounded-b-lg"
|
||||
id="footer"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/app/account/UpdateEmailCard.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { AccountCard, AccountCardFooter, AccountCardBody } from "./AccountCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function UpdateEmailCard({ email }: { email: string }) {
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
const target = event.target as HTMLFormElement;
|
||||
const form = new FormData(target);
|
||||
const { email } = Object.fromEntries(form.entries()) as { email: string };
|
||||
if (email.length < 3) {
|
||||
toast({
|
||||
description: "Email must be longer than 3 characters.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/account", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ email }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (res.status === 200)
|
||||
toast({ description: "Successfully updated email!" });
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AccountCard
|
||||
params={{
|
||||
header: "Your Email",
|
||||
description:
|
||||
"Please enter the email address you want to use with your account.",
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<AccountCardBody>
|
||||
<Input defaultValue={email ?? ""} name="email" disabled={true} />
|
||||
</AccountCardBody>
|
||||
<AccountCardFooter description="We will email vou to verify the change.">
|
||||
<Button disabled={true}>Update Email</Button>
|
||||
</AccountCardFooter>
|
||||
</form>
|
||||
</AccountCard>
|
||||
);
|
||||
}
|
||||
56
src/app/account/UpdateNameCard.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { AccountCard, AccountCardFooter, AccountCardBody } from "./AccountCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function UpdateNameCard({ name }: { name: string }) {
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const handleSubmit = async (event: React.SyntheticEvent) => {
|
||||
event.preventDefault();
|
||||
const target = event.target as HTMLFormElement;
|
||||
const form = new FormData(target);
|
||||
const { name } = Object.fromEntries(form.entries()) as { name: string };
|
||||
if (name.length < 3) {
|
||||
toast({
|
||||
description: "Name must be longer than 3 characters.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const res = await fetch("/api/account", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (res.status === 200)
|
||||
toast({ description: "Successfully updated name!" });
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AccountCard
|
||||
params={{
|
||||
header: "Your Name",
|
||||
description:
|
||||
"Please enter your full name, or a display name you are comfortable with.",
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<AccountCardBody>
|
||||
<Input defaultValue={name ?? ""} name="name" disabled={true} />
|
||||
</AccountCardBody>
|
||||
<AccountCardFooter description="64 characters maximum">
|
||||
<Button disabled={true}>Update Name</Button>
|
||||
</AccountCardFooter>
|
||||
</form>
|
||||
</AccountCard>
|
||||
);
|
||||
}
|
||||
17
src/app/account/UserSettings.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
import UpdateNameCard from "./UpdateNameCard";
|
||||
import UpdateEmailCard from "./UpdateEmailCard";
|
||||
import { AuthSession } from "@/lib/auth/utils";
|
||||
|
||||
export default function UserSettings({
|
||||
session,
|
||||
}: {
|
||||
session: AuthSession["session"];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<UpdateNameCard name={session?.user.name ?? ""} />
|
||||
<UpdateEmailCard email={session?.user.email ?? ""} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/app/account/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import UserSettings from "./UserSettings";
|
||||
import { checkAuth, getUserAuth } from "@/lib/auth/utils";
|
||||
|
||||
export default async function Account() {
|
||||
await checkAuth();
|
||||
const { session } = await getUserAuth();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1 className="text-2xl font-semibold my-4">Account</h1>
|
||||
<div className="space-y-4">
|
||||
<UserSettings session={session} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
15
src/app/api/account/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
import { db } from "@/server/db/index";
|
||||
import { users } from "@/server/db/schema/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const { session } = await getUserAuth();
|
||||
if (!session) return new Response("Error", { status: 400 });
|
||||
const body = (await request.json()) as { name?: string; email?: string };
|
||||
|
||||
await db.update(users).set({ ...body }).where(eq(users.id, session.user.id));
|
||||
revalidatePath("/account");
|
||||
return new Response(JSON.stringify({ message: "ok" }), { status: 200 });
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { DefaultSession } from "next-auth";
|
||||
import NextAuth from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth/utils";
|
||||
|
||||
import { authOptions } from "@/server/auth";
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const handler = NextAuth(authOptions);
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
console.log("route", "register", body);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { db } from "@/server/db";
|
||||
import { child } from "@/server/db/schema";
|
||||
import { StatusCodes, getReasonPhrase } from "http-status-codes";
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getServerAuthSession } from "@/server/auth";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await getServerAuthSession();
|
||||
if (!session || !session.user)
|
||||
return NextResponse.json(
|
||||
{ error: getReasonPhrase(StatusCodes.UNAUTHORIZED) },
|
||||
{ status: StatusCodes.UNAUTHORIZED },
|
||||
);
|
||||
const activeChildren = await db.query.child.findMany({
|
||||
where: eq(child.parentId, session.user.id),
|
||||
with: {
|
||||
devices: {
|
||||
with: { pings: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(activeChildren);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { db } from '@/server/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { child, device } from '@/server/db/schema';
|
||||
import { createApiKey } from '@/lib/services/auth/api';
|
||||
import { badRequest } from '@/app/api/responses';
|
||||
|
||||
type DeviceConnectRequest = {
|
||||
deviceId: string;
|
||||
childId: string;
|
||||
deviceName: string;
|
||||
}
|
||||
const POST = async (req: Request, res: Response) => {
|
||||
if (req.method === 'POST') {
|
||||
const { deviceId, childId, deviceName } = await req.json() as DeviceConnectRequest;
|
||||
console.log('route', 'childId', childId);
|
||||
console.log('route', 'deviceId', deviceId);
|
||||
|
||||
if (!childId || !deviceId) {
|
||||
return badRequest('Invalid registration request');
|
||||
}
|
||||
|
||||
const childToRegister = (
|
||||
await db.selectDistinct().from(child).where(eq(child.id, childId))
|
||||
)[0];
|
||||
|
||||
if (!childToRegister) {
|
||||
return badRequest('Invalid registration request');
|
||||
}
|
||||
|
||||
let done = false;
|
||||
let pin = 2021;
|
||||
while (!done) {
|
||||
pin = Math.floor(1000 + Math.random() * 9000);
|
||||
console.log('route', 'device/connect', 'checking for PIN', pin);
|
||||
const exists = await db
|
||||
.selectDistinct()
|
||||
.from(device)
|
||||
.where(eq(device.pin, pin));
|
||||
console.log('route', 'exists', exists);
|
||||
done = exists.length === 0;
|
||||
}
|
||||
|
||||
const apiKey = createApiKey();
|
||||
await db
|
||||
.insert(device)
|
||||
.values({
|
||||
childId: childToRegister.id,
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
pin,
|
||||
apiKey: apiKey,
|
||||
})
|
||||
.execute();
|
||||
return Response.json(
|
||||
{ childId, deviceId, deviceName, pin, apiKey },
|
||||
{ status: StatusCodes.CREATED },
|
||||
);
|
||||
}
|
||||
return badRequest('Invalid registration request');
|
||||
};
|
||||
|
||||
export { POST };
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
|
||||
export const notAuthorised = () =>
|
||||
new Response('Not authorised', {
|
||||
status: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
export const badRequest = (message: string) =>
|
||||
new Response(message, {
|
||||
status: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
12
src/app/debug/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
|
||||
const DebugPage = () => {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Button variant="default">I am button</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugPage;
|
||||
19
src/app/devices/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import DeviceList from "@/components/devices/DeviceList";
|
||||
import NewDeviceModal from "@/components/devices/DeviceModal";
|
||||
import { api } from "@/trpc/server";
|
||||
import { checkAuth } from "@/lib/auth/utils";
|
||||
|
||||
export default async function Devices() {
|
||||
await checkAuth();
|
||||
const { devices } = await api.devices.getDevices.query();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex justify-between">
|
||||
<h1 className="font-semibold text-2xl my-2">Devices</h1>
|
||||
<NewDeviceModal />
|
||||
</div>
|
||||
<DeviceList devices={devices} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
import "@/styles/globals.css";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { ABeeZee as TheFont } from "next/font/google";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import { TRPCReactProvider } from "@/trpc/react";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { type Metadata } from "next";
|
||||
import NextAuthProvider from "@/lib/services/auth/provider";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import NextAuthProvider from "@/lib/auth/Provider";
|
||||
|
||||
const inter = TheFont({
|
||||
weight: "400",
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
export const metadata = {
|
||||
title: "Kidarr",
|
||||
description: "Radar for your kids",
|
||||
manifest: "/site.webmanifest",
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
description: "Radarr for your kids",
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -29,16 +24,20 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning={true}>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`font-sans ${inter.variable}`}>
|
||||
<NextAuthProvider>
|
||||
<TRPCReactProvider cookies={cookies().toString()}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
<Analytics />
|
||||
</ThemeProvider>
|
||||
</TRPCReactProvider>
|
||||
</NextAuthProvider>
|
||||
<TRPCReactProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<NextAuthProvider>{children}</NextAuthProvider>
|
||||
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</TRPCReactProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
25
src/app/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="grid place-items-center animate-pulse text-neutral-300 p-4">
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 text-neutral-200 dark:text-neutral-600 fill-neutral-600 animate-spin"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getServerAuthSession } from '@/server/auth';
|
||||
import HomePage from '@/components/pages/home-page';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import HomePage from "@/components/pages/home-page";
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Home() {
|
||||
const session = await getServerAuthSession();
|
||||
|
||||
if (session?.user) {
|
||||
redirect('/dashboard');
|
||||
const { session } = await getUserAuth();
|
||||
if (session) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
return <HomePage />;
|
||||
|
||||
return (
|
||||
<main className="space-y-4">
|
||||
<HomePage />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
106
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
export default function Page() {
|
||||
const { setTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
<div className="space-y-4 my-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Appearance</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize the appearance of the app. Automatically switch between
|
||||
day and night themes.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
variant={"ghost"}
|
||||
className="w-fit h-fit"
|
||||
onClick={() => setTheme("light")}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
|
||||
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
||||
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
Light
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant={"ghost"}
|
||||
onClick={() => setTheme("dark")}
|
||||
className="w-fit h-fit"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
|
||||
<div className="space-y-2 rounded-sm bg-neutral-950 p-2">
|
||||
<div className="space-y-2 rounded-md bg-neutral-800 p-2 shadow-sm">
|
||||
<div className="h-2 w-[80px] rounded-lg bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-neutral-800 p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-neutral-800 p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
Dark
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant={"ghost"}
|
||||
onClick={() => setTheme("system")}
|
||||
className="w-fit h-fit"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
|
||||
<div className="space-y-2 rounded-sm bg-neutral-300 p-2">
|
||||
<div className="space-y-2 rounded-md bg-neutral-600 p-2 shadow-sm">
|
||||
<div className="h-2 w-[80px] rounded-lg bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-neutral-600 p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md bg-neutral-600 p-2 shadow-sm">
|
||||
<div className="h-4 w-4 rounded-full bg-neutral-400" />
|
||||
<div className="h-2 w-[100px] rounded-lg bg-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
System
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { AlignRight } from "lucide-react";
|
||||
import { defaultLinks } from "@/config/nav";
|
||||
|
||||
export default function Navbar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="md:hidden border-b mb-4 pb-2 w-full">
|
||||
<nav className="flex justify-between w-full items-center">
|
||||
<div className="font-semibold text-lg">Logo</div>
|
||||
<Button variant="ghost" onClick={() => setOpen(!open)}>
|
||||
<AlignRight />
|
||||
</Button>
|
||||
</nav>
|
||||
{open ? (
|
||||
<div className="my-4 p-4 bg-muted">
|
||||
<ul className="space-y-2">
|
||||
{defaultLinks.map((link) => (
|
||||
<li key={link.title} onClick={() => setOpen(false)} className="">
|
||||
<Link
|
||||
href={link.href}
|
||||
className={
|
||||
pathname === link.href
|
||||
? "text-primary hover:text-primary font-semibold"
|
||||
: "text-muted-foreground hover:text-primary"
|
||||
}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import SidebarItems from "./SidebarItems";
|
||||
import { Avatar, AvatarFallback } from "./ui/avatar";
|
||||
|
||||
import { AuthSession, getUserAuth } from "@/lib/auth/utils";
|
||||
|
||||
const Sidebar = async () => {
|
||||
const session = await getUserAuth();
|
||||
if (session.session === null) return null;
|
||||
|
||||
return (
|
||||
<aside className="h-screen min-w-52 bg-muted hidden md:block p-4 pt-8 border-r border-border shadow-inner">
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold ml-4">Logo</h3>
|
||||
<SidebarItems />
|
||||
</div>
|
||||
<UserDetails session={session} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
const UserDetails = ({ session }: { session: AuthSession }) => {
|
||||
if (session.session === null) return null;
|
||||
const { user } = session.session;
|
||||
|
||||
if (!user?.name || user.name.length == 0) return null;
|
||||
|
||||
return (
|
||||
<Link href="/account">
|
||||
<div className="flex items-center justify-between w-full border-t border-border pt-4 px-2">
|
||||
<div className="text-muted-foreground">
|
||||
<p className="text-xs">{user.name ?? "John Doe"}</p>
|
||||
<p className="text-xs font-light pr-4">
|
||||
{user.email ?? "john@doe.com"}
|
||||
</p>
|
||||
</div>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="border-border border-2 text-muted-foreground">
|
||||
{user.name
|
||||
? user.name
|
||||
?.split(" ")
|
||||
.map((word) => word[0].toUpperCase())
|
||||
.join("")
|
||||
: "~"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
90
src/components/SidebarItems.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { defaultLinks, additionalLinks } from "@/config/nav";
|
||||
|
||||
export interface SidebarLink {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const SidebarItems = () => {
|
||||
return (
|
||||
<>
|
||||
<SidebarLinkGroup links={defaultLinks} />
|
||||
{additionalLinks.length > 0
|
||||
? additionalLinks.map((l) => (
|
||||
<SidebarLinkGroup
|
||||
links={l.links}
|
||||
title={l.title}
|
||||
border
|
||||
key={l.title}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default SidebarItems;
|
||||
|
||||
const SidebarLinkGroup = ({
|
||||
links,
|
||||
title,
|
||||
border,
|
||||
}: {
|
||||
links: SidebarLink[];
|
||||
title?: string;
|
||||
border?: boolean;
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className={border ? "border-border border-t my-8 pt-4" : ""}>
|
||||
{title ? (
|
||||
<h4 className="px-2 mb-2 text-xs uppercase text-muted-foreground tracking-wider">
|
||||
{title}
|
||||
</h4>
|
||||
) : null}
|
||||
<ul>
|
||||
{links.map((link) => (
|
||||
<li key={link.title}>
|
||||
<SidebarLink link={link} active={pathname === link.href} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const SidebarLink = ({
|
||||
link,
|
||||
active,
|
||||
}: {
|
||||
link: SidebarLink;
|
||||
active: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Link
|
||||
href={link.href}
|
||||
className={`group transition-colors p-2 inline-block hover:bg-popover hover:text-primary text-muted-foreground text-xs hover:shadow rounded-md w-full${
|
||||
active ? " text-primary font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"opacity-0 left-0 h-6 w-[4px] absolute rounded-r-lg bg-primary",
|
||||
active ? "opacity-100" : "",
|
||||
)}
|
||||
/>
|
||||
<link.icon className="h-3.5 mr-1" />
|
||||
<span>{link.title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
9
src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
168
src/components/children/child-form.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type Child,
|
||||
type NewChildParams,
|
||||
insertChildParams,
|
||||
} from "@/server/db/schema/children";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api as trpc } from "@/trpc/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type z } from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const ChildForm = ({
|
||||
child,
|
||||
closeModal,
|
||||
}: {
|
||||
child?: Child;
|
||||
closeModal?: () => void;
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const editing = !!child?.id;
|
||||
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const form = useForm<z.infer<typeof insertChildParams>>({
|
||||
// latest Zod release has introduced a TS error with zodResolver
|
||||
// open issue: https://github.com/colinhacks/zod/issues/2663
|
||||
// errors locally but not in production
|
||||
resolver: zodResolver(insertChildParams),
|
||||
defaultValues: child ?? {
|
||||
name: "",
|
||||
email: "",
|
||||
avatar: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuccess = async (
|
||||
action: "create" | "update" | "delete",
|
||||
data?: { error?: string },
|
||||
) => {
|
||||
if (data?.error) {
|
||||
toast({
|
||||
title: `${action
|
||||
.slice(0, 1)
|
||||
.toUpperCase()
|
||||
.concat(action.slice(1))} Failed`,
|
||||
description: data.error,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await utils.children.getChildren.invalidate();
|
||||
router.refresh();
|
||||
if (closeModal) closeModal();
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `Child ${action}d!`,
|
||||
variant: "default",
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: createChild, isLoading: isCreating } =
|
||||
trpc.children.createChild.useMutation({
|
||||
onSuccess: (res) => onSuccess("create"),
|
||||
});
|
||||
|
||||
const { mutate: updateChild, isLoading: isUpdating } =
|
||||
trpc.children.updateChild.useMutation({
|
||||
onSuccess: (res) => onSuccess("update"),
|
||||
});
|
||||
|
||||
const { mutate: deleteChild, isLoading: isDeleting } =
|
||||
trpc.children.deleteChild.useMutation({
|
||||
onSuccess: (res) => onSuccess("delete"),
|
||||
});
|
||||
|
||||
const handleSubmit = (values: NewChildParams) => {
|
||||
if (editing) {
|
||||
updateChild({ ...values, id: child.id });
|
||||
} else {
|
||||
createChild(values);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className={"space-y-8"}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="avatar"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Avatar</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="mr-1"
|
||||
disabled={isCreating || isUpdating}
|
||||
>
|
||||
{editing
|
||||
? `Sav${isUpdating ? "ing..." : "e"}`
|
||||
: `Creat${isCreating ? "ing..." : "e"}`}
|
||||
</Button>
|
||||
{editing ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={"destructive"}
|
||||
onClick={() => deleteChild({ id: child.id })}
|
||||
>
|
||||
Delet{isDeleting ? "ing..." : "e"}
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChildForm;
|
||||
73
src/components/children/child-list.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { type CompleteChild } from "@/server/db/schema/children";
|
||||
import { api as trpc } from "@/trpc/react";
|
||||
import ChildModal from "./child-modal";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Icons } from "@/components/icons";
|
||||
import ConnectDeviceDialog from "@/components/children/connect-device-dialog";
|
||||
|
||||
export default function ChildList({ children }: { children: CompleteChild[] }) {
|
||||
const { data: c } = trpc.children.getChildren.useQuery(undefined, {
|
||||
initialData: { children },
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
if (c.children.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="">Name</TableHead>
|
||||
<TableHead>Last seen at</TableHead>
|
||||
<TableHead className="">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{c.children?.map((kid) => <Child child={kid} key={kid.id} />)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
const Child = ({ child }: { child: CompleteChild }) => {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{child.name}</TableCell>
|
||||
<TableCell>Douglas</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="space-x-1">
|
||||
<ConnectDeviceDialog child={child} />
|
||||
<ChildModal child={child} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState = () => {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h3 className="mt-2 text-sm font-semibold text-secondary-foreground">
|
||||
No children
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Get started by creating a new child.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<ChildModal emptyState={true} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
src/components/children/child-modal.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import ChildForm from "./child-form";
|
||||
import { type Child } from "@/server/db/schema/children";
|
||||
import { Icons } from "../icons";
|
||||
|
||||
export default function ChildModal({
|
||||
child,
|
||||
emptyState,
|
||||
}: {
|
||||
child?: Child;
|
||||
emptyState?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeModal = () => setOpen(false);
|
||||
const editing = !!child?.id;
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
{emptyState ? (
|
||||
<Button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-1"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
New Child
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={editing ? "default" : "default"}
|
||||
size={editing ? "sm" : "sm"}
|
||||
>
|
||||
{editing ? (
|
||||
<Icons.edit className="h-4 w-4" />
|
||||
) : (
|
||||
<Icons.add className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="px-5 pt-5">
|
||||
<DialogTitle>{editing ? "Edit" : "Create"} Child</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-5 pb-5">
|
||||
<ChildForm closeModal={closeModal} child={child} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
"use client";
|
||||
import React from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type ChildModel from '@/lib/models/child';
|
||||
} from "@/components/ui/select";
|
||||
import { type Child } from "@/server/db/schema/children";
|
||||
|
||||
type ChildSelectListProps = {
|
||||
kids: ChildModel[];
|
||||
}
|
||||
const ChildSelectList: React.FC<ChildSelectListProps> = ({ kids }) => {
|
||||
children: Child[];
|
||||
};
|
||||
const ChildSelectList: React.FC<ChildSelectListProps> = ({ children: kids }) => {
|
||||
return (
|
||||
<Select defaultValue={'____all____'}>
|
||||
<Select defaultValue={"____all____"}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Choose child" />
|
||||
</SelectTrigger>
|
||||
@@ -23,10 +23,7 @@ const ChildSelectList: React.FC<ChildSelectListProps> = ({ kids }) => {
|
||||
<SelectGroup>
|
||||
<SelectItem value="____all____">(All Children)</SelectItem>
|
||||
{kids?.map((r) => (
|
||||
<SelectItem
|
||||
key={r.id}
|
||||
value={r.id}
|
||||
>
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import React from 'react';
|
||||
import ChildSelectList from './child-select-list';
|
||||
"use client";
|
||||
import React, { useEffect } from "react";
|
||||
import ChildSelectList from "./child-select-list";
|
||||
|
||||
import AddChildComponent from './add-child-component';
|
||||
import type ChildModel from '@/lib/models/child';
|
||||
import { type Child } from "@/server/db/schema/children";
|
||||
|
||||
type ChildrenFilterProps = {
|
||||
kids: ChildModel[];
|
||||
}
|
||||
const ChildrenFilter: React.FC<ChildrenFilterProps> = async ({ kids }) => {
|
||||
children: Child[];
|
||||
};
|
||||
const ChildrenFilter: React.FC<ChildrenFilterProps> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
console.log("ChildrenFilter: useEffect", children);
|
||||
}, [children]);
|
||||
return (
|
||||
<div className="flex flex-row space-x-2 justify-center items-center">
|
||||
<ChildSelectList kids={kids} />
|
||||
<AddChildComponent />
|
||||
<div className="flex flex-row items-center justify-center space-x-2">
|
||||
<ChildSelectList children={children} />
|
||||
{/* <AddChildComponent /> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -8,21 +8,21 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import QRCode from 'react-qr-code';
|
||||
import type ChildModel from '@/lib/models/child';
|
||||
import { Icons } from "@/components/icons";
|
||||
import QRCode from "react-qr-code";
|
||||
import { type Child } from "@/server/db/schema/children";
|
||||
|
||||
type ConnectDeviceDialogProps = {
|
||||
child: ChildModel;
|
||||
child: Child;
|
||||
};
|
||||
|
||||
const ConnectDeviceDialog: React.FC<ConnectDeviceDialogProps> = ({ child }) => {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button size={"sm"}>
|
||||
<Icons.connect className="mr-2 h-4 w-4" /> Connect device
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -34,10 +34,10 @@ const ConnectDeviceDialog: React.FC<ConnectDeviceDialogProps> = ({ child }) => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="bg-slate-300 ">
|
||||
<div className="p-4 border-8 border-slate-200">
|
||||
<div className="border-8 border-slate-200 p-4">
|
||||
<QRCode
|
||||
size={190}
|
||||
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
|
||||
style={{ height: "auto", maxWidth: "100%", width: "100%" }}
|
||||
value={child.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
import React from "react";
|
||||
import { headers } from "next/headers";
|
||||
import { getRequestHeaders } from "@/lib/helpers/headers";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
const HeadersPrinter = () => {
|
||||
const header = headers();
|
||||
|
||||
const request = getRequestHeaders(header);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>Request Headers</CardHeader>
|
||||
<CardContent>{request}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default HeadersPrinter;
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from "react";
|
||||
import PrintEnv from "@/components/widgets/print-env";
|
||||
import { authOptions } from "@/server/auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export const SecureDebugDetails = async () => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const request = headers();
|
||||
return <PrintEnv session={session} request={request} />;
|
||||
};
|
||||
175
src/components/devices/DeviceForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { Device, NewDeviceParams, insertDeviceParams } from "@/server/db/schema/devices";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api as trpc } from "@/trpc/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { z } from "zod";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const DeviceForm = ({
|
||||
device,
|
||||
closeModal,
|
||||
}: {
|
||||
device?: Device;
|
||||
closeModal?: () => void;
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const { data: children } = trpc.children.getChildren.useQuery();
|
||||
const editing = !!device?.id;
|
||||
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const form = useForm<z.infer<typeof insertDeviceParams>>({
|
||||
// latest Zod release has introduced a TS error with zodResolver
|
||||
// open issue: https://github.com/colinhacks/zod/issues/2663
|
||||
// errors locally but not in production
|
||||
resolver: zodResolver(insertDeviceParams),
|
||||
defaultValues: device ?? {
|
||||
name: "",
|
||||
deviceId: "",
|
||||
childId: ""
|
||||
},
|
||||
});
|
||||
|
||||
const onSuccess = async (action: "create" | "update" | "delete",
|
||||
data?: { error?: string },
|
||||
) => {
|
||||
if (data?.error) {
|
||||
toast({
|
||||
title: `${action
|
||||
.slice(0, 1)
|
||||
.toUpperCase()
|
||||
.concat(action.slice(1))} Failed`,
|
||||
description: data.error,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await utils.devices.getDevices.invalidate();
|
||||
router.refresh();
|
||||
if (closeModal) closeModal();
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Device ${action}d!`,
|
||||
variant: "default",
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: createDevice, isLoading: isCreating } =
|
||||
trpc.devices.createDevice.useMutation({
|
||||
onSuccess: (res) => onSuccess("create"),
|
||||
});
|
||||
|
||||
const { mutate: updateDevice, isLoading: isUpdating } =
|
||||
trpc.devices.updateDevice.useMutation({
|
||||
onSuccess: (res) => onSuccess("update"),
|
||||
});
|
||||
|
||||
const { mutate: deleteDevice, isLoading: isDeleting } =
|
||||
trpc.devices.deleteDevice.useMutation({
|
||||
onSuccess: (res) => onSuccess("delete"),
|
||||
});
|
||||
|
||||
const handleSubmit = (values: NewDeviceParams) => {
|
||||
if (editing) {
|
||||
updateDevice({ ...values, id: device.id });
|
||||
} else {
|
||||
createDevice(values);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className={"space-y-8"}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="deviceId"
|
||||
render={({ field }) => (<FormItem>
|
||||
<FormLabel>Device Id</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="childId"
|
||||
render={({ field }) => (<FormItem>
|
||||
<FormLabel>Child Id</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={String(field.value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a child" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{children?.children.map((child) => (
|
||||
<SelectItem key={child.id} value={child.id.toString()}>
|
||||
{child.id} {/* TODO: Replace with a field from the child model */}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="mr-1"
|
||||
disabled={isCreating || isUpdating}
|
||||
>
|
||||
{editing
|
||||
? `Sav${isUpdating ? "ing..." : "e"}`
|
||||
: `Creat${isCreating ? "ing..." : "e"}`}
|
||||
</Button>
|
||||
{editing ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={"destructive"}
|
||||
onClick={() => deleteDevice({ id: device.id })}
|
||||
>
|
||||
Delet{isDeleting ? "ing..." : "e"}
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceForm;
|
||||
52
src/components/devices/DeviceList.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
import { CompleteDevice } from "@/server/db/schema/devices";
|
||||
import { api as trpc } from "@/trpc/react";
|
||||
import DeviceModal from "./DeviceModal";
|
||||
|
||||
|
||||
export default function DeviceList({ devices }: { devices: CompleteDevice[] }) {
|
||||
const { data: d } = trpc.devices.getDevices.useQuery(undefined, {
|
||||
initialData: { devices },
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
if (d.devices.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{d.devices.map((device) => (
|
||||
<Device device={device} key={device.device.id} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const Device = ({ device }: { device: CompleteDevice }) => {
|
||||
return (
|
||||
<li className="flex justify-between my-2">
|
||||
<div className="w-full">
|
||||
<div>{device.device.name}</div>
|
||||
</div>
|
||||
<DeviceModal device={device.device} />
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState = () => {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h3 className="mt-2 text-sm font-semibold text-secondary-foreground">
|
||||
No devices
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Get started by creating a new device.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<DeviceModal emptyState={true} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
65
src/components/devices/DeviceModal.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog";
|
||||
import DeviceForm from "./DeviceForm";
|
||||
import { Device } from "@/server/db/schema/devices";
|
||||
|
||||
export default function DeviceModal({
|
||||
device,
|
||||
emptyState,
|
||||
}: {
|
||||
device?: Device;
|
||||
emptyState?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const closeModal = () => setOpen(false);
|
||||
const editing = !!device?.id;
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger asChild>
|
||||
{ emptyState ? (
|
||||
<Button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-1"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
New Device
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={editing ? "ghost" : "outline"}
|
||||
size={editing ? "sm" : "icon"}
|
||||
>
|
||||
{editing ? "Edit" : "+"}
|
||||
</Button> )}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="px-5 pt-5">
|
||||
<DialogTitle>{ editing ? "Edit" : "Create" } Device</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-5 pb-5">
|
||||
<DeviceForm closeModal={closeModal} device={device} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type * as z from "zod";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { newChildSchema } from "@/lib/validations/child";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { DialogFooter } from "../ui/dialog";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/trpc/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type AddChildFormProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
type FormData = z.infer<typeof newChildSchema>;
|
||||
|
||||
const AddChildForm: React.FC<AddChildFormProps> = ({ className, ...props }) => {
|
||||
const router = useRouter();
|
||||
const [PIN, setPIN] = React.useState("");
|
||||
const utils = api.useUtils();
|
||||
const createChild = api.child.create.useMutation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(newChildSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
const result = await createChild.mutateAsync({ name: data.name });
|
||||
toast({ description: "Added new child" });
|
||||
await utils.child.invalidate();
|
||||
console.log("add-child-form", "onSubmit", createChild.data);
|
||||
if (result) {
|
||||
setPIN(result.id);
|
||||
} else {
|
||||
toast({ description: "Something went wrong", variant: "destructive" });
|
||||
}
|
||||
} catch (err) {
|
||||
toast({ description: "Something went wrong", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
if (PIN) {
|
||||
return (
|
||||
<>
|
||||
<DialogTitle>Successfully added child</DialogTitle>
|
||||
<div>
|
||||
{`Your child's PIN is ${PIN}`}
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size={"icon"}
|
||||
className="ml-2"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(PIN).then(() => {
|
||||
toast({ description: "PIN copied to clipboard" });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icons.copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn("grid gap-6", className)} {...props}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Child</DialogTitle>
|
||||
<DialogDescription>
|
||||
{
|
||||
"Enter your child's details below and press save, then use the displayed PIN to register their device."
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="sr-only" htmlFor="email">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={"Child's name"}
|
||||
type="text"
|
||||
autoCapitalize="none"
|
||||
autoComplete="child-name"
|
||||
autoCorrect="off"
|
||||
disabled={createChild.isLoading}
|
||||
{...register("name")}
|
||||
/>
|
||||
{errors?.name && (
|
||||
<p className="px-1 text-xs text-red-600">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Icons.save className="mr-2 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AddChildForm;
|
||||
@@ -1,19 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { redirect, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type * as z from "zod";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { userAuthSchema } from "@/lib/validations/auth";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { z } from "zod";
|
||||
|
||||
const userAuthSchema = z.object({
|
||||
email: z.string().email(),
|
||||
})
|
||||
|
||||
type UserAuthFormProps = React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -29,8 +29,8 @@ const AuthHeader = () => {
|
||||
<DropdownMenuTrigger>
|
||||
<UserAvatar
|
||||
user={{
|
||||
name: session?.user?.name || null,
|
||||
image: session?.user?.image || null,
|
||||
name: session?.user?.name ?? null,
|
||||
image: session?.user?.image ?? null,
|
||||
}}
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
|
||||
@@ -13,45 +13,47 @@ import PresenceIndicator from "@/components/widgets/presence-indicator";
|
||||
export function SiteHeader() {
|
||||
const { data: session, status } = useSession();
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b bg-background">
|
||||
<div className="container flex h-16 items-center space-x-4 sm:justify-between sm:space-x-0">
|
||||
<MainNav items={siteConfig.mainNav} />
|
||||
<div className="flex flex-1 items-center justify-end space-x-4">
|
||||
<nav className="flex items-center space-x-1">
|
||||
<Link
|
||||
href={siteConfig.links.github}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div
|
||||
className={buttonVariants({
|
||||
size: "icon",
|
||||
variant: "ghost",
|
||||
})}
|
||||
<header className="sticky inset-x-0 top-0 z-50 backdrop-blur-sm duration-300 animate-in slide-in-from-top-1">
|
||||
<div className="flex border-b border-accent bg-background/80 py-2">
|
||||
<div className="container flex items-center justify-between gap-6">
|
||||
<MainNav items={siteConfig.mainNav} />
|
||||
<div className="flex flex-1 items-center justify-end space-x-4">
|
||||
<nav className="flex items-center space-x-1">
|
||||
<Link
|
||||
href={siteConfig.links.github}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icons.gitHub className="h-5 w-5" />
|
||||
<span className="sr-only">GitHub</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href={siteConfig.links.twitter}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div
|
||||
className={buttonVariants({
|
||||
size: "icon",
|
||||
variant: "ghost",
|
||||
})}
|
||||
<div
|
||||
className={buttonVariants({
|
||||
size: "icon",
|
||||
variant: "ghost",
|
||||
})}
|
||||
>
|
||||
<Icons.gitHub className="h-5 w-5" />
|
||||
<span className="sr-only">GitHub</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href={siteConfig.links.twitter}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icons.twitter className="h-5 w-5 fill-current" />
|
||||
<span className="sr-only">Twitter</span>
|
||||
</div>
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<PresenceIndicator />
|
||||
<AuthHeader />
|
||||
</nav>
|
||||
<div
|
||||
className={buttonVariants({
|
||||
size: "icon",
|
||||
variant: "ghost",
|
||||
})}
|
||||
>
|
||||
<Icons.twitter className="h-5 w-5 fill-current" />
|
||||
<span className="sr-only">Twitter</span>
|
||||
</div>
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<PresenceIndicator />
|
||||
<AuthHeader />
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -20,19 +20,17 @@ export function MainNav({ items }: MainNavProps) {
|
||||
{items?.length ? (
|
||||
<nav className="flex gap-6">
|
||||
{items?.map(
|
||||
(item, index) =>
|
||||
item.href && (
|
||||
<Link
|
||||
key={index}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center text-sm font-medium text-muted-foreground",
|
||||
item.disabled && "cursor-not-allowed opacity-80",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
),
|
||||
(item: NavItem, index) =>
|
||||
<Link
|
||||
key={index}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center text-sm font-medium text-muted-foreground",
|
||||
item.disabled && "cursor-not-allowed opacity-80",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
"use client";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import React, { useEffect, useState } from "react";
|
||||
@@ -10,119 +11,41 @@ import {
|
||||
Polyline,
|
||||
} from "react-leaflet";
|
||||
import { usePingSocket } from "@/lib/hooks/use-ping-socket";
|
||||
import type ChildModel from "@/lib/models/child";
|
||||
import MapMarker from "@/components/maps/map-marker";
|
||||
import { getLatestPing } from "@/lib/helpers/location/ping";
|
||||
import { type CompleteChild } from "@/server/db/schema/children";
|
||||
|
||||
type MainMapProps = {
|
||||
kids: ChildModel[];
|
||||
kids: CompleteChild[];
|
||||
};
|
||||
const MainMap: React.FC<MainMapProps> = ({ kids }) => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
console.log("MainMap", "kids", kids);
|
||||
}, [kids]);
|
||||
// Draw lines between each ping showing the direction of travel
|
||||
const renderLines = (kids: ChildModel[]) => {
|
||||
return kids?.map((kid) =>
|
||||
kid.devices?.map((device) =>
|
||||
device.pings?.map((ping, index) => {
|
||||
const nextPing = device.pings[index + 1];
|
||||
if (nextPing) {
|
||||
return (
|
||||
<>
|
||||
<Polyline
|
||||
key={`${ping.id}-${nextPing.id}`}
|
||||
positions={[
|
||||
[ping.latitude, ping.longitude],
|
||||
[nextPing.latitude, nextPing.longitude],
|
||||
]}
|
||||
/>
|
||||
<MapMarker
|
||||
key={ping.id}
|
||||
childName={kid.name}
|
||||
avatar={kid.avatar}
|
||||
deviceId={device.id}
|
||||
deviceName={device.deviceName}
|
||||
latitude={ping.latitude}
|
||||
longitude={ping.longitude}
|
||||
timestamp={ping.timestamp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
return (
|
||||
isMounted && (
|
||||
<div>
|
||||
<MapContainer
|
||||
className="map"
|
||||
center={[51.903614, -8.468399]}
|
||||
zoom={10}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<>
|
||||
{renderLines(kids)}
|
||||
{kids?.map((kid) =>
|
||||
kid.devices?.map((device) => {
|
||||
const latestPing = getLatestPing(device.pings);
|
||||
|
||||
return (
|
||||
latestPing && (
|
||||
<MapMarker
|
||||
key={latestPing.id}
|
||||
childName={kid.name}
|
||||
avatar={kid.avatar}
|
||||
deviceId={device.id}
|
||||
deviceName={device.deviceName}
|
||||
latitude={latestPing.latitude}
|
||||
longitude={latestPing.longitude}
|
||||
timestamp={latestPing.timestamp}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
// Draw coordinates of each ping on the map
|
||||
const renderPings = () => {
|
||||
return kids?.map((kid) =>
|
||||
kid.devices?.map((device) =>
|
||||
device.pings?.map((ping) => (
|
||||
<Circle
|
||||
key={ping.id}
|
||||
center={[ping.latitude, ping.longitude]}
|
||||
radius={100} // Adjust the radius as needed
|
||||
/>
|
||||
)),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
latestPing && (
|
||||
<MapMarker
|
||||
key={latestPing.id}
|
||||
childName={kid.name}
|
||||
avatar={kid.avatar}
|
||||
deviceId={device.id}
|
||||
deviceName={device.deviceName}
|
||||
latitude={latestPing.latitude}
|
||||
longitude={latestPing.longitude}
|
||||
timestamp={latestPing.timestamp}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</>
|
||||
</MapContainer>
|
||||
</div>
|
||||
)
|
||||
<div>
|
||||
<MapContainer
|
||||
className="map"
|
||||
center={[51.903614, -8.468399]}
|
||||
zoom={10}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
{kids?.map((kid) =>
|
||||
kid.devices?.map((device) =>
|
||||
device.pings.map((ping) => (
|
||||
<MapMarker
|
||||
key={ping.id}
|
||||
deviceId={device.id}
|
||||
childName={kid.name}
|
||||
avatar={kid.avatar}
|
||||
deviceName={device.name}
|
||||
latitude={ping.latitude}
|
||||
longitude={ping.longitude}
|
||||
timestamp={ping.timestamp}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default MainMap;
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
'use client';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Icons } from '@/components/icons';
|
||||
import React from 'react';
|
||||
|
||||
"use client";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { Icons } from "@/components/icons";
|
||||
import React from "react";
|
||||
|
||||
export const MapViewTypeSelector = () => {
|
||||
const [currentView, setCurrentView] = React.useState('location');
|
||||
const [currentView, setCurrentView] = React.useState("location");
|
||||
return (
|
||||
<ToggleGroup type="single" value={currentView}
|
||||
onValueChange={(value) => setCurrentView(value)}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={currentView}
|
||||
onValueChange={(value) => setCurrentView(value)}
|
||||
>
|
||||
<ToggleGroupItem value="location">
|
||||
<Icons.location className="w-4 h-4 mr-1" />
|
||||
<Icons.location className="mr-1 h-4 w-4" />
|
||||
Location
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="route">
|
||||
<Icons.route className="w-4 h-4 mr-1" />
|
||||
<Icons.route className="mr-1 h-4 w-4" />
|
||||
Route
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>);
|
||||
</ToggleGroup>
|
||||
);
|
||||
};
|
||||
|
||||
23
src/components/pages/dashboard-page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { api } from "@/trpc/server";
|
||||
import ChildrenFilter from "../children/children-filter";
|
||||
import { MapViewTypeSelector } from "../maps/map-viewtype-selector";
|
||||
|
||||
const DashboardPage = async () => {
|
||||
const { children } = await api.children.getChildren.query();
|
||||
const Map = dynamic(() => import("@/components/maps/main-map"), {
|
||||
ssr: false,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row justify-between">
|
||||
<ChildrenFilter children={children} />
|
||||
<MapViewTypeSelector />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Map kids={children} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default DashboardPage;
|
||||
@@ -6,95 +6,94 @@ import { Card, CardHeader, CardTitle, CardContent } from "../ui/card";
|
||||
import Link from "next/link";
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto text-center">
|
||||
<h2 className="text-4xl font-bold">Track Your Children with Ease</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Kidarr helps you keep an eye on your loved ones and ensure their
|
||||
safety.
|
||||
</p>
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "default", size: "lg" }),
|
||||
"mt-8",
|
||||
)}
|
||||
href="/signin"
|
||||
>
|
||||
<Icons.rocket className="mr-2 h-4 w-4" /> Let's go
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
return (
|
||||
<>
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto text-center">
|
||||
<h2 className="text-4xl font-bold">Track Your Children with Ease</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Kidarr helps you keep an eye on your loved ones and ensure their
|
||||
safety.
|
||||
</p>
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "default", size: "lg" }),
|
||||
"mt-8",
|
||||
)}
|
||||
href="/signin"
|
||||
>
|
||||
<Icons.rocket className="mr-2 h-4 w-4" /> {`Let's go`}</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Real-Time Location Tracking</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
Instantly know where your children are at all times with
|
||||
accurate GPS tracking.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Geofencing Alerts</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
Receive notifications when your child enters or leaves
|
||||
designated safe zones.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity Monitoring</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
{
|
||||
"View your child's activity history, including visited places and routes taken."
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
<section className="bg-background py-16">
|
||||
<div className="container mx-auto text-center">
|
||||
<h3 className="text-3xl font-semibold">
|
||||
Keep Your Children Safe Today!
|
||||
</h3>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Download Kidarr now and stay connected with your loved ones.
|
||||
</p>
|
||||
<section className="py-16">
|
||||
<div className="container mx-auto grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Real-Time Location Tracking</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
Instantly know where your children are at all times with
|
||||
accurate GPS tracking.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Geofencing Alerts</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
Receive notifications when your child enters or leaves
|
||||
designated safe zones.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity Monitoring</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>
|
||||
{
|
||||
"View your child's activity history, including visited places and routes taken."
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
<section className="bg-background py-16">
|
||||
<div className="container mx-auto text-center">
|
||||
<h3 className="text-3xl font-semibold">
|
||||
Keep Your Children Safe Today!
|
||||
</h3>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Download Kidarr now and stay connected with your loved ones.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"mt-8",
|
||||
)}
|
||||
href="/download"
|
||||
>
|
||||
<Icons.mobile className="mr-2 h-4 w-4" /> Download Now
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<footer className="bg-secondary-foreground py-8 text-center text-secondary">
|
||||
<p>
|
||||
An open source experiment from PodNoms - source code available{" "}
|
||||
<Link target="_blank" href="https://github.com/kid-arr">
|
||||
here
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "lg" }),
|
||||
"mt-8",
|
||||
)}
|
||||
href="/download"
|
||||
>
|
||||
<Icons.mobile className="mr-2 h-4 w-4" /> Download Now
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
<footer className="bg-secondary-foreground py-8 text-center text-secondary">
|
||||
<p>
|
||||
An open source experiment from PodNoms - source code available{" "}
|
||||
<Link target="_blank" href="https://github.com/kid-arr">
|
||||
here
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { type ThemeProviderProps } from 'next-themes/dist/types'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
40
src/components/ui/ThemeToggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,21 @@
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type EmblaCarouselType as CarouselApi,
|
||||
type EmblaOptionsType as CarouselOptions,
|
||||
type EmblaPluginType as CarouselPlugin,
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin[]
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
@@ -44,19 +45,17 @@ const PaginationLink = ({
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<PaginationItem>
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
@@ -90,6 +89,7 @@ const PaginationNext = ({
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
@@ -104,6 +104,7 @@ const PaginationEllipsis = ({
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
|
||||
15
src/config/nav.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SidebarLink } from "@/components/SidebarItems";
|
||||
import { Cog, Globe, HomeIcon } from "lucide-react";
|
||||
|
||||
type AdditionalLinks = {
|
||||
title: string;
|
||||
links: SidebarLink[];
|
||||
};
|
||||
|
||||
export const defaultLinks: SidebarLink[] = [
|
||||
{ href: "/", title: "Home", icon: HomeIcon },
|
||||
{ href: "/account", title: "Account", icon: Cog },
|
||||
{ href: "/settings", title: "Settings", icon: Cog },
|
||||
];
|
||||
|
||||
export const additionalLinks: AdditionalLinks[] = [];
|
||||
36
src/env.js
@@ -1,5 +1,5 @@
|
||||
import { createEnv } from '@t3-oss/env-nextjs';
|
||||
import { z } from 'zod';
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
@@ -7,30 +7,7 @@ export const env = createEnv({
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.refine(
|
||||
(str) => !str.includes('YOUR_PG_URL_HERE'),
|
||||
'You forgot to change the default URL',
|
||||
),
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'test', 'production'])
|
||||
.default('development'),
|
||||
NEXTAUTH_SECRET:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? z.string()
|
||||
: z.string().optional(),
|
||||
NEXTAUTH_URL: z.preprocess(
|
||||
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
|
||||
// Since NextAuth.js automatically uses the VERCEL_URL if present.
|
||||
(str) => process.env.VERCEL_URL ?? str,
|
||||
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
|
||||
process.env.VERCEL ? z.string() : z.string().url(),
|
||||
),
|
||||
GOOGLE_CLIENT_ID: z.string(),
|
||||
GOOGLE_CLIENT_SECRET: z.string(),
|
||||
ALLOWED_DEBUG_IP: z.string().optional(),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -47,13 +24,8 @@ export const env = createEnv({
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
ALLOWED_DEBUG_IP: process.env.ALLOWED_DEBUG_IP,
|
||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||
},
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||
|
||||
36
src/env.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
DATABASE_URL: z.string().min(1),
|
||||
|
||||
NEXTAUTH_SECRET: process.env.NODE_ENV === "production"
|
||||
? z.string().min(1)
|
||||
: z.string().min(1).optional(),
|
||||
NEXTAUTH_URL: z.preprocess(
|
||||
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
|
||||
// Since NextAuth.js automatically uses the VERCEL_URL if present.
|
||||
(str) => process.env.VERCEL_URL ?? str,
|
||||
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
|
||||
process.env.VERCEL_URL ? z.string().min(1) : z.string().url()
|
||||
),
|
||||
GOOGLE_CLIENT_ID: z.string().min(1),
|
||||
GOOGLE_CLIENT_SECRET: z.string().min(1),
|
||||
},
|
||||
client: {
|
||||
// NEXT_PUBLIC_PUBLISHABLE_KEY: z.string().min(1),
|
||||
},
|
||||
// If you're using Next.js < 13.4.4, you'll need to specify the runtimeEnv manually
|
||||
// runtimeEnv: {
|
||||
// DATABASE_URL: process.env.DATABASE_URL,
|
||||
// NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY,
|
||||
// },
|
||||
// For Next.js >= 13.4.4, you only need to destructure client variables:
|
||||
experimental__runtimeEnv: {
|
||||
// NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY,
|
||||
},
|
||||
});
|
||||
58
src/lib/api/children/mutations.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { db } from "@/server/db/index";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
ChildId,
|
||||
NewChildParams,
|
||||
UpdateChildParams,
|
||||
updateChildSchema,
|
||||
insertChildSchema,
|
||||
children,
|
||||
childIdSchema
|
||||
} from "@/server/db/schema/children";
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
|
||||
export const createChild = async (child: NewChildParams) => {
|
||||
const { session } = await getUserAuth();
|
||||
const newChild = insertChildSchema.parse({ ...child, userId: session?.user.id! });
|
||||
try {
|
||||
const [c] = await db.insert(children).values(newChild).returning();
|
||||
return { child: c };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? "Error, please try again";
|
||||
console.error(message);
|
||||
throw { error: message };
|
||||
}
|
||||
};
|
||||
|
||||
export const updateChild = async (id: ChildId, child: UpdateChildParams) => {
|
||||
const { session } = await getUserAuth();
|
||||
const { id: childId } = childIdSchema.parse({ id });
|
||||
const newChild = updateChildSchema.parse({ ...child, userId: session?.user.id! });
|
||||
try {
|
||||
const [c] = await db
|
||||
.update(children)
|
||||
.set(newChild)
|
||||
.where(and(eq(children.id, childId!), eq(children.userId, session?.user.id!)))
|
||||
.returning();
|
||||
return { child: c };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? "Error, please try again";
|
||||
console.error(message);
|
||||
throw { error: message };
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteChild = async (id: ChildId) => {
|
||||
const { session } = await getUserAuth();
|
||||
const { id: childId } = childIdSchema.parse({ id });
|
||||
try {
|
||||
const [c] = await db.delete(children).where(and(eq(children.id, childId!), eq(children.userId, session?.user.id!)))
|
||||
.returning();
|
||||
return { child: c };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? "Error, please try again";
|
||||
console.error(message);
|
||||
throw { error: message };
|
||||
}
|
||||
};
|
||||
|
||||
46
src/lib/api/children/queries.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
type ChildId,
|
||||
childIdSchema,
|
||||
children,
|
||||
} from "@/server/db/schema/children";
|
||||
|
||||
export const getChildren = async () => {
|
||||
const { session } = await getUserAuth();
|
||||
console.log("queries", "getChildren", session?.user.id!);
|
||||
const c = await db.query.children.findMany({
|
||||
where: (children, { eq }) => eq(children.userId, session?.user.id!),
|
||||
orderBy: (children) => children.name,
|
||||
with: {
|
||||
devices: {
|
||||
with: {
|
||||
pings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log("queries", "gotChildren", c);
|
||||
return { children: c };
|
||||
// const c = await db
|
||||
// .select()
|
||||
// .from(children)
|
||||
// .where(eq(children.userId, session?.user.id!))
|
||||
// .innerJoin(devices, eq(devices.childId, children.id))
|
||||
// .innerJoin(pings, eq(pings.deviceId, devices.id))
|
||||
// .orderBy(children.name);
|
||||
// return c;
|
||||
};
|
||||
|
||||
export const getChildById = async (id: ChildId) => {
|
||||
const { session } = await getUserAuth();
|
||||
const { id: childId } = childIdSchema.parse({ id });
|
||||
const [c] = await db
|
||||
.select()
|
||||
.from(children)
|
||||
.where(
|
||||
and(eq(children.id, childId), eq(children.userId, session?.user.id!)),
|
||||
);
|
||||
return { child: c };
|
||||
};
|
||||
58
src/lib/api/devices/mutations.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { db } from "@/server/db/index";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
DeviceId,
|
||||
NewDeviceParams,
|
||||
UpdateDeviceParams,
|
||||
updateDeviceSchema,
|
||||
insertDeviceSchema,
|
||||
devices,
|
||||
deviceIdSchema
|
||||
} from "@/server/db/schema/devices";
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
|
||||
export const createDevice = async (device: NewDeviceParams) => {
|
||||
const { session } = await getUserAuth();
|
||||
const newDevice = insertDeviceSchema.parse({ ...device, userId: session?.user.id! });
|
||||
try {
|
||||
const [d] = await db.insert(devices).values(newDevice).returning();
|
||||
return { device: d };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? "Error, please try again";
|
||||
console.error(message);
|
||||
throw { error: message };
|
||||
}
|
||||
};
|
||||
|
||||
export const updateDevice = async (id: DeviceId, device: UpdateDeviceParams) => {
|
||||
const { session } = await getUserAuth();
|
||||
const { id: deviceId } = deviceIdSchema.parse({ id });
|
||||
const newDevice = updateDeviceSchema.parse({ ...device, userId: session?.user.id! });
|
||||
try {
|
||||
const [d] = await db
|
||||
.update(devices)
|
||||
.set(newDevice)
|
||||
.where(and(eq(devices.id, deviceId!), eq(devices.userId, session?.user.id!)))
|
||||
.returning();
|
||||
return { device: d };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? "Error, please try again";
|
||||
console.error(message);
|
||||
throw { error: message };
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDevice = async (id: DeviceId) => {
|
||||
const { session } = await getUserAuth();
|
||||
const { id: deviceId } = deviceIdSchema.parse({ id });
|
||||
try {
|
||||
const [d] = await db.delete(devices).where(and(eq(devices.id, deviceId!), eq(devices.userId, session?.user.id!)))
|
||||
.returning();
|
||||
return { device: d };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? "Error, please try again";
|
||||
console.error(message);
|
||||
throw { error: message };
|
||||
}
|
||||
};
|
||||
|
||||
19
src/lib/api/devices/queries.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { db } from "@/server/db/index";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
import { type DeviceId, deviceIdSchema, devices } from "@/server/db/schema/devices";
|
||||
import { children } from "@/server/db/schema/children";
|
||||
|
||||
export const getDevices = async () => {
|
||||
const { session } = await getUserAuth();
|
||||
const d = await db.select({ device: devices, child: children }).from(devices).leftJoin(children, eq(devices.childId, children.id)).where(eq(devices.userId, session?.user.id!));
|
||||
return { devices: d };
|
||||
};
|
||||
|
||||
export const getDeviceById = async (id: DeviceId) => {
|
||||
const { session } = await getUserAuth();
|
||||
const { id: deviceId } = deviceIdSchema.parse({ id });
|
||||
const [d] = await db.select().from(devices).where(and(eq(devices.id, deviceId), eq(devices.userId, session?.user.id!))).leftJoin(children, eq(devices.childId, children.id));
|
||||
return { device: d };
|
||||
};
|
||||
|
||||
58
src/lib/api/pings/mutations.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { db } from "@/server/db/index";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
PingId,
|
||||
NewPingParams,
|
||||
UpdatePingParams,
|
||||
updatePingSchema,
|
||||
insertPingSchema,
|
||||
pings,
|
||||
pingIdSchema
|
||||
} from "@/server/db/schema/pings";
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
|
||||
export const createPing = async (ping: NewPingParams) => {
|
||||
const { session } = await getUserAuth();
|
||||
const newPing = insertPingSchema.parse({ ...ping, userId: session?.user.id! });
|
||||
try {
|
||||
const [p] = await db.insert(pings).values(newPing).returning();
|
||||
return { ping: p };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? "Error, please try again";
|
||||
console.error(message);
|
||||
throw { error: message };
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePing = async (id: PingId, ping: UpdatePingParams) => {
|
||||
const { session } = await getUserAuth();
|
||||
const { id: pingId } = pingIdSchema.parse({ id });
|
||||
const newPing = updatePingSchema.parse({ ...ping, userId: session?.user.id! });
|
||||
try {
|
||||
const [p] = await db
|
||||
.update(pings)
|
||||
.set(newPing)
|
||||
.where(and(eq(pings.id, pingId!), eq(pings.userId, session?.user.id!)))
|
||||
.returning();
|
||||
return { ping: p };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? "Error, please try again";
|
||||
console.error(message);
|
||||
throw { error: message };
|
||||
}
|
||||
};
|
||||
|
||||
export const deletePing = async (id: PingId) => {
|
||||
const { session } = await getUserAuth();
|
||||
const { id: pingId } = pingIdSchema.parse({ id });
|
||||
try {
|
||||
const [p] = await db.delete(pings).where(and(eq(pings.id, pingId!), eq(pings.userId, session?.user.id!)))
|
||||
.returning();
|
||||
return { ping: p };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message ?? "Error, please try again";
|
||||
console.error(message);
|
||||
throw { error: message };
|
||||
}
|
||||
};
|
||||
|
||||
19
src/lib/api/pings/queries.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { db } from "@/server/db/index";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getUserAuth } from "@/lib/auth/utils";
|
||||
import { type PingId, pingIdSchema, pings } from "@/server/db/schema/pings";
|
||||
import { devices } from "@/server/db/schema/devices";
|
||||
|
||||
export const getPings = async () => {
|
||||
const { session } = await getUserAuth();
|
||||
const p = await db.select({ ping: pings, device: devices }).from(pings).leftJoin(devices, eq(pings.deviceId, devices.id)).where(eq(pings.userId, session?.user.id!));
|
||||
return { pings: p };
|
||||
};
|
||||
|
||||
export const getPingById = async (id: PingId) => {
|
||||
const { session } = await getUserAuth();
|
||||
const { id: pingId } = pingIdSchema.parse({ id });
|
||||
const [p] = await db.select().from(pings).where(and(eq(pings.id, pingId), eq(pings.userId, session?.user.id!))).leftJoin(devices, eq(pings.deviceId, devices.id));
|
||||
return { ping: p };
|
||||
};
|
||||
|
||||
11
src/lib/auth/Provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function NextAuthProvider({ children }: Props) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
};
|
||||
50
src/lib/auth/utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { db } from "@/server/db/index";
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
import { DefaultSession, getServerSession, NextAuthOptions } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { env } from "@/env.mjs";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type AuthSession = {
|
||||
session: {
|
||||
user: {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: DrizzleAdapter(db),
|
||||
callbacks: {
|
||||
session: ({ session, user }) => {
|
||||
session.user.id = user.id;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export const getUserAuth = async () => {
|
||||
const session = await getServerSession(authOptions);
|
||||
return { session } as AuthSession;
|
||||
};
|
||||
|
||||
export const checkAuth = async () => {
|
||||
const { session } = await getUserAuth();
|
||||
if (!session) redirect("/signin");
|
||||
};
|
||||
38
src/lib/hooks/useValidatedForm.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { ZodSchema } from "zod";
|
||||
|
||||
type EntityZodErrors<T> = Partial<Record<keyof T, string[] | undefined>>;
|
||||
|
||||
export function useValidatedForm<Entity>(insertEntityZodSchema: ZodSchema) {
|
||||
const [errors, setErrors] = useState<EntityZodErrors<Entity> | null>(null);
|
||||
const hasErrors =
|
||||
errors !== null &&
|
||||
Object.values(errors).some((error) => error !== undefined);
|
||||
|
||||
const handleChange = (event: FormEvent<HTMLFormElement>) => {
|
||||
const target = event.target as EventTarget;
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
if (!(target instanceof HTMLInputElement && target.type === "submit")) {
|
||||
const field = target.name as keyof Entity;
|
||||
const result = insertEntityZodSchema.safeParse({
|
||||
[field]: target.value,
|
||||
});
|
||||
const fieldError = result.success
|
||||
? undefined
|
||||
: result.error.flatten().fieldErrors[field];
|
||||
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: fieldError,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
return { errors, setErrors, handleChange, hasErrors };
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type DeviceModel from './device';
|
||||
|
||||
export default interface ChildModel {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
devices: DeviceModel[];
|
||||
// recentLocations: Location[];
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type PingModel from './ping';
|
||||
|
||||
export default interface DeviceModel {
|
||||
id: string;
|
||||
deviceName: string;
|
||||
pings: PingModel[];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type Location from './location';
|
||||
import type Location from "./location";
|
||||
|
||||
export default interface LocationUpdate {
|
||||
childId: string;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default interface ChildModel {
|
||||
id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import generateApiKey from 'generate-api-key';
|
||||
|
||||
const createApiKey = () =>
|
||||
generateApiKey({ method: 'string', length: 256 }) as string;
|
||||
|
||||
export { createApiKey };
|
||||
@@ -1,12 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export default function NextAuthProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export type NavItem = {
|
||||
title: string;
|
||||
href?: string;
|
||||
disabled?: boolean;
|
||||
external?: boolean;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const userAuthSchema = z.object({
|
||||
email: z.string().email(),
|
||||
})
|
||||