mirror of
https://github.com/philipredstone/relnet.git
synced 2025-06-18 05:21:16 +02:00
ach ich weiß nicht
This commit is contained in:
parent
56c0867a20
commit
e60ec9248d
@ -1 +0,0 @@
|
|||||||
node_modules
|
|
@ -1,16 +0,0 @@
|
|||||||
# Ignore build outputs
|
|
||||||
/dist
|
|
||||||
/build
|
|
||||||
|
|
||||||
# Ignore dependencies
|
|
||||||
/node_modules
|
|
||||||
|
|
||||||
# Ignore coverage reports
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# Ignore logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Ignore configuration files
|
|
||||||
.env
|
|
||||||
.env.*
|
|
@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"printWidth": 100,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"jsxSingleQuote": false,
|
|
||||||
"bracketSameLine": false
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"format": "prettier --write \"src/**/*.{tsx,ts,js,jsx,json,css,html}\"",
|
|
||||||
"format:check": "prettier --check \"src/**/*.{tsx,ts,js,jsx,json,css,html}\""
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"description": "",
|
|
||||||
"dependencies": {
|
|
||||||
"@headlessui/react": "^2.2.1",
|
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
|
||||||
"axios": "^1.8.4",
|
|
||||||
"framer-motion": "^12.7.3",
|
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-datepicker": "^8.3.0",
|
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"react-force-graph-2d": "^1.27.1",
|
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"react-router-dom": "^7.5.0",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"vite": "^6.2.6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/axios": "^0.14.4",
|
|
||||||
"@types/node": "^22.14.1",
|
|
||||||
"@types/react": "^19.1.2",
|
|
||||||
"@types/react-dom": "^19.1.2",
|
|
||||||
"@types/react-router-dom": "^5.3.3",
|
|
||||||
"@vitejs/plugin-react": "^4.4.0",
|
|
||||||
"autoprefixer": "^10.4.21",
|
|
||||||
"postcss": "^8.4.32",
|
|
||||||
"prettier": "^3.5.3",
|
|
||||||
"tailwindcss": "^4.1.4",
|
|
||||||
"webpack": "^5.99.5",
|
|
||||||
"webpack-cli": "^6.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
@import 'tailwindcss';
|
|
File diff suppressed because it is too large
Load Diff
@ -1,113 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
|
||||||
|
|
||||||
/* Projects */
|
|
||||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
|
||||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
|
||||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
|
||||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
|
||||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
|
||||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
|
||||||
|
|
||||||
/* Language and Environment */
|
|
||||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
|
||||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
|
||||||
"jsx": "preserve", /* Specify what JSX code is generated. */
|
|
||||||
// "libReplacement": true, /* Enable lib replacement. */
|
|
||||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
|
||||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
|
||||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
|
||||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
|
||||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
|
||||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
|
||||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
|
||||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
|
||||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
|
||||||
|
|
||||||
/* Modules */
|
|
||||||
"module": "commonjs", /* Specify what module code is generated. */
|
|
||||||
"rootDir": "./src", /* Specify the root folder within your source files. */
|
|
||||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
|
||||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
|
||||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
|
||||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
|
||||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
|
||||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
|
||||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
|
||||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
|
||||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
|
||||||
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
|
||||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
|
||||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
|
||||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
|
||||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
|
||||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
|
||||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
|
||||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
|
||||||
|
|
||||||
/* JavaScript Support */
|
|
||||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
|
||||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
|
||||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
|
||||||
|
|
||||||
/* Emit */
|
|
||||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
|
||||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
|
||||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
|
||||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
|
||||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
|
||||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
|
||||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
|
||||||
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
|
||||||
// "removeComments": true, /* Disable emitting comments. */
|
|
||||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
|
||||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
|
||||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
|
||||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
|
||||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
|
||||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
|
||||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
|
||||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
|
||||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
|
||||||
"noEmitOnError": false, /* Disable emitting files if any type checking errors are reported. */
|
|
||||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
|
||||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
|
||||||
|
|
||||||
/* Interop Constraints */
|
|
||||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
|
||||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
|
||||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
|
||||||
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
|
|
||||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
|
||||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
|
||||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
|
||||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
|
||||||
|
|
||||||
/* Type Checking */
|
|
||||||
"strict": true, /* Enable all strict type-checking options. */
|
|
||||||
"noImplicitAny": false, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
|
||||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
|
||||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
|
||||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
|
||||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
|
||||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
|
||||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
|
||||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
|
||||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
|
||||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
|
||||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
|
||||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
|
||||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
|
||||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
|
||||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
|
||||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
|
||||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
|
||||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
|
||||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
|
||||||
|
|
||||||
/* Completeness */
|
|
||||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
|
||||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
|
||||||
},
|
|
||||||
});
|
|
76
package.json
76
package.json
@ -1,51 +1,53 @@
|
|||||||
{
|
{
|
||||||
"name": "relnet",
|
"name": "relnet",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "dist/main.js",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node dist/server.js",
|
"dev": "vite",
|
||||||
"dev": "nodemon --exec ts-node src/server.ts",
|
"server": "bun run server/dev.ts",
|
||||||
"build": "tsc",
|
"dev:all": "concurrently \"bun run dev\" \"bun run server\"",
|
||||||
"build:all": "npm run build && cd frontend && npm run build",
|
"build": "tsc && vite build",
|
||||||
"format": "prettier --write \"src/**/*.{ts,js,json}\"",
|
"preview": "vite preview",
|
||||||
"format:check": "prettier --check \"src/**/*.{ts,js,json}\"",
|
"start": "NODE_ENV=production bun run dist/server/index.js",
|
||||||
"format:all": "npm run format && cd frontend && npm run format"
|
"format": "prettier --write \"src/**/*.{tsx,ts,js,jsx,json,css,html}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{tsx,ts,js,jsx,json,css,html}\""
|
||||||
},
|
},
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/philipredstone/relnet.git"
|
|
||||||
},
|
|
||||||
"author": "Tobias Hopp <tobstr02> & Philip Rothstein <philipredstone>",
|
|
||||||
"license": "ISC",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/philipredstone/relnet/issues"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/philipredstone/relnet#readme",
|
|
||||||
"description": "Visualize your network among you and your friends",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.2",
|
"@headlessui/react": "^2.2.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"axios": "^1.8.4",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^5.1.0",
|
"express": "^4.18.2",
|
||||||
"express-validator": "^7.2.1",
|
"framer-motion": "^12.7.3",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^7.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"react": "^19.1.0",
|
||||||
"mongoose": "^8.13.2"
|
"react-datepicker": "^8.3.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-force-graph-2d": "^1.27.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-router-dom": "^7.5.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@types/cookie-parser": "^1.4.8",
|
"@types/axios": "^0.14.4",
|
||||||
|
"@types/cookie-parser": "^1.4.6",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/dotenv": "^8.2.3",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express": "^5.0.1",
|
|
||||||
"@types/helmet": "^4.0.0",
|
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
|
||||||
"@types/mongoose": "^5.11.97",
|
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.14.1",
|
||||||
"nodemon": "^3.1.9",
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-datepicker": "^4.19.6",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"ts-node": "^10.9.2",
|
"tailwindcss": "^4.1.4",
|
||||||
"typescript": "^5.8.3"
|
"vite-plugin-node": "^5.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
8
postcss.config.js
Normal file
8
postcss.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
@ -4,7 +4,7 @@ import User, { IUser } from '../models/user.model';
|
|||||||
import Network from '../models/network.model';
|
import Network from '../models/network.model';
|
||||||
import Person from '../models/person.model';
|
import Person from '../models/person.model';
|
||||||
import Relationship from '../models/relationship.model';
|
import Relationship from '../models/relationship.model';
|
||||||
import { UserRequest } from '../types/express';
|
import { UserRequest } from '../../frontend/types/express';
|
||||||
import { validationResult } from 'express-validator';
|
import { validationResult } from 'express-validator';
|
||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
|
|
@ -1,6 +1,6 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import Network from '../models/network.model';
|
import Network from '../models/network.model';
|
||||||
import { UserRequest } from '../types/express';
|
import { UserRequest } from '../../frontend/types/express';
|
||||||
import { validationResult } from 'express-validator';
|
import { validationResult } from 'express-validator';
|
||||||
|
|
||||||
// Get all networks for current user and all public networks
|
// Get all networks for current user and all public networks
|
@ -1,7 +1,7 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import Person from '../models/person.model';
|
import Person from '../models/person.model';
|
||||||
import Relationship from '../models/relationship.model';
|
import Relationship from '../models/relationship.model';
|
||||||
import { UserRequest } from '../types/express';
|
import { UserRequest } from '../../frontend/types/express';
|
||||||
import { validationResult } from 'express-validator';
|
import { validationResult } from 'express-validator';
|
||||||
|
|
||||||
// Get all people in a network
|
// Get all people in a network
|
@ -1,7 +1,7 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import Relationship from '../models/relationship.model';
|
import Relationship from '../models/relationship.model';
|
||||||
import Person from '../models/person.model';
|
import Person from '../models/person.model';
|
||||||
import { UserRequest } from '../types/express';
|
import { UserRequest } from '../../frontend/types/express';
|
||||||
import { validationResult } from 'express-validator';
|
import { validationResult } from 'express-validator';
|
||||||
|
|
||||||
// Get all relationships in a network
|
// Get all relationships in a network
|
7
server/dev.ts
Normal file
7
server/dev.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// This file is used for starting the server in development mode
|
||||||
|
import app from './index';
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
@ -8,9 +8,12 @@ import peopleRoutes from './routes/people.routes';
|
|||||||
import relationshipRoutes from './routes/relationship.routes';
|
import relationshipRoutes from './routes/relationship.routes';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
|
import connectDB from './config/db';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
connectDB();
|
||||||
|
|
||||||
const app: Application = express();
|
const app: Application = express();
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
@ -40,7 +43,7 @@ app.use(express.json());
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: process.env.APP_URL || 'http://localhost:3000',
|
origin: 'http://0.0.0.0:3000',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -56,10 +59,25 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.send('OK');
|
res.send('OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, '../frontend/dist/')));
|
// In development, Vite handles static files
|
||||||
|
// In production, we serve static files from the dist directory
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.use(express.static(path.resolve(__dirname, '../dist')));
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
// Always return the main index.html for any route that doesn't match an API endpoint
|
||||||
res.sendFile(path.join(__dirname, '..', 'frontend/dist/index.html'));
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.resolve(__dirname, '../dist/index.html'));
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// This will be handled by the Vite dev server
|
||||||
|
}
|
||||||
|
|
||||||
|
// This setup allows the server to be used both standalone and with Vite
|
||||||
|
if (import.meta.env?.PROD) {
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
@ -1,7 +1,7 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import User from '../models/user.model';
|
import User from '../models/user.model';
|
||||||
import { UserRequest } from '../types/express';
|
import { UserRequest } from '../../frontend/types/express';
|
||||||
|
|
||||||
// JWT secret from environment variables
|
// JWT secret from environment variables
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_jwt_secret_key_change_this';
|
const JWT_SECRET = process.env.JWT_SECRET || 'your_jwt_secret_key_change_this';
|
@ -1,6 +1,6 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import Network from '../models/network.model';
|
import Network from '../models/network.model';
|
||||||
import { UserRequest } from '../types/express';
|
import { UserRequest } from '../../frontend/types/express';
|
||||||
|
|
||||||
export const checkNetworkAccess = async (
|
export const checkNetworkAccess = async (
|
||||||
req: UserRequest,
|
req: UserRequest,
|
@ -5,7 +5,7 @@ import { NetworkProvider } from './context/NetworkContext';
|
|||||||
import Login from './components/auth/Login';
|
import Login from './components/auth/Login';
|
||||||
import Register from './components/auth/Register';
|
import Register from './components/auth/Register';
|
||||||
import NetworkList from './components/networks/NetworkList';
|
import NetworkList from './components/networks/NetworkList';
|
||||||
import FriendshipNetwork from './components/FriendshipNetwork';
|
import FriendshipNetwork from './pages/FriendshipNetwork';
|
||||||
import Header from './components/layout/Header';
|
import Header from './components/layout/Header';
|
||||||
|
|
||||||
// Protected route component
|
// Protected route component
|
||||||
@ -28,13 +28,14 @@ const App: React.FC = () => {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<NetworkProvider>
|
<NetworkProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
|
<header className="header-height">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-grow">
|
</header>
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/networks"
|
path="/networks"
|
||||||
element={
|
element={
|
||||||
@ -43,16 +44,16 @@ const App: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/networks/:id"
|
path="/networks/:id"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
|
<div className="h-full">
|
||||||
<FriendshipNetwork />
|
<FriendshipNetwork />
|
||||||
|
</div>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/networks" />} />
|
<Route path="/" element={<Navigate to="/networks" />} />
|
||||||
<Route path="*" element={<Navigate to="/networks" />} />
|
<Route path="*" element={<Navigate to="/networks" />} />
|
||||||
</Routes>
|
</Routes>
|
14
src/api/api.ts
Normal file
14
src/api/api.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export const getApiUrl = (): string => {
|
||||||
|
// const protocol = window.location.protocol;
|
||||||
|
// const hostname = window.location.hostname;
|
||||||
|
// const port = window.location.port;
|
||||||
|
|
||||||
|
// // @ts-ignore
|
||||||
|
// if (import.meta.env.DEV) {
|
||||||
|
// return protocol + '//' + hostname + ':5000' + '/api';
|
||||||
|
// } else {
|
||||||
|
// return protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
||||||
|
// }
|
||||||
|
|
||||||
|
return '/api';
|
||||||
|
};
|
@ -1,10 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { getApiUrl } from './api';
|
||||||
|
|
||||||
const protocol = window.location.protocol;
|
const API_URL = getApiUrl();
|
||||||
const hostname = window.location.hostname;
|
|
||||||
const port = window.location.port;
|
|
||||||
|
|
||||||
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
|
||||||
|
|
||||||
// Configure axios
|
// Configure axios
|
||||||
axios.defaults.withCredentials = true;
|
axios.defaults.withCredentials = true;
|
@ -1,10 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { getApiUrl } from './api';
|
||||||
|
|
||||||
const protocol = window.location.protocol;
|
const API_URL = getApiUrl();
|
||||||
const hostname = window.location.hostname;
|
|
||||||
const port = window.location.port;
|
|
||||||
|
|
||||||
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface NetworkOwner {
|
export interface NetworkOwner {
|
@ -1,10 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { getApiUrl } from './api';
|
||||||
|
|
||||||
const protocol = window.location.protocol;
|
const API_URL = getApiUrl();
|
||||||
const hostname = window.location.hostname;
|
|
||||||
const port = window.location.port;
|
|
||||||
|
|
||||||
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface Person {
|
export interface Person {
|
@ -1,10 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { getApiUrl } from './api';
|
||||||
|
|
||||||
const protocol = window.location.protocol;
|
const API_URL = getApiUrl();
|
||||||
const hostname = window.location.hostname;
|
|
||||||
const port = window.location.port;
|
|
||||||
|
|
||||||
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface Relationship {
|
export interface Relationship {
|
9
src/app.css
Normal file
9
src/app.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
.header-height {
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-full-important {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
575
src/components/Modals.tsx
Normal file
575
src/components/Modals.tsx
Normal file
@ -0,0 +1,575 @@
|
|||||||
|
// Modals.tsx - Modal components for the FriendshipNetwork
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import DatePicker from 'react-datepicker';
|
||||||
|
import {
|
||||||
|
FaPlus,
|
||||||
|
FaRegCalendarAlt,
|
||||||
|
FaSave,
|
||||||
|
FaStar,
|
||||||
|
FaTrash,
|
||||||
|
FaUserFriends,
|
||||||
|
FaUserPlus,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
import { Button, FormField, Modal } from '../components/FriendshipNetworkComponents';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PersonNode,
|
||||||
|
RelationshipEdge,
|
||||||
|
RelationshipType,
|
||||||
|
FormErrors,
|
||||||
|
NewPersonForm,
|
||||||
|
NewRelationshipForm,
|
||||||
|
} from '../types/types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
KeyboardShortcut,
|
||||||
|
TipItem,
|
||||||
|
ToggleSetting,
|
||||||
|
OptionGroup,
|
||||||
|
ErrorMessage,
|
||||||
|
} from './UIComponents';
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Person Form Modal
|
||||||
|
// ==============================
|
||||||
|
interface PersonFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
formData: NewPersonForm;
|
||||||
|
setFormData: React.Dispatch<React.SetStateAction<NewPersonForm>>;
|
||||||
|
errors: FormErrors;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
isEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PersonFormModal: React.FC<PersonFormModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
errors,
|
||||||
|
onSubmit,
|
||||||
|
isEdit = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Person' : 'Add New Person'}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={errors.general} />
|
||||||
|
|
||||||
|
<FormField label="First Name" id="firstName" required error={errors.firstName}>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
type="text"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.firstName ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
placeholder="Enter first name"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Last Name" id="lastName" required error={errors.lastName}>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
type="text"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.lastName ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
placeholder="Enter last name"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Birthday (Optional)" id="birthday">
|
||||||
|
<div className="relative">
|
||||||
|
<DatePicker
|
||||||
|
id="birthday"
|
||||||
|
selected={formData.birthday}
|
||||||
|
onChange={(date: Date | null) => setFormData({ ...formData, birthday: date })}
|
||||||
|
dateFormat="MMMM d, yyyy"
|
||||||
|
placeholderText="Select birthday"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
showYearDropdown
|
||||||
|
dropdownMode="select"
|
||||||
|
wrapperClassName="w-full"
|
||||||
|
/>
|
||||||
|
<FaRegCalendarAlt className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Notes (Optional)" id="notes">
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 min-h-[80px]
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
placeholder="Add any additional information"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" icon={isEdit ? <FaSave /> : <FaUserPlus />}>
|
||||||
|
{isEdit ? 'Save Changes' : 'Add Person'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Relationship Form Modal
|
||||||
|
// ==============================
|
||||||
|
interface RelationshipFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
formData: NewRelationshipForm;
|
||||||
|
setFormData: React.Dispatch<React.SetStateAction<NewRelationshipForm>>;
|
||||||
|
errors: FormErrors;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
people: PersonNode[];
|
||||||
|
relationshipLabels: Record<RelationshipType, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RelationshipFormModal: React.FC<RelationshipFormModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
errors,
|
||||||
|
onSubmit,
|
||||||
|
people,
|
||||||
|
relationshipLabels,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Add New Relationship">
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={errors.general} />
|
||||||
|
|
||||||
|
<FormField label="Source Person" id="source" required error={errors.source}>
|
||||||
|
<select
|
||||||
|
id="source"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.source ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
value={formData.source}
|
||||||
|
onChange={e => setFormData({ ...formData, source: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select person</option>
|
||||||
|
{people.map(person => (
|
||||||
|
<option key={`source-${person._id}`} value={person._id}>
|
||||||
|
{person.firstName} {person.lastName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Target Person" id="target" required error={errors.target}>
|
||||||
|
<select
|
||||||
|
id="target"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.target ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
value={formData.target}
|
||||||
|
onChange={e => setFormData({ ...formData, target: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select person</option>
|
||||||
|
{people.map(person => (
|
||||||
|
<option key={`target-${person._id}`} value={person._id}>
|
||||||
|
{person.firstName} {person.lastName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Relationship Type" id="type" required>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
type: e.target.value as RelationshipType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.entries(relationshipLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{formData.type === 'custom' && (
|
||||||
|
<FormField label="Custom Type" id="customType" required error={errors.customType}>
|
||||||
|
<input
|
||||||
|
id="customType"
|
||||||
|
type="text"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.customType ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
placeholder="Enter custom relationship type"
|
||||||
|
value={formData.customType}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
customType: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField label="Notes (Optional)" id="relationNotes">
|
||||||
|
<textarea
|
||||||
|
id="relationNotes"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 min-h-[60px]
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
placeholder="Add any additional information"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex items-center mt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="bidirectional"
|
||||||
|
className="h-4 w-4 rounded border-gray-500 text-indigo-600 focus:ring-indigo-500 bg-slate-700"
|
||||||
|
checked={formData.bidirectional}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
bidirectional: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label htmlFor="bidirectional" className="ml-2 block text-sm text-gray-300">
|
||||||
|
Create bidirectional relationship (recommended)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" icon={<FaUserFriends />}>
|
||||||
|
Add Relationship
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Person Detail Modal
|
||||||
|
// ==============================
|
||||||
|
interface PersonDetailModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
person: PersonNode;
|
||||||
|
setPerson: React.Dispatch<React.SetStateAction<PersonNode | null>>;
|
||||||
|
errors: FormErrors;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
relationships: RelationshipEdge[];
|
||||||
|
people: PersonNode[];
|
||||||
|
relationshipColors: Record<RelationshipType, string>;
|
||||||
|
relationshipLabels: Record<RelationshipType, string>;
|
||||||
|
onDeleteRelationship: (id: string) => void;
|
||||||
|
onAddNewConnection: () => void;
|
||||||
|
onNavigateToPerson: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PersonDetailModal: React.FC<PersonDetailModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
person,
|
||||||
|
setPerson,
|
||||||
|
errors,
|
||||||
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
|
relationships,
|
||||||
|
people,
|
||||||
|
relationshipColors,
|
||||||
|
relationshipLabels,
|
||||||
|
onDeleteRelationship,
|
||||||
|
onAddNewConnection,
|
||||||
|
onNavigateToPerson,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={`${person.firstName} ${person.lastName}`}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={errors.general} />
|
||||||
|
|
||||||
|
<FormField label="First Name" id="editFirstName" required error={errors.firstName}>
|
||||||
|
<input
|
||||||
|
id="editFirstName"
|
||||||
|
type="text"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.firstName ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
value={person.firstName || ''}
|
||||||
|
onChange={e => setPerson({ ...person, firstName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Last Name" id="editLastName" required error={errors.lastName}>
|
||||||
|
<input
|
||||||
|
id="editLastName"
|
||||||
|
type="text"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.lastName ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
value={person.lastName || ''}
|
||||||
|
onChange={e => setPerson({ ...person, lastName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Birthday" id="editBirthday">
|
||||||
|
<div className="relative">
|
||||||
|
<DatePicker
|
||||||
|
id="editBirthday"
|
||||||
|
selected={person.birthday ? new Date(person.birthday) : null}
|
||||||
|
onChange={(date: Date | null) => setPerson({ ...person, birthday: date })}
|
||||||
|
dateFormat="MMMM d, yyyy"
|
||||||
|
placeholderText="Select birthday"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
showYearDropdown
|
||||||
|
dropdownMode="select"
|
||||||
|
wrapperClassName="w-full"
|
||||||
|
/>
|
||||||
|
<FaRegCalendarAlt className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Notes" id="editNotes">
|
||||||
|
<textarea
|
||||||
|
id="editNotes"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 min-h-[80px]
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
value={person.notes || ''}
|
||||||
|
onChange={e => setPerson({ ...person, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-2">
|
||||||
|
<Button variant="danger" onClick={() => onDelete(person._id)} icon={<FaTrash />}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" icon={<FaSave />}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-indigo-400 mb-2">Connections</h4>
|
||||||
|
<div className="max-h-40 overflow-y-auto space-y-1 bg-slate-900 rounded-lg p-2">
|
||||||
|
{relationships.filter(
|
||||||
|
(r: RelationshipEdge) => r.source === person._id || r.target === person._id
|
||||||
|
).length > 0 ? (
|
||||||
|
relationships
|
||||||
|
.filter((r: RelationshipEdge) => r.source === person._id || r.target === person._id)
|
||||||
|
.map((rel: RelationshipEdge) => {
|
||||||
|
const isSource = rel.source === person._id;
|
||||||
|
const otherPersonId = isSource ? rel.target : rel.source;
|
||||||
|
const otherPerson = people.find((p: PersonNode) => p._id === otherPersonId);
|
||||||
|
|
||||||
|
if (!otherPerson) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rel._id}
|
||||||
|
className="flex justify-between items-center py-1 px-2 hover:bg-slate-800 rounded"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full mr-2"
|
||||||
|
style={{ backgroundColor: relationshipColors[rel.type] }}
|
||||||
|
></span>
|
||||||
|
<span className="text-sm">
|
||||||
|
{isSource ? 'To: ' : 'From: '}
|
||||||
|
<span
|
||||||
|
className="font-medium hover:text-indigo-400 cursor-pointer"
|
||||||
|
onClick={() => onNavigateToPerson(otherPersonId)}
|
||||||
|
>
|
||||||
|
{otherPerson.firstName} {otherPerson.lastName}
|
||||||
|
</span>
|
||||||
|
{rel.type === 'custom'
|
||||||
|
? ` (${rel.customType})`
|
||||||
|
: ` (${relationshipLabels[rel.type]})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
onClick={() => onDeleteRelationship(rel._id)}
|
||||||
|
>
|
||||||
|
<FaTrash size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-2 text-slate-400 text-sm">No connections yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-center">
|
||||||
|
<Button variant="secondary" size="sm" onClick={onAddNewConnection} icon={<FaPlus />}>
|
||||||
|
Add New Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Settings Modal
|
||||||
|
// ==============================
|
||||||
|
interface SettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
settings: {
|
||||||
|
darkMode: boolean;
|
||||||
|
autoLayout: boolean;
|
||||||
|
showLabels: boolean;
|
||||||
|
animationSpeed: string;
|
||||||
|
highlightConnections: boolean;
|
||||||
|
nodeSize: string;
|
||||||
|
};
|
||||||
|
setSettings: React.Dispatch<
|
||||||
|
React.SetStateAction<{
|
||||||
|
darkMode: boolean;
|
||||||
|
autoLayout: boolean;
|
||||||
|
showLabels: boolean;
|
||||||
|
animationSpeed: string;
|
||||||
|
highlightConnections: boolean;
|
||||||
|
nodeSize: string;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
settings,
|
||||||
|
setSettings,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Network Settings">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toggle settings */}
|
||||||
|
<ToggleSetting
|
||||||
|
label="Show Labels"
|
||||||
|
id="showLabels"
|
||||||
|
checked={settings.showLabels}
|
||||||
|
onChange={() => setSettings({ ...settings, showLabels: !settings.showLabels })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleSetting
|
||||||
|
label="Auto Layout"
|
||||||
|
id="autoLayout"
|
||||||
|
checked={settings.autoLayout}
|
||||||
|
onChange={() => setSettings({ ...settings, autoLayout: !settings.autoLayout })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleSetting
|
||||||
|
label="Highlight Connections"
|
||||||
|
id="highlightConnections"
|
||||||
|
checked={settings.highlightConnections}
|
||||||
|
onChange={() =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
highlightConnections: !settings.highlightConnections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Option groups */}
|
||||||
|
<OptionGroup
|
||||||
|
label="Animation Speed"
|
||||||
|
options={['slow', 'medium', 'fast']}
|
||||||
|
currentValue={settings.animationSpeed}
|
||||||
|
onChange={value => setSettings({ ...settings, animationSpeed: value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionGroup
|
||||||
|
label="Node Size"
|
||||||
|
options={['small', 'medium', 'large']}
|
||||||
|
currentValue={settings.nodeSize}
|
||||||
|
onChange={value => setSettings({ ...settings, nodeSize: value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end">
|
||||||
|
<Button variant="primary" onClick={onClose} icon={<FaSave />}>
|
||||||
|
Save Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Help Modal
|
||||||
|
// ==============================
|
||||||
|
interface HelpModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HelpModal: React.FC<HelpModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Keyboard Shortcuts & Help" size="lg">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-semibold text-indigo-400 mb-2">Keyboard Shortcuts</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<KeyboardShortcut shortcut="n" description="Add new person" />
|
||||||
|
<KeyboardShortcut shortcut="r" description="Add new relationship" />
|
||||||
|
<KeyboardShortcut shortcut="s" description="Toggle sidebar" />
|
||||||
|
<KeyboardShortcut shortcut="+" description="Zoom in" />
|
||||||
|
<KeyboardShortcut shortcut="-" description="Zoom out" />
|
||||||
|
<KeyboardShortcut shortcut="0" description="Reset zoom" />
|
||||||
|
<KeyboardShortcut shortcut="Ctrl+/" description="Show this help" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-semibold text-indigo-400 mb-2">Tips & Tricks</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-slate-300">
|
||||||
|
<TipItem text="Click on a person in the graph to see their details and edit their information" />
|
||||||
|
<TipItem text="Drag people around in the graph to organize your network visually" />
|
||||||
|
<TipItem text="Use the sidebar to filter and manage your network's people and relationships" />
|
||||||
|
<TipItem text="Create bidirectional relationships to show mutual connections (recommended)" />
|
||||||
|
<TipItem text="Customize the appearance and behavior in Settings" />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center pt-2">
|
||||||
|
<Button variant="primary" onClick={onClose} icon={<FaStar />}>
|
||||||
|
Got it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
460
src/components/NetworkSidebar.tsx
Normal file
460
src/components/NetworkSidebar.tsx
Normal file
@ -0,0 +1,460 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FaEdit,
|
||||||
|
FaHome,
|
||||||
|
FaSearch,
|
||||||
|
FaTrash,
|
||||||
|
FaUserCircle,
|
||||||
|
FaUserFriends,
|
||||||
|
FaUserPlus,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
// Import custom UI components
|
||||||
|
import { Button, EmptyState, Tooltip, NetworkStats } from './FriendshipNetworkComponents';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
type RelationshipType = 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
|
||||||
|
|
||||||
|
interface PersonNode {
|
||||||
|
_id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
birthday?: Date | string | null;
|
||||||
|
notes?: string;
|
||||||
|
position?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelationshipEdge {
|
||||||
|
_id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: RelationshipType;
|
||||||
|
customType?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph appearance constants
|
||||||
|
const RELATIONSHIP_COLORS = {
|
||||||
|
freund: '#60A5FA', // Light blue
|
||||||
|
partner: '#F472B6', // Pink
|
||||||
|
familie: '#34D399', // Green
|
||||||
|
arbeitskolleg: '#FBBF24', // Yellow
|
||||||
|
custom: '#9CA3AF', // Gray
|
||||||
|
};
|
||||||
|
|
||||||
|
const RELATIONSHIP_LABELS = {
|
||||||
|
freund: 'Friend',
|
||||||
|
partner: 'Partner',
|
||||||
|
familie: 'Family',
|
||||||
|
arbeitskolleg: 'Colleague',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
// NetworkSidebar component props
|
||||||
|
interface NetworkSidebarProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
currentNetwork: any;
|
||||||
|
sidebarTab: string;
|
||||||
|
people: PersonNode[];
|
||||||
|
relationships: RelationshipEdge[];
|
||||||
|
selectedPersonId: string | null;
|
||||||
|
peopleFilter: string;
|
||||||
|
relationshipFilter: string;
|
||||||
|
relationshipTypeFilter: string;
|
||||||
|
|
||||||
|
onTabChange: (tab: string) => void;
|
||||||
|
onPeopleFilterChange: (filter: string) => void;
|
||||||
|
onRelationshipFilterChange: (filter: string) => void;
|
||||||
|
onRelationshipTypeFilterChange: (type: string) => void;
|
||||||
|
onAddPerson: () => void;
|
||||||
|
onAddRelationship: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onOpenHelp: () => void;
|
||||||
|
onPersonDelete: (id: string) => void;
|
||||||
|
onRelationshipDelete: (id: string) => void;
|
||||||
|
onOpenPersonDetail: (person: PersonNode) => void;
|
||||||
|
onNavigateBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NetworkSidebar: React.FC<NetworkSidebarProps> = ({
|
||||||
|
isOpen,
|
||||||
|
currentNetwork,
|
||||||
|
sidebarTab,
|
||||||
|
people,
|
||||||
|
relationships,
|
||||||
|
selectedPersonId,
|
||||||
|
peopleFilter,
|
||||||
|
relationshipFilter,
|
||||||
|
relationshipTypeFilter,
|
||||||
|
|
||||||
|
onTabChange,
|
||||||
|
onPeopleFilterChange,
|
||||||
|
onRelationshipFilterChange,
|
||||||
|
onRelationshipTypeFilterChange,
|
||||||
|
onAddPerson,
|
||||||
|
onAddRelationship,
|
||||||
|
onPersonDelete,
|
||||||
|
onRelationshipDelete,
|
||||||
|
onOpenPersonDetail,
|
||||||
|
onNavigateBack,
|
||||||
|
}) => {
|
||||||
|
// Filter logic for people and relationships
|
||||||
|
const filteredPeople = people.filter(person =>
|
||||||
|
`${person.firstName} ${person.lastName}`.toLowerCase().includes(peopleFilter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRelationships = relationships.filter(rel => {
|
||||||
|
const source = people.find(p => p._id === rel.source);
|
||||||
|
const target = people.find(p => p._id === rel.target);
|
||||||
|
|
||||||
|
if (!source || !target) return false;
|
||||||
|
|
||||||
|
const matchesFilter =
|
||||||
|
`${source.firstName} ${source.lastName} ${target.firstName} ${target.lastName}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(relationshipFilter.toLowerCase());
|
||||||
|
|
||||||
|
const matchesType = relationshipTypeFilter === 'all' || rel.type === relationshipTypeFilter;
|
||||||
|
|
||||||
|
return matchesFilter && matchesType;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort people alphabetically
|
||||||
|
const sortedPeople = [...filteredPeople].sort((a, b) => {
|
||||||
|
const nameA = `${a.firstName} ${a.lastName}`.toLowerCase();
|
||||||
|
const nameB = `${b.firstName} ${b.lastName}`.toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-slate-800 border-r border-slate-700 h-full transition-all duration-300
|
||||||
|
ease-in-out z-30 ${isOpen ? 'w-100' : 'w-0'}`}
|
||||||
|
>
|
||||||
|
<div className="h-full overflow-y-auto p-4">
|
||||||
|
{/* Network Header */}
|
||||||
|
<div className="mb-6 mt-8">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center">
|
||||||
|
<span className="truncate">{currentNetwork?.name || 'Relationship Network'}</span>
|
||||||
|
</h2>
|
||||||
|
<Tooltip text="Back to networks">
|
||||||
|
<button
|
||||||
|
onClick={onNavigateBack}
|
||||||
|
className="p-2 text-slate-400 hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
<FaHome />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-sm">Visualize your connections</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Stats */}
|
||||||
|
<NetworkStats people={people} relationships={relationships} />
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex space-x-2 mb-6">
|
||||||
|
<Button variant="primary" fullWidth onClick={onAddPerson} icon={<FaUserPlus />}>
|
||||||
|
Add Person
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
fullWidth
|
||||||
|
onClick={onAddRelationship}
|
||||||
|
icon={<FaUserFriends />}
|
||||||
|
>
|
||||||
|
Add Relation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Tabs */}
|
||||||
|
<div className="flex border-b border-slate-700 mb-4">
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 font-medium flex items-center justify-center ${
|
||||||
|
sidebarTab === 'people'
|
||||||
|
? 'text-indigo-400 border-b-2 border-indigo-400'
|
||||||
|
: 'text-slate-400 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
onClick={() => onTabChange('people')}
|
||||||
|
>
|
||||||
|
<FaUserCircle className="mr-2" /> People
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 font-medium flex items-center justify-center ${
|
||||||
|
sidebarTab === 'relations'
|
||||||
|
? 'text-indigo-400 border-b-2 border-indigo-400'
|
||||||
|
: 'text-slate-400 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
onClick={() => onTabChange('relations')}
|
||||||
|
>
|
||||||
|
<FaUserFriends className="mr-2" /> Relations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
|
||||||
|
{sidebarTab === 'people' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md py-2 pl-8 pr-3
|
||||||
|
text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
placeholder="Search people..."
|
||||||
|
value={peopleFilter}
|
||||||
|
onChange={e => onPeopleFilterChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-[calc(100vh-350px)] overflow-y-auto pr-1">
|
||||||
|
{sortedPeople.length > 0 ? (
|
||||||
|
sortedPeople.map(person => {
|
||||||
|
const connectionCount = relationships.filter(
|
||||||
|
r => r.source === person._id || r.target === person._id
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={person._id}
|
||||||
|
className={`bg-slate-700 rounded-lg p-3 group hover:bg-slate-600 transition-colors
|
||||||
|
cursor-pointer border-l-4 ${
|
||||||
|
selectedPersonId === person._id
|
||||||
|
? 'border-l-pink-500'
|
||||||
|
: connectionCount > 0
|
||||||
|
? 'border-l-indigo-500'
|
||||||
|
: 'border-l-slate-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
onOpenPersonDetail(person);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">
|
||||||
|
{person.firstName} {person.lastName}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center text-xs text-slate-400 mt-1">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full mr-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: connectionCount > 0 ? '#60A5FA' : '#94A3B8',
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
{connectionCount} connection{connectionCount !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Tooltip text="Edit">
|
||||||
|
<button
|
||||||
|
className="p-1 text-slate-400 hover:text-indigo-400 transition-colors"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenPersonDetail(person);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaEdit size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="Delete">
|
||||||
|
<button
|
||||||
|
className="p-1 text-slate-400 hover:text-red-400 transition-colors"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPersonDelete(person._id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTrash size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title={peopleFilter ? 'No matches found' : 'No people yet'}
|
||||||
|
description={
|
||||||
|
peopleFilter
|
||||||
|
? 'Try adjusting your search criteria'
|
||||||
|
: 'Add people to start building your network'
|
||||||
|
}
|
||||||
|
icon={<FaUserCircle className="text-2xl text-slate-400" />}
|
||||||
|
action={
|
||||||
|
!peopleFilter && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddPerson}
|
||||||
|
icon={<FaUserPlus />}
|
||||||
|
>
|
||||||
|
Add Person
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sidebarTab === 'relations' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md py-2 pl-8 pr-3
|
||||||
|
text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
placeholder="Search relationships..."
|
||||||
|
value={relationshipFilter}
|
||||||
|
onChange={e => onRelationshipFilterChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex mb-3 overflow-x-auto pb-2 space-x-1">
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap ${
|
||||||
|
relationshipTypeFilter === 'all'
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => onRelationshipTypeFilterChange('all')}
|
||||||
|
>
|
||||||
|
All Types
|
||||||
|
</button>
|
||||||
|
{Object.entries(RELATIONSHIP_COLORS).map(([type, color]) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap flex items-center ${
|
||||||
|
relationshipTypeFilter === type
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => onRelationshipTypeFilterChange(type as RelationshipType)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full mr-1"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
></span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{RELATIONSHIP_LABELS[type as RelationshipType]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-[calc(100vh-390px)] overflow-y-auto pr-1">
|
||||||
|
{filteredRelationships.length > 0 ? (
|
||||||
|
filteredRelationships.map(rel => {
|
||||||
|
const source = people.find(p => p._id === rel.source);
|
||||||
|
const target = people.find(p => p._id === rel.target);
|
||||||
|
if (!source || !target) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rel._id}
|
||||||
|
className={`bg-slate-700 rounded-lg p-3 group hover:bg-slate-600 transition-colors
|
||||||
|
border-l-4 ${
|
||||||
|
selectedPersonId === rel.source || selectedPersonId === rel.target
|
||||||
|
? 'border-l-pink-500'
|
||||||
|
: 'border-l-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span
|
||||||
|
className={`font-medium ${selectedPersonId === rel.source ? 'text-pink-400' : ''}`}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const sourcePerson = people.find(p => p._id === rel.source);
|
||||||
|
if (sourcePerson) onOpenPersonDetail(sourcePerson);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{source.firstName} {source.lastName}
|
||||||
|
</span>
|
||||||
|
<span className="mx-2 text-slate-400">→</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${selectedPersonId === rel.target ? 'text-pink-400' : ''}`}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const targetPerson = people.find(p => p._id === rel.target);
|
||||||
|
if (targetPerson) onOpenPersonDetail(targetPerson);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{target.firstName} {target.lastName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-xs text-slate-400 mt-1">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full mr-1"
|
||||||
|
style={{ backgroundColor: RELATIONSHIP_COLORS[rel.type] }}
|
||||||
|
></span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{rel.type === 'custom'
|
||||||
|
? rel.customType
|
||||||
|
: RELATIONSHIP_LABELS[rel.type]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Tooltip text="Delete">
|
||||||
|
<button
|
||||||
|
className="p-1 text-slate-400 hover:text-red-400 transition-colors"
|
||||||
|
onClick={() => onRelationshipDelete(rel._id)}
|
||||||
|
>
|
||||||
|
<FaTrash size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
relationshipFilter || relationshipTypeFilter !== 'all'
|
||||||
|
? 'No matches found'
|
||||||
|
: 'No relationships yet'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
relationshipFilter || relationshipTypeFilter !== 'all'
|
||||||
|
? 'Try adjusting your search criteria'
|
||||||
|
: 'Create relationships between people to visualize connections'
|
||||||
|
}
|
||||||
|
icon={<FaUserFriends className="text-2xl text-slate-400" />}
|
||||||
|
action={
|
||||||
|
!relationshipFilter &&
|
||||||
|
relationshipTypeFilter === 'all' && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddRelationship}
|
||||||
|
icon={<FaUserFriends />}
|
||||||
|
>
|
||||||
|
Add Relationship
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NetworkSidebar;
|
230
src/components/UIComponents.tsx
Normal file
230
src/components/UIComponents.tsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
// UIComponents.tsx - Small UI components used in the FriendshipNetwork
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FormErrors } from '../types/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle setting component with a switch-style toggle
|
||||||
|
*/
|
||||||
|
export interface ToggleSettingProps {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToggleSetting: React.FC<ToggleSettingProps> = ({ label, id, checked, onChange }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-gray-300">{label}</label>
|
||||||
|
<div className="relative inline-block w-12 align-middle select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
className="sr-only"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<div className="block h-6 bg-slate-700 rounded-full w-12"></div>
|
||||||
|
<div
|
||||||
|
className={`absolute left-1 top-1 w-4 h-4 rounded-full transition-transform ${
|
||||||
|
checked ? 'transform translate-x-6 bg-indigo-500' : 'bg-gray-400'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option group component for selecting from a group of options
|
||||||
|
*/
|
||||||
|
export interface OptionGroupProps {
|
||||||
|
label: string;
|
||||||
|
options: string[];
|
||||||
|
currentValue: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OptionGroup: React.FC<OptionGroupProps> = ({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
currentValue,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">{label}</label>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{options.map(option => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
className={`flex-1 py-2 px-3 rounded-md text-sm ${
|
||||||
|
currentValue === option
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange(option)}
|
||||||
|
>
|
||||||
|
{option.charAt(0).toUpperCase() + option.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard shortcut item component for the help modal
|
||||||
|
*/
|
||||||
|
export interface KeyboardShortcutProps {
|
||||||
|
shortcut: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KeyboardShortcut: React.FC<KeyboardShortcutProps> = ({ shortcut, description }) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 p-2 rounded">
|
||||||
|
<span className="inline-block bg-slate-700 px-2 py-1 rounded mr-2 text-xs font-mono">
|
||||||
|
{shortcut}
|
||||||
|
</span>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tip item component for the help modal
|
||||||
|
*/
|
||||||
|
export interface TipItemProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TipItem: React.FC<TipItemProps> = ({ text }) => {
|
||||||
|
return (
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-indigo-400 mr-2">•</span>
|
||||||
|
<span>{text}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message display component
|
||||||
|
*/
|
||||||
|
export interface ErrorMessageProps {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ message }) => {
|
||||||
|
return message ? (
|
||||||
|
<div className="bg-red-500/20 border border-red-500 text-white p-3 rounded-lg text-sm mb-4">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading spinner component
|
||||||
|
*/
|
||||||
|
export const LoadingSpinner: React.FC<{ message?: string }> = ({ message = 'Loading...' }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="w-16 h-16 border-t-4 border-b-4 border-indigo-500 border-solid rounded-full animate-spin"></div>
|
||||||
|
<p className="text-white text-lg">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form field group with validation
|
||||||
|
*/
|
||||||
|
export interface FormGroupProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormGroup: React.FC<FormGroupProps> = ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
required = false,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
{label} {required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form validation helpers
|
||||||
|
*/
|
||||||
|
export const validatePersonForm = (person: { firstName: string; lastName: string }): FormErrors => {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
|
||||||
|
if (!person.firstName.trim()) {
|
||||||
|
errors.firstName = 'First name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!person.lastName.trim()) {
|
||||||
|
errors.lastName = 'Last name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateRelationshipForm = (
|
||||||
|
relationship: {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: string;
|
||||||
|
customType: string;
|
||||||
|
bidirectional: boolean;
|
||||||
|
},
|
||||||
|
existingRelationships: any[]
|
||||||
|
): FormErrors => {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
|
||||||
|
if (!relationship.source) {
|
||||||
|
errors.source = 'Source person is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relationship.target) {
|
||||||
|
errors.target = 'Target person is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationship.source === relationship.target) {
|
||||||
|
errors.target = 'Source and target cannot be the same person';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationship.type === 'custom' && !relationship.customType.trim()) {
|
||||||
|
errors.customType = 'Custom relationship type is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if relationship already exists
|
||||||
|
if (relationship.source && relationship.target) {
|
||||||
|
const existingRelationship = existingRelationships.find(
|
||||||
|
r =>
|
||||||
|
(r.source === relationship.source && r.target === relationship.target) ||
|
||||||
|
(relationship.bidirectional &&
|
||||||
|
r.source === relationship.target &&
|
||||||
|
r.target === relationship.source)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingRelationship) {
|
||||||
|
errors.general = 'This relationship already exists';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
@ -17,11 +17,9 @@ const Header: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if we're on the login or register page
|
|
||||||
const isAuthPage = location.pathname === '/login' || location.pathname === '/register';
|
const isAuthPage = location.pathname === '/login' || location.pathname === '/register';
|
||||||
|
|
||||||
if (isAuthPage) {
|
if (isAuthPage) {
|
||||||
return null; // Don't show header on auth pages
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -29,11 +27,10 @@ const Header: React.FC = () => {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link to="/" className="flex-shrink-0 flex items-center">
|
<Link to="/" className="flex-shrink-0 flex items-center -ml-2">
|
||||||
<FaNetworkWired className="h-6 w-6 text-indigo-400" />
|
<FaNetworkWired className="h-6 w-6 text-indigo-400" />
|
||||||
<span className="ml-2 text-white font-bold text-xl">RelNet</span>
|
<span className="ml-2 text-white font-bold text-xl">RelNet</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<nav className="ml-8 flex space-x-4">
|
<nav className="ml-8 flex space-x-4">
|
||||||
<Link
|
<Link
|
||||||
@ -49,7 +46,6 @@ const Header: React.FC = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
@ -62,9 +58,7 @@ const Header: React.FC = () => {
|
|||||||
<FaUser />
|
<FaUser />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-slate-800 rounded-md shadow-lg py-1 z-10 border border-slate-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||||
<div
|
|
||||||
className="absolute right-0 mt-2 w-48 bg-slate-800 rounded-md shadow-lg py-1 z-10 border border-slate-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 flex items-center"
|
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 flex items-center"
|
63
src/hooks/useGraphDimensions.ts
Normal file
63
src/hooks/useGraphDimensions.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { useState, useEffect, useCallback, RefObject } from 'react';
|
||||||
|
import { ToastItem } from '../types/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing graph container dimensions and handling resize events
|
||||||
|
*/
|
||||||
|
export const useGraphDimensions = (
|
||||||
|
graphContainerRef: RefObject<HTMLDivElement>,
|
||||||
|
sidebarOpen: boolean
|
||||||
|
) => {
|
||||||
|
const [graphDimensions, setGraphDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!graphContainerRef.current) return;
|
||||||
|
|
||||||
|
const updateDimensions = () => {
|
||||||
|
if (graphContainerRef.current) {
|
||||||
|
const { width, height } = graphContainerRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
setGraphDimensions(prev => {
|
||||||
|
if (prev.width !== width || prev.height !== height) {
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial measurement
|
||||||
|
updateDimensions();
|
||||||
|
|
||||||
|
// Set up resize observer
|
||||||
|
const resizeObserver = new ResizeObserver(updateDimensions);
|
||||||
|
if (graphContainerRef.current) {
|
||||||
|
resizeObserver.observe(graphContainerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up window resize listener
|
||||||
|
window.addEventListener('resize', updateDimensions);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
return () => {
|
||||||
|
if (graphContainerRef.current) {
|
||||||
|
resizeObserver.unobserve(graphContainerRef.current);
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', updateDimensions);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update dimensions when sidebar is toggled
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (graphContainerRef.current) {
|
||||||
|
const { width, height } = graphContainerRef.current.getBoundingClientRect();
|
||||||
|
setGraphDimensions({ width, height });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [sidebarOpen]);
|
||||||
|
|
||||||
|
return graphDimensions;
|
||||||
|
};
|
78
src/hooks/useKeyboardShortcuts.ts
Normal file
78
src/hooks/useKeyboardShortcuts.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useEffect, useCallback, RefObject } from 'react';
|
||||||
|
import { ToastItem } from '../types/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for setting up keyboard shortcuts
|
||||||
|
*/
|
||||||
|
export const useKeyboardShortcuts = (handlers: {
|
||||||
|
handleZoomIn: () => void;
|
||||||
|
handleZoomOut: () => void;
|
||||||
|
handleResetZoom: () => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
setPersonModalOpen: (open: boolean) => void;
|
||||||
|
setRelationshipModalOpen: (open: boolean) => void;
|
||||||
|
setHelpModalOpen: (open: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const {
|
||||||
|
handleZoomIn,
|
||||||
|
handleZoomOut,
|
||||||
|
handleResetZoom,
|
||||||
|
toggleSidebar,
|
||||||
|
setPersonModalOpen,
|
||||||
|
setRelationshipModalOpen,
|
||||||
|
setHelpModalOpen,
|
||||||
|
} = handlers;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Only apply shortcuts when not in an input field
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
// Ctrl/Cmd + / to open help modal
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHelpModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// + for zoom in
|
||||||
|
if (e.key === '+' || e.key === '=') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleZoomIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// - for zoom out
|
||||||
|
if (e.key === '-' || e.key === '_') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleZoomOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 for reset zoom
|
||||||
|
if (e.key === '0') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleResetZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
// n for new person
|
||||||
|
if (e.key === 'n' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPersonModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// r for new relationship
|
||||||
|
if (e.key === 'r' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
setRelationshipModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// s for toggle sidebar
|
||||||
|
if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handlers]);
|
||||||
|
};
|
60
src/hooks/useSmartNodePositioning.ts
Normal file
60
src/hooks/useSmartNodePositioning.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useState, useEffect, useCallback, RefObject } from 'react';
|
||||||
|
import { ToastItem } from '../types/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage node positions in the graph
|
||||||
|
*/
|
||||||
|
export const useSmartNodePositioning = (
|
||||||
|
graphWidth: number,
|
||||||
|
graphHeight: number,
|
||||||
|
peopleCount: number
|
||||||
|
) => {
|
||||||
|
return useCallback(() => {
|
||||||
|
const centerX = graphWidth / 2;
|
||||||
|
const centerY = graphHeight / 2;
|
||||||
|
const maxRadius = Math.min(graphWidth, graphHeight) * 0.4;
|
||||||
|
const totalNodes = peopleCount;
|
||||||
|
const index = totalNodes;
|
||||||
|
|
||||||
|
if (totalNodes <= 0) {
|
||||||
|
return { x: centerX, y: centerY };
|
||||||
|
} else if (totalNodes <= 4) {
|
||||||
|
const theta = index * 2.399;
|
||||||
|
const radius = maxRadius * 0.5 * Math.sqrt(index / (totalNodes + 1));
|
||||||
|
return {
|
||||||
|
x: centerX + radius * Math.cos(theta),
|
||||||
|
y: centerY + radius * Math.sin(theta),
|
||||||
|
};
|
||||||
|
} else if (totalNodes <= 11) {
|
||||||
|
const isOuterRing = index >= Math.floor(totalNodes / 2);
|
||||||
|
const ringIndex = isOuterRing ? index - Math.floor(totalNodes / 2) : index;
|
||||||
|
const ringTotal = isOuterRing
|
||||||
|
? totalNodes - Math.floor(totalNodes / 2) + 1
|
||||||
|
: Math.floor(totalNodes / 2);
|
||||||
|
const ringRadius = isOuterRing ? maxRadius * 0.8 : maxRadius * 0.4;
|
||||||
|
|
||||||
|
const angle = (ringIndex / ringTotal) * 2 * Math.PI + (isOuterRing ? 0 : Math.PI / ringTotal);
|
||||||
|
return {
|
||||||
|
x: centerX + ringRadius * Math.cos(angle),
|
||||||
|
y: centerY + ringRadius * Math.sin(angle),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const clusterCount = Math.max(3, Math.floor(Math.sqrt(totalNodes)));
|
||||||
|
const clusterIndex = index % clusterCount;
|
||||||
|
|
||||||
|
const clusterAngle = (clusterIndex / clusterCount) * 2 * Math.PI;
|
||||||
|
const clusterDistance = maxRadius * 0.6;
|
||||||
|
const clusterX = centerX + clusterDistance * Math.cos(clusterAngle);
|
||||||
|
const clusterY = centerY + clusterDistance * Math.sin(clusterAngle);
|
||||||
|
|
||||||
|
const clusterRadius = maxRadius * 0.3;
|
||||||
|
const randomAngle = Math.random() * 2 * Math.PI;
|
||||||
|
const randomDistance = Math.random() * clusterRadius;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: clusterX + randomDistance * Math.cos(randomAngle),
|
||||||
|
y: clusterY + randomDistance * Math.sin(randomAngle),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [graphWidth, graphHeight, peopleCount]);
|
||||||
|
};
|
30
src/hooks/useToastNotifications.ts
Normal file
30
src/hooks/useToastNotifications.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useState, useEffect, useCallback, RefObject } from 'react';
|
||||||
|
import { ToastItem } from '../types/types';
|
||||||
|
|
||||||
|
export const useToastNotifications = () => {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const addToast = useCallback(
|
||||||
|
(message: string, type: 'error' | 'success' | 'warning' | 'info' = 'success') => {
|
||||||
|
const id = Date.now();
|
||||||
|
const newToast = {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
onClose: () => removeToast(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
setToasts(prevToasts => [...prevToasts, newToast]);
|
||||||
|
|
||||||
|
// Auto-remove after 3 seconds
|
||||||
|
setTimeout(() => removeToast(id), 3000);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: number) => {
|
||||||
|
setToasts(prevToasts => prevToasts.filter(toast => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { toasts, addToast, removeToast };
|
||||||
|
};
|
729
src/pages/FriendshipNetwork.tsx
Normal file
729
src/pages/FriendshipNetwork.tsx
Normal file
@ -0,0 +1,729 @@
|
|||||||
|
// FriendshipNetwork.tsx - Main component for the friendship network visualization
|
||||||
|
|
||||||
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useFriendshipNetwork } from '../hooks/useFriendshipNetwork';
|
||||||
|
import { useNetworks } from '../context/NetworkContext';
|
||||||
|
import {
|
||||||
|
FaArrowLeft,
|
||||||
|
FaChevronLeft,
|
||||||
|
FaChevronRight,
|
||||||
|
FaExclamationTriangle,
|
||||||
|
FaInfo,
|
||||||
|
FaTimes,
|
||||||
|
FaUserFriends,
|
||||||
|
FaUserPlus,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
// Import components
|
||||||
|
import { Button, ConfirmDialog, Toast } from '../components/FriendshipNetworkComponents';
|
||||||
|
import NetworkSidebar from '../components/NetworkSidebar';
|
||||||
|
import CanvasGraph from '../components/CanvasGraph';
|
||||||
|
|
||||||
|
// Import types and constants
|
||||||
|
import {
|
||||||
|
PersonNode,
|
||||||
|
RelationshipEdge,
|
||||||
|
FormErrors,
|
||||||
|
NewPersonForm,
|
||||||
|
NewRelationshipForm,
|
||||||
|
RelationshipType,
|
||||||
|
} from '../types/types';
|
||||||
|
|
||||||
|
const RELATIONSHIP_COLORS: Record<RelationshipType, string> = {
|
||||||
|
freund: '#60A5FA', // Light blue
|
||||||
|
partner: '#F472B6', // Pink
|
||||||
|
familie: '#34D399', // Green
|
||||||
|
arbeitskolleg: '#FBBF24', // Yellow
|
||||||
|
custom: '#9CA3AF', // Gray
|
||||||
|
};
|
||||||
|
|
||||||
|
const RELATIONSHIP_LABELS: Record<RelationshipType, string> = {
|
||||||
|
freund: 'Friend',
|
||||||
|
partner: 'Partner',
|
||||||
|
familie: 'Family',
|
||||||
|
arbeitskolleg: 'Colleague',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
darkMode: true,
|
||||||
|
autoLayout: true,
|
||||||
|
showLabels: true,
|
||||||
|
animationSpeed: 'medium',
|
||||||
|
highlightConnections: true,
|
||||||
|
nodeSize: 'medium',
|
||||||
|
};
|
||||||
|
|
||||||
|
import { useToastNotifications } from '../hooks/useToastNotifications';
|
||||||
|
import { useGraphDimensions } from '../hooks/useGraphDimensions';
|
||||||
|
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||||
|
import { useSmartNodePositioning } from '../hooks/useSmartNodePositioning';
|
||||||
|
|
||||||
|
// Import modals
|
||||||
|
import {
|
||||||
|
PersonFormModal,
|
||||||
|
RelationshipFormModal,
|
||||||
|
PersonDetailModal,
|
||||||
|
SettingsModal,
|
||||||
|
HelpModal,
|
||||||
|
} from '../components/Modals';
|
||||||
|
|
||||||
|
// Import UI components
|
||||||
|
import { LoadingSpinner } from '../components/UIComponents';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main FriendshipNetwork component
|
||||||
|
*/
|
||||||
|
const FriendshipNetwork: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { networks } = useNetworks();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Network data state from custom hook
|
||||||
|
const {
|
||||||
|
people,
|
||||||
|
relationships,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
createPerson,
|
||||||
|
updatePerson,
|
||||||
|
deletePerson,
|
||||||
|
createRelationship,
|
||||||
|
deleteRelationship,
|
||||||
|
refreshNetwork,
|
||||||
|
updatePersonPosition: updatePersonPositionImpl = (
|
||||||
|
id: string,
|
||||||
|
position: { x: number; y: number }
|
||||||
|
) => {
|
||||||
|
console.warn('updatePersonPosition not implemented');
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
} = useFriendshipNetwork(id || null) as any;
|
||||||
|
|
||||||
|
// Create a type-safe wrapper for updatePersonPosition
|
||||||
|
const updatePersonPosition = (id: string, position: { x: number; y: number }) => {
|
||||||
|
return updatePersonPositionImpl(id, position);
|
||||||
|
};
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [sidebarTab, setSidebarTab] = useState('people');
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
|
const [interactionHint, setInteractionHint] = useState(true);
|
||||||
|
const [selectedPersonId, setSelectedPersonId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Custom hooks
|
||||||
|
const { toasts, addToast, removeToast } = useToastNotifications();
|
||||||
|
const graphDimensions = useGraphDimensions(graphContainerRef, sidebarOpen);
|
||||||
|
const getSmartNodePosition = useSmartNodePositioning(
|
||||||
|
graphDimensions.width,
|
||||||
|
graphDimensions.height,
|
||||||
|
people.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [personModalOpen, setPersonModalOpen] = useState(false);
|
||||||
|
const [relationshipModalOpen, setRelationshipModalOpen] = useState(false);
|
||||||
|
const [personDetailModalOpen, setPersonDetailModalOpen] = useState(false);
|
||||||
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [helpModalOpen, setHelpModalOpen] = useState(false);
|
||||||
|
const [itemToDelete, setItemToDelete] = useState<{ type: string; id: string }>({
|
||||||
|
type: '',
|
||||||
|
id: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form errors
|
||||||
|
const [personFormErrors, setPersonFormErrors] = useState<FormErrors>({});
|
||||||
|
const [relationshipFormErrors, setRelationshipFormErrors] = useState<FormErrors>({});
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [newPerson, setNewPerson] = useState<NewPersonForm>({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
birthday: null,
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [editPerson, setEditPerson] = useState<PersonNode | null>(null);
|
||||||
|
|
||||||
|
const [newRelationship, setNewRelationship] = useState<NewRelationshipForm>({
|
||||||
|
source: '',
|
||||||
|
target: '',
|
||||||
|
type: 'freund',
|
||||||
|
customType: '',
|
||||||
|
notes: '',
|
||||||
|
bidirectional: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter states
|
||||||
|
const [peopleFilter, setPeopleFilter] = useState('');
|
||||||
|
const [relationshipFilter, setRelationshipFilter] = useState('');
|
||||||
|
const [relationshipTypeFilter, setRelationshipTypeFilter] = useState('all');
|
||||||
|
|
||||||
|
// Settings state
|
||||||
|
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
|
||||||
|
|
||||||
|
// Get current network info
|
||||||
|
const currentNetwork = networks.find(network => network._id === id);
|
||||||
|
|
||||||
|
// Dismiss interaction hint after 10 seconds
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (interactionHint) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setInteractionHint(false);
|
||||||
|
}, 10000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [interactionHint]);
|
||||||
|
|
||||||
|
// Register keyboard shortcuts
|
||||||
|
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 0.2, 2.5));
|
||||||
|
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 0.2, 0.5));
|
||||||
|
const handleResetZoom = () => setZoomLevel(1);
|
||||||
|
const toggleSidebar = () => setSidebarOpen(!sidebarOpen);
|
||||||
|
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
handleZoomIn,
|
||||||
|
handleZoomOut,
|
||||||
|
handleResetZoom,
|
||||||
|
toggleSidebar,
|
||||||
|
setPersonModalOpen,
|
||||||
|
setRelationshipModalOpen,
|
||||||
|
setHelpModalOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform API data to graph format
|
||||||
|
const getGraphData = useCallback(() => {
|
||||||
|
if (!people || !relationships) {
|
||||||
|
return { nodes: [], edges: [], links: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create nodes
|
||||||
|
const graphNodes = people.map((person: PersonNode) => {
|
||||||
|
const connectionCount = relationships.filter(
|
||||||
|
(r: RelationshipEdge) => r.source === person._id || r.target === person._id
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Determine if node should be highlighted
|
||||||
|
const isSelected = person._id === selectedPersonId;
|
||||||
|
const isConnected = selectedPersonId
|
||||||
|
? relationships.some(
|
||||||
|
(r: RelationshipEdge) =>
|
||||||
|
(r.source === selectedPersonId && r.target === person._id) ||
|
||||||
|
(r.target === selectedPersonId && r.source === person._id)
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Determine background color based on connection count or highlight state
|
||||||
|
let bgColor;
|
||||||
|
if (isSelected) {
|
||||||
|
bgColor = '#F472B6'; // Pink-400 for selected
|
||||||
|
} else if (isConnected && settings.highlightConnections) {
|
||||||
|
bgColor = '#A78BFA'; // Violet-400 for connected
|
||||||
|
} else if (connectionCount === 0) {
|
||||||
|
bgColor = '#94A3B8'; // Slate-400
|
||||||
|
} else if (connectionCount === 1) {
|
||||||
|
bgColor = '#38BDF8'; // Sky-400
|
||||||
|
} else if (connectionCount <= 3) {
|
||||||
|
bgColor = '#818CF8'; // Indigo-400
|
||||||
|
} else if (connectionCount <= 5) {
|
||||||
|
bgColor = '#A78BFA'; // Violet-400
|
||||||
|
} else {
|
||||||
|
bgColor = '#F472B6'; // Pink-400
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: person._id,
|
||||||
|
firstName: person.firstName,
|
||||||
|
lastName: person.lastName,
|
||||||
|
connectionCount,
|
||||||
|
bgColor,
|
||||||
|
x: person.position?.x || 0,
|
||||||
|
y: person.position?.y || 0,
|
||||||
|
showLabel: settings.showLabels,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create edges
|
||||||
|
const graphEdges = relationships.map((rel: RelationshipEdge) => {
|
||||||
|
const color = RELATIONSHIP_COLORS[rel.type] || RELATIONSHIP_COLORS.custom;
|
||||||
|
const width = rel.type === 'partner' ? 4 : rel.type === 'familie' ? 3 : 2;
|
||||||
|
|
||||||
|
// Highlight edges connected to selected node
|
||||||
|
const isHighlighted =
|
||||||
|
selectedPersonId &&
|
||||||
|
settings.highlightConnections &&
|
||||||
|
(rel.source === selectedPersonId || rel.target === selectedPersonId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: rel._id,
|
||||||
|
source: rel.source,
|
||||||
|
target: rel.target,
|
||||||
|
color: isHighlighted ? '#F472B6' : color, // Pink color for highlighted edges
|
||||||
|
width: isHighlighted ? width + 1 : width, // Slightly thicker for highlighted
|
||||||
|
type: rel.type,
|
||||||
|
customType: rel.customType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// For compatibility with CustomGraphData
|
||||||
|
return {
|
||||||
|
nodes: graphNodes,
|
||||||
|
edges: graphEdges,
|
||||||
|
links: graphEdges, // Duplicate edges as links for compatibility
|
||||||
|
};
|
||||||
|
}, [people, relationships, settings.showLabels, settings.highlightConnections, selectedPersonId]);
|
||||||
|
|
||||||
|
// Form validation functions
|
||||||
|
const validatePersonForm = (person: typeof newPerson): FormErrors => {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
|
||||||
|
if (!person.firstName.trim()) {
|
||||||
|
errors.firstName = 'First name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!person.lastName.trim()) {
|
||||||
|
errors.lastName = 'Last name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateRelationshipForm = (relationship: typeof newRelationship): FormErrors => {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
|
||||||
|
if (!relationship.source) {
|
||||||
|
errors.source = 'Source person is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relationship.target) {
|
||||||
|
errors.target = 'Target person is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationship.source === relationship.target) {
|
||||||
|
errors.target = 'Source and target cannot be the same person';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationship.type === 'custom' && !relationship.customType.trim()) {
|
||||||
|
errors.customType = 'Custom relationship type is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if relationship already exists
|
||||||
|
if (relationship.source && relationship.target) {
|
||||||
|
const existingRelationship = relationships.find(
|
||||||
|
(r: RelationshipEdge) =>
|
||||||
|
(r.source === relationship.source && r.target === relationship.target) ||
|
||||||
|
(relationship.bidirectional &&
|
||||||
|
r.source === relationship.target &&
|
||||||
|
r.target === relationship.source)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingRelationship) {
|
||||||
|
errors.general = 'This relationship already exists';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handlePersonSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const errors = validatePersonForm(newPerson);
|
||||||
|
setPersonFormErrors(errors);
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
|
// Create person with smart positioning
|
||||||
|
const position = getSmartNodePosition();
|
||||||
|
|
||||||
|
createPerson({
|
||||||
|
firstName: newPerson.firstName.trim(),
|
||||||
|
lastName: newPerson.lastName.trim(),
|
||||||
|
birthday: newPerson.birthday?.toISOString() || undefined,
|
||||||
|
notes: newPerson.notes,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form and close modal
|
||||||
|
setNewPerson({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
birthday: null,
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
setPersonModalOpen(false);
|
||||||
|
addToast('Person added successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdatePerson = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!editPerson) return;
|
||||||
|
|
||||||
|
const errors = validatePersonForm(editPerson as any);
|
||||||
|
setPersonFormErrors(errors);
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
|
updatePerson(editPerson._id, {
|
||||||
|
firstName: editPerson.firstName,
|
||||||
|
lastName: editPerson.lastName,
|
||||||
|
birthday: editPerson.birthday ? new Date(editPerson.birthday).toISOString() : undefined,
|
||||||
|
notes: editPerson.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
setEditPerson(null);
|
||||||
|
setPersonDetailModalOpen(false);
|
||||||
|
addToast('Person updated successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRelationshipSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const errors = validateRelationshipForm(newRelationship);
|
||||||
|
setRelationshipFormErrors(errors);
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
|
const { source, target, type, customType, notes, bidirectional } = newRelationship;
|
||||||
|
|
||||||
|
// Create the relationship
|
||||||
|
createRelationship({
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
type,
|
||||||
|
customType: type === 'custom' ? customType : undefined,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create bidirectional relationship if selected
|
||||||
|
if (bidirectional && source !== target) {
|
||||||
|
createRelationship({
|
||||||
|
source: target,
|
||||||
|
target: source,
|
||||||
|
type,
|
||||||
|
customType: type === 'custom' ? customType : undefined,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form and close modal
|
||||||
|
setNewRelationship({
|
||||||
|
source: '',
|
||||||
|
target: '',
|
||||||
|
type: 'freund',
|
||||||
|
customType: '',
|
||||||
|
notes: '',
|
||||||
|
bidirectional: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setRelationshipModalOpen(false);
|
||||||
|
addToast(`Relationship${bidirectional ? 's' : ''} created successfully`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common actions
|
||||||
|
const confirmDelete = (type: string, id: string) => {
|
||||||
|
setItemToDelete({ type, id });
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeDelete = () => {
|
||||||
|
const { type, id } = itemToDelete;
|
||||||
|
|
||||||
|
if (type === 'person') {
|
||||||
|
deletePerson(id);
|
||||||
|
addToast('Person deleted');
|
||||||
|
} else if (type === 'relationship') {
|
||||||
|
deleteRelationship(id);
|
||||||
|
addToast('Relationship deleted');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPersonDetail = (person: PersonNode) => {
|
||||||
|
setEditPerson({ ...person });
|
||||||
|
setPersonDetailModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshNetwork = () => {
|
||||||
|
refreshNetwork();
|
||||||
|
addToast('Network refreshed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeClick = (nodeId: string) => {
|
||||||
|
// Toggle selection
|
||||||
|
if (selectedPersonId === nodeId) {
|
||||||
|
setSelectedPersonId(null);
|
||||||
|
} else {
|
||||||
|
setSelectedPersonId(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open person details
|
||||||
|
const person = people.find((p: PersonNode) => p._id === nodeId);
|
||||||
|
if (person) {
|
||||||
|
openPersonDetail(person);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort people alphabetically
|
||||||
|
const sortedPeople = [...people].sort((a: PersonNode, b: PersonNode) => {
|
||||||
|
const nameA = `${a.firstName} ${a.lastName}`.toLowerCase();
|
||||||
|
const nameB = `${b.firstName} ${b.lastName}`.toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-screen bg-slate-900">
|
||||||
|
<LoadingSpinner message="Loading your network..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-screen bg-slate-900">
|
||||||
|
<div className="bg-red-500/20 border border-red-500 text-white p-6 rounded-lg shadow-lg max-w-md">
|
||||||
|
<h3 className="text-lg font-bold mb-3 flex items-center">
|
||||||
|
<FaExclamationTriangle className="mr-2 text-red-500" /> Error
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4">{error}</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => navigate('/networks')}
|
||||||
|
icon={<FaArrowLeft />}
|
||||||
|
>
|
||||||
|
Back to Networks
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate graph data
|
||||||
|
const graphData = getGraphData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full h-full-important bg-slate-900 text-white overflow-hidden">
|
||||||
|
{/* Network Sidebar Component */}
|
||||||
|
<NetworkSidebar
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
currentNetwork={currentNetwork}
|
||||||
|
sidebarTab={sidebarTab}
|
||||||
|
people={people}
|
||||||
|
relationships={relationships}
|
||||||
|
selectedPersonId={selectedPersonId}
|
||||||
|
peopleFilter={peopleFilter}
|
||||||
|
relationshipFilter={relationshipFilter}
|
||||||
|
relationshipTypeFilter={relationshipTypeFilter}
|
||||||
|
onTabChange={setSidebarTab}
|
||||||
|
onPeopleFilterChange={setPeopleFilter}
|
||||||
|
onRelationshipFilterChange={setRelationshipFilter}
|
||||||
|
onRelationshipTypeFilterChange={setRelationshipTypeFilter}
|
||||||
|
onAddPerson={() => setPersonModalOpen(true)}
|
||||||
|
onAddRelationship={() => setRelationshipModalOpen(true)}
|
||||||
|
onOpenSettings={() => setSettingsModalOpen(true)}
|
||||||
|
onOpenHelp={() => setHelpModalOpen(true)}
|
||||||
|
onPersonDelete={id => confirmDelete('person', id)}
|
||||||
|
onRelationshipDelete={id => confirmDelete('relationship', id)}
|
||||||
|
onOpenPersonDetail={person => {
|
||||||
|
openPersonDetail(person);
|
||||||
|
setSelectedPersonId(person._id);
|
||||||
|
}}
|
||||||
|
onNavigateBack={() => navigate('/networks')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Graph Area */}
|
||||||
|
<div ref={graphContainerRef} className="flex-1 bg-slate-900 relative overflow-hidden">
|
||||||
|
{graphDimensions.width <= 0 || graphDimensions.height <= 0 ? (
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CanvasGraph
|
||||||
|
data={graphData}
|
||||||
|
width={graphDimensions.width}
|
||||||
|
height={graphDimensions.height}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeDrag={(nodeId, x, y) => {
|
||||||
|
updatePersonPosition(nodeId, { x, y }).then();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state overlay */}
|
||||||
|
{people.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-900/80 backdrop-blur-sm">
|
||||||
|
<div className="text-center max-w-md p-6">
|
||||||
|
<div className="inline-flex items-center justify-center p-4 bg-indigo-600/30 rounded-full mb-4">
|
||||||
|
<FaUserPlus className="text-3xl text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Start Building Your Network</h2>
|
||||||
|
<p className="text-slate-300 mb-6">
|
||||||
|
Add people and create relationships between them to visualize your network
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setPersonModalOpen(true)}
|
||||||
|
icon={<FaUserPlus />}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
Add Your First Person
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Interaction hint */}
|
||||||
|
{people.length > 0 && interactionHint && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-20 left-1/2 transform -translate-x-1/2 bg-indigo-900/90
|
||||||
|
text-white px-4 py-2 rounded-lg shadow-lg text-sm flex items-center space-x-2 animate-pulse"
|
||||||
|
>
|
||||||
|
<FaInfo className="text-indigo-400" />
|
||||||
|
<span>Click on a person to see details, drag to reposition</span>
|
||||||
|
<button
|
||||||
|
className="ml-2 text-indigo-400 hover:text-white"
|
||||||
|
onClick={() => setInteractionHint(false)}
|
||||||
|
>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick action buttons */}
|
||||||
|
<div className="absolute bottom-6 left-1/2 transform -translate-x-1/2 flex space-x-2">
|
||||||
|
<div
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700 text-white p-3 rounded-full shadow-lg transition-colors cursor-pointer"
|
||||||
|
onClick={() => setPersonModalOpen(true)}
|
||||||
|
title="Add Person (shortcut: n)"
|
||||||
|
>
|
||||||
|
<FaUserPlus />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="bg-pink-600 hover:bg-pink-700 text-white p-3 rounded-full shadow-lg transition-colors cursor-pointer"
|
||||||
|
onClick={() => setRelationshipModalOpen(true)}
|
||||||
|
title="Add Relationship (shortcut: r)"
|
||||||
|
>
|
||||||
|
<FaUserFriends />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<PersonFormModal
|
||||||
|
isOpen={personModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setPersonModalOpen(false);
|
||||||
|
setPersonFormErrors({});
|
||||||
|
}}
|
||||||
|
formData={newPerson}
|
||||||
|
setFormData={setNewPerson}
|
||||||
|
errors={personFormErrors}
|
||||||
|
onSubmit={handlePersonSubmit}
|
||||||
|
isEdit={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RelationshipFormModal
|
||||||
|
isOpen={relationshipModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setRelationshipModalOpen(false);
|
||||||
|
setRelationshipFormErrors({});
|
||||||
|
}}
|
||||||
|
formData={newRelationship}
|
||||||
|
setFormData={setNewRelationship}
|
||||||
|
errors={relationshipFormErrors}
|
||||||
|
onSubmit={handleRelationshipSubmit}
|
||||||
|
people={sortedPeople}
|
||||||
|
relationshipLabels={RELATIONSHIP_LABELS}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editPerson && (
|
||||||
|
<PersonDetailModal
|
||||||
|
isOpen={personDetailModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setPersonDetailModalOpen(false);
|
||||||
|
setEditPerson(null);
|
||||||
|
setPersonFormErrors({});
|
||||||
|
}}
|
||||||
|
person={editPerson}
|
||||||
|
setPerson={setEditPerson}
|
||||||
|
errors={personFormErrors}
|
||||||
|
onSubmit={handleUpdatePerson}
|
||||||
|
onDelete={id => {
|
||||||
|
confirmDelete('person', id);
|
||||||
|
setPersonDetailModalOpen(false);
|
||||||
|
}}
|
||||||
|
relationships={relationships}
|
||||||
|
people={people}
|
||||||
|
relationshipColors={RELATIONSHIP_COLORS}
|
||||||
|
relationshipLabels={RELATIONSHIP_LABELS}
|
||||||
|
onDeleteRelationship={deleteRelationship}
|
||||||
|
onAddNewConnection={() => {
|
||||||
|
setNewRelationship({
|
||||||
|
...newRelationship,
|
||||||
|
source: editPerson._id,
|
||||||
|
});
|
||||||
|
setPersonDetailModalOpen(false);
|
||||||
|
setTimeout(() => setRelationshipModalOpen(true), 100);
|
||||||
|
}}
|
||||||
|
onNavigateToPerson={personId => {
|
||||||
|
setSelectedPersonId(personId);
|
||||||
|
setPersonDetailModalOpen(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
const targetPerson = people.find((p: PersonNode) => p._id === personId);
|
||||||
|
if (targetPerson) openPersonDetail(targetPerson);
|
||||||
|
}, 100);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsModal
|
||||||
|
isOpen={settingsModalOpen}
|
||||||
|
onClose={() => setSettingsModalOpen(false)}
|
||||||
|
settings={settings}
|
||||||
|
setSettings={setSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HelpModal isOpen={helpModalOpen} onClose={() => setHelpModalOpen(false)} />
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={deleteConfirmOpen}
|
||||||
|
onClose={() => setDeleteConfirmOpen(false)}
|
||||||
|
onConfirm={executeDelete}
|
||||||
|
title="Confirm Deletion"
|
||||||
|
message={
|
||||||
|
itemToDelete.type === 'person'
|
||||||
|
? 'Are you sure you want to delete this person? This will also remove all their relationships.'
|
||||||
|
: 'Are you sure you want to delete this relationship?'
|
||||||
|
}
|
||||||
|
confirmText="Delete"
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Toast Notifications */}
|
||||||
|
<div className="fixed bottom-4 right-4 z-[9900] space-y-2 pointer-events-none">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<Toast
|
||||||
|
key={toast.id}
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type as any}
|
||||||
|
onClose={() => removeToast(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FriendshipNetwork;
|
@ -1,15 +0,0 @@
|
|||||||
import app from './app';
|
|
||||||
import connectDB from './config/db';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 5000;
|
|
||||||
|
|
||||||
// Connect to MongoDB
|
|
||||||
connectDB();
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`Server running on port ${PORT}`);
|
|
||||||
});
|
|
8
src/types/express.d.ts
vendored
8
src/types/express.d.ts
vendored
@ -1,8 +0,0 @@
|
|||||||
import { Request } from 'express';
|
|
||||||
import { IUser } from '../models/user.model';
|
|
||||||
import { INetwork } from '../models/network.model';
|
|
||||||
|
|
||||||
export interface UserRequest extends Request {
|
|
||||||
user?: IUser;
|
|
||||||
network?: INetwork;
|
|
||||||
}
|
|
85
src/types/types.ts
Normal file
85
src/types/types.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
export type RelationshipType = 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
|
||||||
|
|
||||||
|
export interface PersonNode {
|
||||||
|
_id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
birthday?: Date | string | null;
|
||||||
|
notes?: string;
|
||||||
|
position?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelationshipEdge {
|
||||||
|
_id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: RelationshipType;
|
||||||
|
customType?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
connectionCount: number;
|
||||||
|
bgColor: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
showLabel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
color: string;
|
||||||
|
width: number;
|
||||||
|
type: RelationshipType;
|
||||||
|
customType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasGraphData {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
edges: GraphEdge[];
|
||||||
|
links: GraphEdge[]; // Added for compatibility with CustomGraphData
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormErrors {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkSettings {
|
||||||
|
darkMode: boolean;
|
||||||
|
autoLayout: boolean;
|
||||||
|
showLabels: boolean;
|
||||||
|
animationSpeed: string;
|
||||||
|
highlightConnections: boolean;
|
||||||
|
nodeSize: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewPersonForm {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
birthday: Date | null;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewRelationshipForm {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: RelationshipType;
|
||||||
|
customType: string;
|
||||||
|
notes: string;
|
||||||
|
bidirectional: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: 'error' | 'success' | 'warning' | 'info';
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
@ -1,15 +1,36 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2018",
|
"target": "ES2020",
|
||||||
"module": "commonjs",
|
"useDefineForClassFields": true,
|
||||||
"outDir": "./dist",
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"rootDir": "./src",
|
"module": "ESNext",
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["frontend/*"],
|
||||||
|
"@server/*": ["server/*"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "**/*.test.ts"]
|
/* For Node.js compatibility */
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["frontend", "server", "src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
39
vite.config.ts
Normal file
39
vite.config.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
'@server': resolve(__dirname, 'server'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
'react',
|
||||||
|
'react-dom',
|
||||||
|
'react-router-dom',
|
||||||
|
'framer-motion',
|
||||||
|
'react-icons',
|
||||||
|
'react-force-graph-2d',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
manifest: true,
|
||||||
|
},
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user