]> PHS Git Server - phs-admin.git/commitdiff
Adding authentication, media, and messaging.
authorcharleswrayjr <charleswrayjr@gmail.com>
Sat, 13 Sep 2025 06:31:39 +0000 (01:31 -0500)
committercharleswrayjr <charleswrayjr@gmail.com>
Sat, 13 Sep 2025 06:31:39 +0000 (01:31 -0500)
36 files changed:
src/App.js
src/app/components/AppHeader.js [new file with mode: 0644]
src/app/components/AuthContext.js [new file with mode: 0644]
src/app/components/MediaForm.js [new file with mode: 0644]
src/app/components/MediaTable.js [new file with mode: 0644]
src/app/components/MessageForm.js [new file with mode: 0644]
src/app/components/MessageGroupForm.js [new file with mode: 0644]
src/app/components/MessageGroupMemberForm.js [new file with mode: 0644]
src/app/components/MessageGroupMembersTable.js [new file with mode: 0644]
src/app/components/MessageGroupsTable.js [new file with mode: 0644]
src/app/components/MessagesTable.js [new file with mode: 0644]
src/app/components/PostForm.js [new file with mode: 0644]
src/app/components/PostsTable.js [new file with mode: 0644]
src/app/routes.js
src/app/services/auth.js [new file with mode: 0644]
src/app/services/index.js
src/app/services/media.js [new file with mode: 0644]
src/app/services/message_group_members.js [new file with mode: 0644]
src/app/services/message_groups.js [new file with mode: 0644]
src/app/services/messages.js [new file with mode: 0644]
src/app/services/posts.js [new file with mode: 0644]
src/app/views/Dashboard/Dashboard.jsx
src/app/views/Login/Login.jsx
src/app/views/Media/MediaFormView.js [new file with mode: 0644]
src/app/views/Media/MediaView.js [new file with mode: 0644]
src/app/views/MessageGroupMembers/MessageGroupMemberFormView.js [new file with mode: 0644]
src/app/views/MessageGroupMembers/MessageGroupMembersView.js [new file with mode: 0644]
src/app/views/MessageGroups/MessageGroupFormView.js [new file with mode: 0644]
src/app/views/MessageGroups/MessageGroupsView.js [new file with mode: 0644]
src/app/views/Messages/ChatView.js [new file with mode: 0644]
src/app/views/Messages/MessageFormView.js [new file with mode: 0644]
src/app/views/Messages/MessagesView.js [new file with mode: 0644]
src/app/views/Posts/PostFormView.js [new file with mode: 0644]
src/app/views/Posts/PostsView.js [new file with mode: 0644]
src/app/views/VPN/VPN.jsx
src/styles/App.css

index 16de745933f0d9b3bf98673d04b5f8e39c7b261c..7a5a3db12df1f29c95226328d58ee71f225ebf2e 100755 (executable)
@@ -1,14 +1,28 @@
 import './styles/App.css';
 import AppRoutes from './app/routes';
+import React from 'react';
+import { BrowserRouter as Router } from 'react-router-dom';
+import { SnackbarProvider } from 'notistack';
+import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
 
-function App() {
+const theme = createTheme({
+  palette: {
+    primary: { main: '#1976d2' },
+    secondary: { main: '#dc004e' },
+  },
+});
+
+const App = () => {
   return (
-    <div className="App">
-      <header className="app-header">
-      </header>
-      { AppRoutes() }
-    </div>
+    <ThemeProvider theme={theme}>
+      <CssBaseline />
+      <SnackbarProvider maxSnack={3}>
+        <Router>
+          <AppRoutes />
+        </Router>
+      </SnackbarProvider>
+    </ThemeProvider>
   );
-}
+};
 
 export default App;
diff --git a/src/app/components/AppHeader.js b/src/app/components/AppHeader.js
new file mode 100644 (file)
index 0000000..365dd84
--- /dev/null
@@ -0,0 +1,57 @@
+/**
+ * @file Reusable header component with navigation
+ */
+
+import React, { useContext } from 'react';
+import { AppBar, Toolbar, Typography, Button, Box } from '@mui/material';
+import { useNavigate } from 'react-router-dom';
+import { AuthContext } from './AuthContext';
+
+const AppHeader = () => {
+  const navigate = useNavigate();
+  const { user, logout } = useContext(AuthContext);
+
+  const navItems = [
+    { label: 'Dashboard', path: '/', roles: ['User', 'Admin'] },
+    { label: 'Messages', path: '/messages', roles: ['User', 'Admin'] },
+    { label: 'Message Groups', path: '/message-groups', roles: ['User', 'Admin'] },
+    { label: 'Media', path: '/media', roles: ['User', 'Admin'] },
+    { label: 'Posts', path: '/posts', roles: ['User', 'Admin'] },
+    { label: 'Docker', path: '/docker', roles: ['Admin'] },
+  ];
+
+  return (
+    <AppBar position="static" className="bg-blue-600">
+      <Toolbar className="container">
+        <Typography variant="h6" component="div" className="flex-grow">
+          PHS Admin
+        </Typography>
+        <Box>
+          {navItems.map((item) => (
+            (!user || item.roles.some(role => user.roles?.includes(role))) && (
+              <Button
+                key={item.label}
+                color="inherit"
+                onClick={() => navigate(item.path)}
+                className="mx-2"
+              >
+                {item.label}
+              </Button>
+            )
+          ))}
+          {user ? (
+            <Button color="inherit" onClick={logout}>
+              Logout
+            </Button>
+          ) : (
+            <Button color="inherit" onClick={() => navigate('/login')}>
+              Login
+            </Button>
+          )}
+        </Box>
+      </Toolbar>
+    </AppBar>
+  );
+};
+
+export default AppHeader;
\ No newline at end of file
diff --git a/src/app/components/AuthContext.js b/src/app/components/AuthContext.js
new file mode 100644 (file)
index 0000000..290b9a9
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * @file Authentication context for user and role data
+ */
+
+import React, { createContext, useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useSnackbar } from 'notistack';
+import { AuthService } from '../services/auth';
+
+export const AuthContext = createContext();
+
+export const AuthProvider = ({ children }) => {
+  const [user, setUser] = useState(null);
+  const navigate = useNavigate();
+  const { enqueueSnackbar } = useSnackbar();
+
+  useEffect(() => {
+    const fetchUser = async () => {
+      try {
+        const token = localStorage.getItem('token');
+        if (token) {
+          const response = await AuthService.getCurrentUser();
+          setUser(response.user);
+        }
+      } catch (error) {
+        enqueueSnackbar(`Error fetching user: ${error.message}`, { variant: 'error' });
+      }
+    };
+    fetchUser();
+  }, [enqueueSnackbar]);
+
+  const login = async (email, password) => {
+    try {
+      const response = await AuthService.login(email, password);
+      if (response.success) {
+        localStorage.setItem('token', response.token);
+        setUser(response.user);
+        navigate('/');
+        return true;
+      }
+      return false;
+    } catch (error) {
+      enqueueSnackbar(`Login error: ${error.message}`, { variant: 'error' });
+      return false;
+    }
+  };
+
+  const logout = () => {
+    localStorage.removeItem('token');
+    setUser(null);
+    navigate('/login');
+  };
+
+  return (
+    <AuthContext.Provider value={{ user, setUser, login, logout }}>
+      {children}
+    </AuthContext.Provider>
+  );
+};
\ No newline at end of file
diff --git a/src/app/components/MediaForm.js b/src/app/components/MediaForm.js
new file mode 100644 (file)
index 0000000..7cd1f3e
--- /dev/null
@@ -0,0 +1,120 @@
+/**
+ * @file Component for creating a new media item
+ */
+
+import React from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+import { TextField, MenuItem, Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { MediaService } from '../services';
+
+const schema = yup.object({
+  user_id: yup.number().required('User ID is required'),
+  file_path: yup.string().required('File path is required'),
+  file_type: yup.string().required('File type is required'),
+  visibility: yup.string().oneOf(['private', 'family', 'public']).required('Visibility is required'),
+}).required();
+
+const MediaForm = () => {
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const { control, handleSubmit, formState: { errors } } = useForm({
+    resolver: yupResolver(schema),
+  });
+
+  const onSubmit = async (data) => {
+    try {
+      await MediaService.createMedia(data);
+      enqueueSnackbar('Media created successfully', { variant: 'success' });
+      navigate('/media');
+    } catch (error) {
+      enqueueSnackbar(`Error creating media: ${error.message}`, { variant: 'error' });
+    }
+  };
+
+  return (
+    <div className="container">
+      <h2 className="text-2xl font-bold mb-4">Upload Media</h2>
+      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+        <Controller
+          name="user_id"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="User ID"
+              type="number"
+              fullWidth
+              error={!!errors.user_id}
+              helperText={errors.user_id?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Controller
+          name="file_path"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="File Path"
+              fullWidth
+              error={!!errors.file_path}
+              helperText={errors.file_path?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Controller
+          name="file_type"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="File Type"
+              select
+              fullWidth
+              error={!!errors.file_type}
+              helperText={errors.file_type?.message}
+              className="mb-4"
+            >
+              <MenuItem value="image">Image</MenuItem>
+              <MenuItem value="video">Video</MenuItem>
+            </TextField>
+          )}
+        />
+        <Controller
+          name="visibility"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="Visibility"
+              select
+              fullWidth
+              error={!!errors.visibility}
+              helperText={errors.visibility?.message}
+              className="mb-4"
+            >
+              <MenuItem value="private">Private</MenuItem>
+              <MenuItem value="family">Family</MenuItem>
+              <MenuItem value="public">Public</MenuItem>
+            </TextField>
+          )}
+        />
+        <Button type="submit" variant="contained" color="primary" fullWidth>
+          Upload Media
+        </Button>
+      </form>
+    </div>
+  );
+};
+
+export default MediaForm;
\ No newline at end of file
diff --git a/src/app/components/MediaTable.js b/src/app/components/MediaTable.js
new file mode 100644 (file)
index 0000000..b1faff2
--- /dev/null
@@ -0,0 +1,97 @@
+/**
+ * @file Component for displaying media in a table
+ */
+
+import React, { useMemo, useState } from 'react';
+import { MaterialReactTable } from 'material-react-table';
+import { Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { mediaService } from '../services';
+import { formatDate } from '../utils';
+
+const MediaTable = () => {
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const [mediaItems, setMediaItems] = useState([]);
+
+  // Fetch media on mount
+  React.useEffect(() => {
+    const fetchMedia = async () => {
+      try {
+        const response = await mediaService.getMedia();
+        setMediaItems(response.data);
+      } catch (error) {
+        enqueueSnackbar(`Error fetching media: ${error.message}`, { variant: 'error' });
+      }
+    };
+    fetchMedia();
+  }, [enqueueSnackbar]);
+
+  const columns = useMemo(() => [
+    {
+      accessorKey: 'id',
+      header: 'ID',
+      size: 80,
+    },
+    {
+      accessorKey: 'user_id',
+      header: 'User ID',
+      size: 100,
+    },
+    {
+      accessorKey: 'file_path',
+      header: 'File Path',
+      size: 200,
+    },
+    {
+      accessorKey: 'file_type',
+      header: 'Type',
+      size: 100,
+    },
+    {
+      accessorKey: 'visibility',
+      header: 'Visibility',
+      size: 100,
+    },
+    {
+      accessorKey: 'created_at',
+      header: 'Created At',
+      size: 150,
+      Cell: ({ cell }) => formatDate(cell.getValue()),
+    },
+  ], []);
+
+  return (
+    <div className="container">
+      <h2 className="text-2xl font-bold mb-4">Media</h2>
+      <Button
+        variant="contained"
+        color="primary"
+        onClick={() => navigate('/media/new')}
+        className="mb-4"
+      >
+        Upload Media
+      </Button>
+      <MaterialReactTable
+        columns={columns}
+        data={mediaItems}
+        enableColumnActions={false}
+        enableColumnFilters={true}
+        enablePagination={true}
+        enableSorting={true}
+        muiTablePaperProps={{
+          className: 'shadow-md rounded-lg',
+        }}
+        muiTableHeadCellProps={{
+          className: 'table-header',
+        }}
+        muiTableBodyCellProps={{
+          className: 'table-row',
+        }}
+      />
+    </div>
+  );
+};
+
+export default MediaTable;
\ No newline at end of file
diff --git a/src/app/components/MessageForm.js b/src/app/components/MessageForm.js
new file mode 100644 (file)
index 0000000..dbc2f00
--- /dev/null
@@ -0,0 +1,104 @@
+/**
+ * @file Component for creating a new message
+ */
+
+import React from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+import { TextField, Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { messageService } from '../services';
+
+const schema = yup.object({
+  recipient_id: yup.number().nullable().when('group_id', {
+    is: (group_id) => !group_id,
+    then: yup.number().required('Either recipient_id or group_id is required'),
+  }),
+  group_id: yup.number().nullable().when('recipient_id', {
+    is: (recipient_id) => !recipient_id,
+    then: yup.number().required('Either recipient_id or group_id is required'),
+  }),
+  content: yup.string().required('Content is required'),
+}).required();
+
+const MessageForm = () => {
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const { control, handleSubmit, formState: { errors } } = useForm({
+    resolver: yupResolver(schema),
+  });
+
+  const onSubmit = async (data) => {
+    try {
+      await messageService.createMessage(data);
+      enqueueSnackbar('Message created successfully', { variant: 'success' });
+      navigate('/messages');
+    } catch (error) {
+      enqueueSnackbar(`Error creating message: ${error.message}`, { variant: 'error' });
+    }
+  };
+
+  return (
+    <div className="container">
+      <h2 className="text-2xl font-bold mb-4">New Message</h2>
+      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+        <Controller
+          name="recipient_id"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="Recipient ID"
+              type="number"
+              fullWidth
+              error={!!errors.recipient_id}
+              helperText={errors.recipient_id?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Controller
+          name="group_id"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="Group ID"
+              type="number"
+              fullWidth
+              error={!!errors.group_id}
+              helperText={errors.group_id?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Controller
+          name="content"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="Content"
+              multiline
+              rows={4}
+              fullWidth
+              error={!!errors.content}
+              helperText={errors.content?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Button type="submit" variant="contained" color="primary" fullWidth>
+          Send Message
+        </Button>
+      </form>
+    </div>
+  );
+};
+
+export default MessageForm;
\ No newline at end of file
diff --git a/src/app/components/MessageGroupForm.js b/src/app/components/MessageGroupForm.js
new file mode 100644 (file)
index 0000000..b312a55
--- /dev/null
@@ -0,0 +1,79 @@
+/**
+ * @file Component for creating a new message group
+ */
+
+import React from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+import { TextField, Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { MessageGroupService } from '../services';
+
+const schema = yup.object({
+  name: yup.string().required('Name is required'),
+  created_by_id: yup.number().required('Created by ID is required'),
+}).required();
+
+const MessageGroupForm = () => {
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const { control, handleSubmit, formState: { errors } } = useForm({
+    resolver: yupResolver(schema),
+  });
+
+  const onSubmit = async (data) => {
+    try {
+      await MessageGroupService.createMessageGroup(data);
+      enqueueSnackbar('Message group created successfully', { variant: 'success' });
+      navigate('/message-groups');
+    } catch (error) {
+      enqueueSnackbar(`Error creating message group: ${error.message}`, { variant: 'error' });
+    }
+  };
+
+  return (
+    <div className="container">
+      <h2 className="text-2xl font-bold mb-4">New Message Group</h2>
+      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+        <Controller
+          name="name"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="Group Name"
+              fullWidth
+              error={!!errors.name}
+              helperText={errors.name?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Controller
+          name="created_by_id"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="Created By ID"
+              type="number"
+              fullWidth
+              error={!!errors.created_by_id}
+              helperText={errors.created_by_id?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Button type="submit" variant="contained" color="primary" fullWidth>
+          Create Group
+        </Button>
+      </form>
+    </div>
+  );
+};
+
+export default MessageGroupForm;
\ No newline at end of file
diff --git a/src/app/components/MessageGroupMemberForm.js b/src/app/components/MessageGroupMemberForm.js
new file mode 100644 (file)
index 0000000..5e46d1c
--- /dev/null
@@ -0,0 +1,64 @@
+/**
+ * @file Component for adding a message group member
+ */
+
+import React from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+import { TextField, Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate, useParams } from 'react-router-dom';
+import { MessageGroupMembersService } from '../services';
+
+const schema = yup.object({
+  user_id: yup.number().required('User ID is required'),
+}).required();
+
+const MessageGroupMemberForm = () => {
+  const { group_id } = useParams();
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const { control, handleSubmit, formState: { errors } } = useForm({
+    resolver: yupResolver(schema),
+  });
+
+  const onSubmit = async (data) => {
+    try {
+      await MessageGroupMembersService.addMember({ ...data, group_id });
+      enqueueSnackbar('Member added successfully', { variant: 'success' });
+      navigate(`/message-groups/${group_id}/members`);
+    } catch (error) {
+      enqueueSnackbar(`Error adding member: ${error.message}`, { variant: 'error' });
+    }
+  };
+
+  return (
+    <div className="container">
+      <h2 className="text-2xl font-bold mb-4">Add Member to Group</h2>
+      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+        <Controller
+          name="user_id"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="User ID"
+              type="number"
+              fullWidth
+              error={!!errors.user_id}
+              helperText={errors.user_id?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Button type="submit" variant="contained" color="primary" fullWidth>
+          Add Member
+        </Button>
+      </form>
+    </div>
+  );
+};
+
+export default MessageGroupMemberForm;
\ No newline at end of file
diff --git a/src/app/components/MessageGroupMembersTable.js b/src/app/components/MessageGroupMembersTable.js
new file mode 100644 (file)
index 0000000..8fe0c52
--- /dev/null
@@ -0,0 +1,101 @@
+/**
+ * @file Component for displaying message group members in a table
+ */
+
+import React, { useMemo, useState } from 'react';
+import { MaterialReactTable } from 'material-react-table';
+import { Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useParams, useNavigate } from 'react-router-dom';
+import { messageGroupMembersService } from '../services';
+
+const MessageGroupMembersTable = () => {
+  const { group_id } = useParams();
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const [members, setMembers] = useState([]);
+
+  // Fetch members on mount
+  React.useEffect(() => {
+    const fetchMembers = async () => {
+      try {
+        const response = await messageGroupMembersService.getMembersByGroupId(group_id);
+        setMembers(response.data);
+      } catch (error) {
+        enqueueSnackbar(`Error fetching group members: ${error.message}`, { variant: 'error' });
+      }
+    };
+    fetchMembers();
+  }, [group_id, enqueueSnackbar]);
+
+  // Remove member
+  const handleRemoveMember = async (user_id) => {
+    try {
+      await messageGroupMembersService.removeMember({ user_id, group_id });
+      setMembers(members.filter(member => member.user_id !== user_id));
+      enqueueSnackbar('Member removed successfully', { variant: 'success' });
+    } catch (error) {
+      enqueueSnackbar(`Error removing member: ${error.message}`, { variant: 'error' });
+    }
+  };
+
+  const columns = useMemo(() => [
+    {
+      accessorKey: 'user_id',
+      header: 'User ID',
+      size: 100,
+    },
+    {
+      accessorKey: 'group_id',
+      header: 'Group ID',
+      size: 100,
+    },
+    {
+      accessorKey: 'user_id',
+      header: 'Actions',
+      size: 100,
+      Cell: ({ cell }) => (
+        <Button
+          variant="outlined"
+          color="error"
+          onClick={() => handleRemoveMember(cell.getValue())}
+        >
+          Remove
+        </Button>
+      ),
+    },
+  ], [members]);
+
+  return (
+    <div className="container">
+      <h2 className="text-2xl font-bold mb-4">Message Group Members</h2>
+      <Button
+        variant="contained"
+        color="primary"
+        onClick={() => navigate(`/message-groups/${group_id}/members/new`)}
+        className="mb-4"
+      >
+        Add Member
+      </Button>
+      <MaterialReactTable
+        columns={columns}
+        data={members}
+        enableColumnActions={false}
+        enableColumnFilters={true}
+        enablePagination={true}
+        enableSorting={true}
+        muiTablePaperProps={{
+          className: 'shadow-md rounded-lg',
+        }}
+        muiTableHeadCellProps={{
+          className: 'table-header',
+        }}
+        muiTableBodyCellProps={{
+          className: 'table-row',
+        }}
+      />
+    </div>
+  );
+};
+
+export default MessageGroupMembersTable;
\ No newline at end of file
diff --git a/src/app/components/MessageGroupsTable.js b/src/app/components/MessageGroupsTable.js
new file mode 100644 (file)
index 0000000..e962ad0
--- /dev/null
@@ -0,0 +1,110 @@
+/**
+ * @file Component for displaying message groups in a table
+ */
+
+import React, { useMemo, useState } from 'react';
+import { MaterialReactTable } from 'material-react-table';
+import { Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { messageGroupService } from '../services';
+import { formatDate } from '../utils';
+
+const MessageGroupsTable = () => {
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const [messageGroups, setMessageGroups] = useState([]);
+
+  // Fetch message groups on mount
+  React.useEffect(() => {
+    const fetchMessageGroups = async () => {
+      try {
+        const response = await messageGroupService.getMessageGroups();
+        setMessageGroups(response.data);
+      } catch (error) {
+        enqueueSnackbar(`Error fetching message groups: ${error.message}`, { variant: 'error' });
+      }
+    };
+    fetchMessageGroups();
+  }, [enqueueSnackbar]);
+
+  const columns = useMemo(() => [
+    {
+      accessorKey: 'id',
+      header: 'ID',
+      size: 80,
+    },
+    {
+      accessorKey: 'name',
+      header: 'Name',
+      size: 200,
+    },
+    {
+      accessorKey: 'created_by_id',
+      header: 'Created By',
+      size: 100,
+    },
+    {
+      accessorKey: 'created_at',
+      header: 'Created At',
+      size: 150,
+      Cell: ({ cell }) => formatDate(cell.getValue()),
+    },
+    {
+      accessorKey: 'id',
+      header: 'Actions',
+      size: 150,
+      Cell: ({ cell }) => (
+        <div className="space-x-2">
+          <Button
+            variant="outlined"
+            color="primary"
+            onClick={() => navigate(`/message-groups/${cell.getValue()}/members`)}
+          >
+            View Members
+          </Button>
+          <Button
+            variant="outlined"
+            color="primary"
+            onClick={() => navigate(`/messages?group_id=${cell.getValue()}`)}
+          >
+            View Messages
+          </Button>
+        </div>
+      ),
+    },
+  ], []);
+
+  return (
+    <div className="container">
+      <h2 className="text-2xl font-bold mb-4">Message Groups</h2>
+      <Button
+        variant="contained"
+        color="primary"
+        onClick={() => navigate('/message-groups/new')}
+        className="mb-4"
+      >
+        New Group
+      </Button>
+      <MaterialReactTable
+        columns={columns}
+        data={messageGroups}
+        enableColumnActions={false}
+        enableColumnFilters={true}
+        enablePagination={true}
+        enableSorting={true}
+        muiTablePaperProps={{
+          className: 'shadow-md rounded-lg',
+        }}
+        muiTableHeadCellProps={{
+          className: 'table-header',
+        }}
+        muiTableBodyCellProps={{
+          className: 'table-row',
+        }}
+      />
+    </div>
+  );
+};
+
+export default MessageGroupsTable;
\ No newline at end of file
diff --git a/src/app/components/MessagesTable.js b/src/app/components/MessagesTable.js
new file mode 100644 (file)
index 0000000..f76ba74
--- /dev/null
@@ -0,0 +1,129 @@
+/**
+ * @file Component for displaying messages in a table
+ */
+
+import React, { useMemo, useState } from 'react';
+import { MaterialReactTable } from 'material-react-table';
+import { Button, IconButton } from '@mui/material';
+import { MarkEmailRead } from '@mui/icons-material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { messageService } from '../services';
+import { formatDate } from '../utils';
+
+const MessagesTable = ({ groupId, recipientId }) => {
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const [messages, setMessages] = useState([]);
+
+  // Fetch messages on mount
+  React.useEffect(() => {
+    const fetchMessages = async () => {
+      try {
+        let response;
+        if (groupId) {
+          response = await messageService.getMessagesByGroupId(groupId);
+        } else if (recipientId) {
+          response = await messageService.getMessagesByRecipientId(recipientId);
+        } else {
+          response = await messageService.getMessages();
+        }
+        setMessages(response.data);
+      } catch (error) {
+        enqueueSnackbar(`Error fetching messages: ${error.message}`, { variant: 'error' });
+      }
+    };
+    fetchMessages();
+  }, [enqueueSnackbar, groupId, recipientId]);
+
+  // Mark message as read
+  const handleMarkAsRead = async (messageId) => {
+    try {
+      const response = await messageService.markMessageAsRead(messageId);
+      setMessages(messages.map(msg => msg.id === messageId ? response.data : msg));
+      enqueueSnackbar('Message marked as read', { variant: 'success' });
+    } catch (error) {
+      enqueueSnackbar(`Error marking message as read: ${error.message}`, { variant: 'error' });
+    }
+  };
+
+  const columns = useMemo(() => [
+    {
+      accessorKey: 'sender_id',
+      header: 'Sender ID',
+      size: 100,
+    },
+    {
+      accessorKey: 'content',
+      header: 'Content',
+      size: 300,
+    },
+    {
+      accessorKey: 'read',
+      header: 'Read',
+      size: 80,
+      Cell: ({ cell }) => (cell.getValue() ? 'Yes' : 'No'),
+    },
+    {
+      accessorKey: 'read_at',
+      header: 'Read At',
+      size: 150,
+      Cell: ({ cell }) => formatDate(cell.getValue()),
+    },
+    {
+      accessorKey: 'created_at',
+      header: 'Created At',
+      size: 150,
+      Cell: ({ cell }) => formatDate(cell.getValue()),
+    },
+    {
+      accessorKey: 'id',
+      header: 'Actions',
+      size: 100,
+      Cell: ({ cell }) => (
+        !cell.row.original.read && (
+          <IconButton
+            color="primary"
+            onClick={() => handleMarkAsRead(cell.getValue())}
+            title="Mark as Read"
+          >
+            <MarkEmailRead />
+          </IconButton>
+        )
+      ),
+    },
+  ], [messages]);
+
+  return (
+    <div className="container">
+      <h2 className="text-2xl font-bold mb-4">Messages</h2>
+      <Button
+        variant="contained"
+        color="primary"
+        onClick={() => navigate('/messages/new')}
+        className="mb-4"
+      >
+        New Message
+      </Button>
+      <MaterialReactTable
+        columns={columns}
+        data={messages}
+        enableColumnActions={false}
+        enableColumnFilters={true}
+        enablePagination={true}
+        enableSorting={true}
+        muiTablePaperProps={{
+          className: 'shadow-md rounded-lg',
+        }}
+        muiTableHeadCellProps={{
+          className: 'table-header',
+        }}
+        muiTableBodyCellProps={{
+          className: 'table-row',
+        }}
+      />
+    </div>
+  );
+};
+
+export default MessagesTable;
\ No newline at end of file
diff --git a/src/app/components/PostForm.js b/src/app/components/PostForm.js
new file mode 100644 (file)
index 0000000..24e8ec6
--- /dev/null
@@ -0,0 +1,138 @@
+/**
+ * @file Component for creating a new post
+ */
+
+import React from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { yupResolver } from '@hookform/resolvers/yup';
+import * as yup from 'yup';
+import { TextField, MenuItem, Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { PostService } from '../services';
+
+const schema = yup.object({
+  user_id: yup.number().required('User ID is required'),
+  title: yup.string().required('Title is required'),
+  content: yup.string().required('Content is required'),
+  post_type: yup.string().oneOf(['blog', 'vlog']).required('Post type is required'),
+  visibility: yup.string().oneOf(['private', 'family', 'public']).required('Visibility is required'),
+}).required();
+
+const PostForm = () => {
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const { control, handleSubmit, formState: { errors } } = useForm({
+    resolver: yupResolver(schema),
+  });
+
+  const onSubmit = async (data) => {
+    try {
+      await PostService.createPost(data);
+      enqueueSnackbar('Post created successfully', { variant: 'success' });
+      navigate('/posts');
+    } catch (error) {
+      enqueueSnackbar(`Error creating post: ${error.message}`, { variant: 'error' });
+    }
+  };
+
+  return (
+    <div className="container">
+      <h2 className="text-2xl font-bold mb-4">Create Post</h2>
+      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+        <Controller
+          name="user_id"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="User ID"
+              type="number"
+              fullWidth
+              error={!!errors.user_id}
+              helperText={errors.user_id?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Controller
+          name="title"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="Title"
+              fullWidth
+              error={!!errors.title}
+              helperText={errors.title?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Controller
+          name="content"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="Content"
+              multiline
+              rows={6}
+              fullWidth
+              error={!!errors.content}
+              helperText={errors.content?.message}
+              className="mb-4"
+            />
+          )}
+        />
+        <Controller
+          name="post_type"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="Post Type"
+              select
+              fullWidth
+              error={!!errors.post_type}
+              helperText={errors.post_type?.message}
+              className="mb-4"
+            >
+              <MenuItem value="blog">Blog</MenuItem>
+              <MenuItem value="vlog">Vlog</MenuItem>
+            </TextField>
+          )}
+        />
+        <Controller
+          name="visibility"
+          control={control}
+          defaultValue=""
+          render={({ field }) => (
+            <TextField
+              {...field}
+              label="Visibility"
+              select
+              fullWidth
+              error={!!errors.visibility}
+              helperText={errors.visibility?.message}
+              className="mb-4"
+            >
+              <MenuItem value="private">Private</MenuItem>
+              <MenuItem value="family">Family</MenuItem>
+              <MenuItem value="public">Public</MenuItem>
+            </TextField>
+          )}
+        />
+        <Button type="submit" variant="contained" color="primary" fullWidth>
+          Create Post
+        </Button>
+      </form>
+    </div>
+  );
+};
+
+export default PostForm;
\ No newline at end of file
diff --git a/src/app/components/PostsTable.js b/src/app/components/PostsTable.js
new file mode 100644 (file)
index 0000000..e344a04
--- /dev/null
@@ -0,0 +1,102 @@
+/**
+ * @file Component for displaying posts in a table
+ */
+
+import React, { useMemo, useState } from 'react';
+import { MaterialReactTable } from 'material-react-table';
+import { Button } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+import { postService } from '../services';
+import { formatDate } from '../utils';
+
+const PostsTable = () => {
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const [posts, setPosts] = useState([]);
+
+  // Fetch posts on mount
+  React.useEffect(() => {
+    const fetchPosts = async () => {
+      try {
+        const response = await postService.getPosts();
+        setPosts(response.data);
+      } catch (error) {
+        enqueueSnackbar(`Error fetching posts: ${error.message}`, { variant: 'error' });
+      }
+    };
+    fetchPosts();
+  }, [enqueueSnackbar]);
+
+  const columns = useMemo(() => [
+    {
+      accessorKey: 'id',
+      header: 'ID',
+      size: 80,
+    },
+    {
+      accessorKey: 'user_id',
+      header: 'User ID',
+      size: 100,
+    },
+    {
+      accessorKey: 'title',
+      header: 'Title',
+      size: 200,
+    },
+    {
+      accessorKey: 'content',
+      header: 'Content',
+      size: 300,
+    },
+    {
+      accessorKey: 'post_type',
+      header: 'Type',
+      size: 100,
+    },
+    {
+      accessorKey: 'visibility',
+      header: 'Visibility',
+      size: 100,
+    },
+    {
+      accessorKey: 'created_at',
+      header: 'Created At',
+      size: 150,
+      Cell: ({ cell }) => formatDate(cell.getValue()),
+    },
+  ], []);
+
+  return (
+    <div className="container">
+      <h2 className="text-2xl font-bold mb-4">Posts</h2>
+      <Button
+        variant="contained"
+        color="primary"
+        onClick={() => navigate('/posts/new')}
+        className="mb-4"
+      >
+        Create Post
+      </Button>
+      <MaterialReactTable
+        columns={columns}
+        data={posts}
+        enableColumnActions={false}
+        enableColumnFilters={true}
+        enablePagination={true}
+        enableSorting={true}
+        muiTablePaperProps={{
+          className: 'shadow-md rounded-lg',
+        }}
+        muiTableHeadCellProps={{
+          className: 'table-header',
+        }}
+        muiTableBodyCellProps={{
+          className: 'table-row',
+        }}
+      />
+    </div>
+  );
+};
+
+export default PostsTable;
\ No newline at end of file
index f31818d2b5468afc491e543188ceb343f0942911..8547ad6ffe5da8fe7e5dcad6f99a7604f568d008 100755 (executable)
@@ -1,6 +1,17 @@
 import { Route, Routes } from 'react-router-dom';
 import { Login, NotFound, Dashboard, Git, VPN, Docker } from './views';
 import { VPNProvider } from './components/';
+import MessagesView from './views/Messages/MessagesView';
+import MessageFormView from './views/Messages/MessageFormView';
+import ChatView from './views/Messages/ChatView';
+import MessageGroupsView from './views/MessageGroups/MessageGroupsView';
+import MessageGroupFormView from './views/MessageGroups/MessageGroupFormView';
+import MessageGroupMembersView from './views/MessageGroupMembers/MessageGroupMembersView';
+import MessageGroupMemberFormView from './views/MessageGroupMembers/MessageGroupMemberFormView';
+import MediaView from './views/Media/MediaView';
+import MediaFormView from './views/Media/MediaFormView';
+import PostsView from './views/Posts/PostsView';
+import PostFormView from './views/Posts/PostFormView';
 
 export default function AppRoutes() {
   return (
@@ -10,6 +21,18 @@ export default function AppRoutes() {
       <Route path="/git" element={ <Git /> } />
       <Route path="/vpn" element={ <VPNProvider><VPN/></VPNProvider> } />
       <Route path="/docker" element={ <Docker /> } />
+      <Route path="/messages" element={<MessagesView />} />
+      <Route path="/messages/new" element={<MessageFormView />} />
+      <Route path="/messages/group/:group_id" element={<ChatView />} />
+      <Route path="/messages/recipient/:recipient_id" element={<ChatView />} />
+      <Route path="/message-groups" element={<MessageGroupsView />} />
+      <Route path="/message-groups/new" element={<MessageGroupFormView />} />
+      <Route path="/message-groups/:group_id/members" element={<MessageGroupMembersView />} />
+      <Route path="/message-groups/:group_id/members/new" element={<MessageGroupMemberFormView />} />
+      <Route path="/media" element={<MediaView />} />
+      <Route path="/media/new" element={<MediaFormView />} />
+      <Route path="/posts" element={<PostsView />} />
+      <Route path="/posts/new" element={<PostFormView />} />
       <Route path="*" element={ <NotFound /> } />
     </Routes>
   );
diff --git a/src/app/services/auth.js b/src/app/services/auth.js
new file mode 100644 (file)
index 0000000..de99b9a
--- /dev/null
@@ -0,0 +1,22 @@
+/**
+ * @file Authentication service for login API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const AuthService = {
+  async login(email, password) {
+    const response = await axios.post(`${BASE_URL}/auth/authenticate`, { email, password });
+    return response.data;
+  },
+  async getCurrentUser() {
+    const response = await axios.get(`${BASE_URL}/user/1`, getAuthHeaders());
+    return { user: response.data };
+  },
+};
\ No newline at end of file
index 9c81015bd2313e8a867e2f50a2a87a9a2a130268..24eb00d659b2d0cceea08082aa072c1348b91d00 100644 (file)
@@ -2,4 +2,10 @@ import DockerService from './Docker/DockerService';
 import GitService from './Git/GitService';
 import VPNService from './VPN/VPNService';
 
+
+export { MessageService } from './messages';
+export { MediaService } from './media';
+export { MessageGroupService } from './message_groups';
+export { MessageGroupMembersService } from './message_group_members';
+export { PostService } from './posts';
 export { DockerService, GitService, VPNService };
\ No newline at end of file
diff --git a/src/app/services/media.js b/src/app/services/media.js
new file mode 100644 (file)
index 0000000..9057215
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * @file Media service for API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const MediaService = {
+  async getMedia(params = {}) {
+    return await axios.get(`${BASE_URL}/media`, { ...getAuthHeaders(), params });
+  },
+  async getMediaByFilePath(file_path) {
+    return await axios.get(`${BASE_URL}/media/file_path/${file_path}`, getAuthHeaders());
+  },
+  async getMediaById(id) {
+    return await axios.get(`${BASE_URL}/media/${id}`, getAuthHeaders());
+  },
+  async createMedia(data) {
+    return await axios.post(`${BASE_URL}/media/create`, data, getAuthHeaders());
+  },
+  async updateMedia(id, data) {
+    return await axios.put(`${BASE_URL}/media/${id}`, data, getAuthHeaders());
+  },
+  async softDeleteMedia(id, data) {
+    return await axios.put(`${BASE_URL}/media/${id}/soft_delete`, data, getAuthHeaders());
+  },
+};
\ No newline at end of file
diff --git a/src/app/services/message_group_members.js b/src/app/services/message_group_members.js
new file mode 100644 (file)
index 0000000..0d745bf
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * @file Message group members service for API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const MessageGroupMembersService = {
+  async getMembersByGroupId(group_id, params = {}) {
+    return await axios.get(`${BASE_URL}/message_group_members/group/${group_id}`, { ...getAuthHeaders(), params });
+  },
+  async getMemberByIds(group_id, user_id) {
+    return await axios.get(`${BASE_URL}/message_group_members/ids/${group_id}/${user_id}`, getAuthHeaders());
+  },
+  async addMember(data) {
+    return await axios.post(`${BASE_URL}/message_group_members/add`, data, getAuthHeaders());
+  },
+  async removeMember(data) {
+    return await axios.delete(`${BASE_URL}/message_group_members/remove`, { ...getAuthHeaders(), data });
+  },
+};
\ No newline at end of file
diff --git a/src/app/services/message_groups.js b/src/app/services/message_groups.js
new file mode 100644 (file)
index 0000000..42ab639
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * @file Message group service for API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const MessageGroupService = {
+  async getMessageGroups(params = {}) {
+    return await axios.get(`${BASE_URL}/message_group`, { ...getAuthHeaders(), params });
+  },
+  async getMessageGroup(id) {
+    return await axios.get(`${BASE_URL}/message_group/${id}`, getAuthHeaders());
+  },
+  async createMessageGroup(data) {
+    return await axios.post(`${BASE_URL}/message_group/create`, data, getAuthHeaders());
+  },
+  async updateMessageGroup(id, data) {
+    return await axios.put(`${BASE_URL}/message_group/${id}`, data, getAuthHeaders());
+  },
+};
\ No newline at end of file
diff --git a/src/app/services/messages.js b/src/app/services/messages.js
new file mode 100644 (file)
index 0000000..decbe25
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * @file Message service for API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const MessageService = {
+  async getMessages(params = {}) {
+    return await axios.get(`${BASE_URL}/message`, { ...getAuthHeaders(), params });
+  },
+  async getMessagesByGroupId(group_id, params = {}) {
+    return await axios.get(`${BASE_URL}/message/group/${group_id}`, { ...getAuthHeaders(), params });
+  },
+  async getMessagesByRecipientId(recipient_id, params = {}) {
+    return await axios.get(`${BASE_URL}/message/recipient/${recipient_id}`, { ...getAuthHeaders(), params });
+  },
+  async getMessage(id) {
+    return await axios.get(`${BASE_URL}/message/${id}`, getAuthHeaders());
+  },
+  async createMessage(data) {
+    return await axios.post(`${BASE_URL}/message/create`, data, getAuthHeaders());
+  },
+  async markMessageAsRead(id) {
+    return await axios.put(`${BASE_URL}/message/${id}/mark_as_read`, {}, getAuthHeaders());
+  },
+};
\ No newline at end of file
diff --git a/src/app/services/posts.js b/src/app/services/posts.js
new file mode 100644 (file)
index 0000000..a3211fc
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * @file Post service for API calls
+ */
+
+import axios from 'axios';
+
+const BASE_URL = 'http://localhost:3601';
+
+const getAuthHeaders = () => ({
+  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
+});
+
+export const PostService = {
+  async getPosts(params = {}) {
+    return await axios.get(`${BASE_URL}/post`, { ...getAuthHeaders(), params });
+  },
+  async getPostByTitle(title) {
+    return await axios.get(`${BASE_URL}/post/title/${title}`, getAuthHeaders());
+  },
+  async getPost(id) {
+    return await axios.get(`${BASE_URL}/post/${id}`, getAuthHeaders());
+  },
+  async createPost(data) {
+    return await axios.post(`${BASE_URL}/post/create`, data, getAuthHeaders());
+  },
+  async updatePost(id, data) {
+    return await axios.put(`${BASE_URL}/post/${id}`, data, getAuthHeaders());
+  },
+  async softDeletePost(id, data) {
+    return await axios.put(`${BASE_URL}/post/${id}/soft_delete`, data, getAuthHeaders());
+  },
+};
\ No newline at end of file
index 6157ab8235199fce62f40650015bacf0db1ce4ee..ebf390413001577c20cdb01544d4d34e8f1f9cf2 100755 (executable)
@@ -1,98 +1,60 @@
-import React, { useState, useEffect } from 'react';
-import { useMaterialReactTable, MaterialReactTable } from 'material-react-table';
-import { Container, IconButton } from '@mui/material';
-import { VisibilityOutlined } from '@mui/icons-material';
-// import { FormService } from '../../services';
-// import { FormDetail } from '../../components';
-import { getFormattedDate } from '../../utils';
-
-const Dashboard = () => {
-  const [data, setData] = useState( [] );
-  const [selectedFormID, setSelectedFormID] = useState( null );
-  const [detailOpen, setDetailOpen] = useState( false );
-  const [selectedForm, setSelectedForm] = useState( null );
+/**
+ * @file Dashboard view with navigation to main features
+ */
 
+import React, { useContext } from 'react';
+import { Container, Grid, Card, CardContent, Typography, Button } from '@mui/material';
+import { useNavigate } from 'react-router-dom';
+import { AuthContext } from '../../components/AuthContext';
+import AppHeader from '../../components/AppHeader';
 
-  // useEffect( () => {
-  //   FormService.get( null, null ).then( response => {
-  //     console.log('response', response);
-  //     setData(response);
-  //   });
-  // }, [] );
-  //
-  // useEffect( () => {
-  //   if (selectedFormID) {
-  //     FormService.get( null, selectedFormID ).then( response => {
-  //       setSelectedForm( response );
-  //     });
-  //   }
-  // }, [selectedFormID] );
-
-  const table = useMaterialReactTable({
-    columns: [
-      {
-        header: 'Actions',
-        id: 'woActions',
-        enableSorting: false,
-        enableGlobalFilter: false,
-        enableColumnFilter: false,
-        enableGrouping: false,
-        enableEditing: false,
-        size: 150,
-        accessorFn: (row) => {
-          const showSelectedModal = () => {
-            setSelectedFormID(row.id)
-            setDetailOpen(true);
-          };
+const Dashboard = () => {
+  const navigate = useNavigate();
+  const { user } = useContext(AuthContext);
 
-          return(
-            <IconButton size='small'
-                        color='primary'
-                        onClick={ showSelectedModal }>
-              <VisibilityOutlined fontSize="inherit" />
-            </IconButton>
-          )
-        },
-      },
-      {
-        header: 'ID',
-        accessorKey: 'id', //access nested data with dot notation
-      },
-      {
-        header:'Unit#',
-        accessorKey: 'unit_number', //simple function
-      },
-      {
-        header: 'Employee Name',
-        accessorKey: 'employee_name'
-      },
-      {
-        header: 'Employee Number',
-        accessorKey: 'employee_number'
-      },
-      {
-        header: 'Email Address',
-        accessorKey: 'email_address'
-      },
-      {
-        header: 'Photos',
-        accessorKey: 'photo_count'
-      },
-      {
-        header: 'Date',
-        accessorKey: 'created_at',
-        accessorFn: (row) => {
-          return getFormattedDate(row.created_at);
-        }
-      }
-    ],
-    data
-  });
-  return <Container maxWidth='xl' >
+  const features = [
+    { title: 'Messages', path: '/messages', description: 'View and send messages', roles: ['User', 'Admin'] },
+    { title: 'Message Groups', path: '/message-groups', description: 'Manage group chats', roles: ['User', 'Admin'] },
+    { title: 'Media', path: '/media', description: 'Upload and view media', roles: ['User', 'Admin'] },
+    { title: 'Posts', path: '/posts', description: 'Create and view posts', roles: ['User', 'Admin'] },
+    { title: 'Docker', path: '/docker', description: 'Manage Docker containers', roles: ['Admin'] },
+  ];
 
-    {/*<FormDetail isOpen={detailOpen} setOpen={setDetailOpen} inspection={selectedForm} setSelectedForm={setSelectedForm} setSelectedFormID={setSelectedFormID}/>*/}
-    <MaterialReactTable table={table} />
-  </Container>;
+  return (
+    <div>
+      <AppHeader />
+      <Container maxWidth="xl" className="container">
+        <Typography variant="h4" className="text-3xl font-bold mb-6 mt-4">
+          Welcome to PHS Admin{user ? `, ${user.first_name || 'User ' + user.id}` : ''}
+        </Typography>
+        <Grid container spacing={3}>
+          {features.map((feature) => (
+            (!user || feature.roles.some(role => user.roles?.includes(role))) && (
+              <Grid item xs={12} sm= {6} md={4} key={feature.title}>
+                <Card className="shadow-md rounded-lg">
+                  <CardContent>
+                    <Typography variant="h6" className="font-semibold">
+                      {feature.title}
+                    </Typography>
+                    <Typography variant="body2" className="text-gray-600 mb-4">
+                      {feature.description}
+                    </Typography>
+                    <Button
+                      variant="contained"
+                      color="primary"
+                      onClick={() => navigate(feature.path)}
+                    >
+                      Go to {feature.title}
+                    </Button>
+                  </CardContent>
+                </Card>
+              </Grid>
+            )
+          ))}
+        </Grid>
+      </Container>
+    </div>
+  );
 };
 
 export default Dashboard;
\ No newline at end of file
index 8ada8cf8ae76341abf125b783c6cb6f2e6e437a3..8b051780ed6f9b3e6ca367a79a033b534a3dc1d8 100755 (executable)
-import { useState } from 'react';
+/**
+ * @file Login view with authentication form
+ */
+
+import React, { useContext } from 'react';
+import { Container, TextField, Button, Typography, Box } from '@mui/material';
+import { useForm, Controller } from 'react-hook-form';
 import { yupResolver } from '@hookform/resolvers/yup';
-import { Controller, useForm } from 'react-hook-form';
-import {
-  Button,
-  Checkbox,
-  FormControl,
-  FormControlLabel,
-  FormHelperText,
-  TextField,
-  Typography,
-  Box,
-  Paper,
-  InputAdornment, IconButton
-} from '@mui/material';
 import * as yup from 'yup';
-import _ from '../../../@lodash';
-// import jwtService from '../../auth/services/jwtService';
-import { Visibility, VisibilityOff } from '@mui/icons-material';
+import { useSnackbar } from 'notistack';
 import { useNavigate } from 'react-router-dom';
+import { AuthContext } from '../../components/AuthContext';
+import AppHeader from '../../components/AppHeader';
 
-/**
- * Form Validation Schema
- */
-const schema = yup.object().shape( {
-  email:yup.string().email( 'You must enter a valid email' ).required( 'You must enter a email' ),
-  password:yup
-    .string()
-    .required( 'Please enter your password.' )
-    .min( 4, 'Password is too short - must be at least 4 chars.' ),
-} );
+const schema = yup.object({
+  email: yup.string().email('Invalid email').required('Email is required'),
+  password: yup.string().required('Password is required'),
+}).required();
 
-const defaultValues = {
-  email:'',
-  password:'',
-  remember:true,
-};
-
-function SignInPage() {
-  const { control, formState, handleSubmit, setError } = useForm( {
-    mode:'onChange',
-    defaultValues,
-    resolver:yupResolver( schema ),
-  } );
-  const [showPassword, setShowPassword] = useState( false );
-  const [signInError, setSignInError] = useState(null);
+const Login = () => {
+  const { enqueueSnackbar } = useSnackbar();
   const navigate = useNavigate();
-  const { isValid, dirtyFields, errors } = formState;
-
-  function onSubmit( { email, password } ) {
-    setSignInError(null);
-    /*jwtService
-      .signInWithEmailAndPassword( email, password )
-      .then( data => {
-        if(data.requires_2fa) {
-          navigate('/sign-in-verify', { state: { data: data } });
-        }
-      } )
-      .catch( (error) => {
-        const message = error?.response?.data?.message;
-        console.log(`Sign-in error: ${error}, sub error: ${message}`);
-        setSignInError(message);
-      } );*/
-  }
+  const { login } = useContext(AuthContext);
+  const { control, handleSubmit, formState: { errors } } = useForm({
+    resolver: yupResolver(schema),
+  });
+
+  const onSubmit = async (data) => {
+    const success = await login(data.email, data.password);
+    if (success) {
+      enqueueSnackbar('Login successful', { variant: 'success' });
+    } else {
+      enqueueSnackbar('Invalid credentials', { variant: 'error' });
+    }
+  };
 
   return (
-    <div
-      className="flex flex-col sm:flex-row items-center md:items-start sm:justify-center md:justify-start flex-1 min-w-0">
-      <Paper
-        className="h-full sm:h-auto md:flex md:items-center md:justify-end w-full sm:w-auto md:h-full md:w-1/2 py-8 px-16 sm:p-48 md:p-64 sm:rounded-2xl md:rounded-none sm:shadow md:shadow-none ltr:border-r-1 rtl:border-l-1">
-        <div className="w-full max-w-320 sm:w-320 mx-auto sm:mx-0">
-
-          <Typography fontSize={'2rem'} className="mt-32 text-4xl font-extrabold tracking-tight leading-tight">
-            Sign in
-          </Typography>
-
-          {signInError &&
-            <FormHelperText id="validation-helper-text" className="mt-24 mb-24 text-lg" error>
-              {signInError}
-            </FormHelperText>
-          }
-
-          <form
-            name="loginForm"
-            noValidate
-            className="flex flex-col justify-center w-full mt-32"
-            onSubmit={ handleSubmit( onSubmit ) }
-          >
-            <Controller
-              name="employee_number"
-              control={ control }
-              render={ ( { field } ) => (
-                <TextField
-                  { ...field }
-                  className="mb-24"
-                  label="Employee Number"
-                  autoFocus
-                  type="text"
-                  error={ !!errors.employee_number }
-                  helperText={ errors?.employee_number?.message }
-                  variant="outlined"
-                  required
-                  fullWidth
-                />
-              ) }
-            />
-
-            <Controller
-              name="password"
-              control={ control }
-              render={ ( { field } ) => (
-                <TextField
-                  { ...field }
-                  className="mb-6"
-                  label="Password"
-                  type={ showPassword ? 'text' : 'password' }
-                  error={ !!errors.password }
-                  inputProps={{
-                    'aria-describedby': errors.password ? 'password-helper-text' : undefined,
-                  }}
-                  InputProps={{
-                    endAdornment:
-                      <InputAdornment position='end' >
-                        <IconButton aria-label='toggle password visibility' onClick={ () => setShowPassword( prevState => !prevState ) }>
-                          { showPassword ? <VisibilityOff/> : <Visibility/> }
-                        </IconButton>
-                      </InputAdornment>
-                  }}
-                  variant="outlined"
-                  required
-                  fullWidth
-                />
-              ) }
-            />
-            {errors?.password && (
-              <FormHelperText id="password-helper-text" className="mb-24" error>
-                {errors.password.message}
-              </FormHelperText>
+    <div>
+      <AppHeader />
+      <Container maxWidth="sm" className="container">
+        <Typography variant="h4" className="text-3xl font-bold mb-6 mt-4">
+          Login to PHS Admin
+        </Typography>
+        <Box component="form" onSubmit={handleSubmit(onSubmit)} className="space-y-4">
+          <Controller
+            name="email"
+            control={control}
+            defaultValue=""
+            render={({ field }) => (
+              <TextField
+                {...field}
+                label="Email"
+                fullWidth
+                error={!!errors.email}
+                helperText={errors.email?.message}
+                className="mb-4"
+              />
             )}
-
-            <div className="flex flex-col sm:flex-row items-center justify-center sm:justify-between">
-              <Controller
-                name="remember"
-                control={ control }
-                render={ ( { field } ) => (
-                  <FormControl>
-                    <FormControlLabel
-                      label="Remember me"
-                      control={ <Checkbox size="small" { ...field } /> }
-                    />
-                  </FormControl>
-                ) }
+          />
+          <Controller
+            name="password"
+            control={control}
+            defaultValue=""
+            render={({ field }) => (
+              <TextField
+                {...field}
+                label="Password"
+                type="password"
+                fullWidth
+                error={!!errors.password}
+                helperText={errors.password?.message}
+                className="mb-4"
               />
-            </div>
-
-            <Button
-              variant="contained"
-              color="secondary"
-              className=" w-full mt-16"
-              aria-label="Sign in"
-              disabled={ _.isEmpty( dirtyFields ) || !isValid }
-              type="submit"
-              size="large"
-            >Sign in</Button>
-
-            <Button
-              variant="outlined"
-              color="primary"
-              className=" w-full mt-16"
-              aria-label="Forgot Password"
-              size="large"
-              onClick={() => {
-                navigate('/forgot-password');
-              }}
-            >Forgot Password</Button>
-
-          </form>
-        </div>
-      </Paper>
-
-      <Box
-        className="relative hidden md:flex flex-auto items-center justify-center h-full p-64 lg:px-112 overflow-hidden"
-        sx={ { backgroundColor:'primary.main' } }
-      >
-        <svg
-          className="absolute inset-0 pointer-events-none"
-          viewBox="0 0 960 540"
-          width="100%"
-          height="100%"
-          preserveAspectRatio="xMidYMax slice"
-          xmlns="http://www.w3.org/2000/svg"
-        >
-          <Box
-            component="g"
-            sx={ { color:'primary.light' } }
-            className="opacity-20"
-            fill="none"
-            stroke="currentColor"
-            strokeWidth="100"
-          >
-            <circle r="234" cx="196" cy="23"/>
-            <circle r="234" cx="790" cy="491"/>
-          </Box>
-        </svg>
-        <Box
-          component="svg"
-          className="absolute -top-64 -right-64 opacity-20"
-          sx={ { color:'primary.light' } }
-          viewBox="0 0 220 192"
-          width="220px"
-          height="192px"
-          fill="none"
-        >
-          <defs>
-            <pattern
-              id="837c3e70-6c3a-44e6-8854-cc48c737b659"
-              x="0"
-              y="0"
-              width="20"
-              height="20"
-              patternUnits="userSpaceOnUse"
-            >
-              <rect x="0" y="0" width="4" height="4" fill="currentColor"/>
-            </pattern>
-          </defs>
-          <rect width="220" height="192" fill="url(#837c3e70-6c3a-44e6-8854-cc48c737b659)"/>
+            )}
+          />
+          <Button type="submit" variant="contained" color="primary" fullWidth>
+            Login
+          </Button>
         </Box>
-
-        <div className="z-10 relative w-full max-w-2xl">
-          <div className="text-7xl font-bold leading-none text-grey-100">
-            <div>Please Login</div>
-          </div>
-          <div className="mt-24 text-lg tracking-tight leading-6 text-grey-400">
-            Enter your company employee number and password to log in.
-          </div>
-        </div>
-      </Box>
+      </Container>
     </div>
   );
-}
+};
 
-export default SignInPage;
+export default Login;
\ No newline at end of file
diff --git a/src/app/views/Media/MediaFormView.js b/src/app/views/Media/MediaFormView.js
new file mode 100644 (file)
index 0000000..766900e
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ * @file View for creating a new media item
+ */
+
+import React from 'react';
+import MediaForm from '../../components/MediaForm';
+
+const MediaFormView = () => {
+  return (
+    <div className="container">
+      <MediaForm />
+    </div>
+  );
+};
+
+export default MediaFormView;
\ No newline at end of file
diff --git a/src/app/views/Media/MediaView.js b/src/app/views/Media/MediaView.js
new file mode 100644 (file)
index 0000000..e465f3e
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ * @file View for displaying media
+ */
+
+import React from 'react';
+import MediaTable from '../../components/MediaTable';
+
+const MediaView = () => {
+  return (
+    <div className="container">
+      <MediaTable />
+    </div>
+  );
+};
+
+export default MediaView;
\ No newline at end of file
diff --git a/src/app/views/MessageGroupMembers/MessageGroupMemberFormView.js b/src/app/views/MessageGroupMembers/MessageGroupMemberFormView.js
new file mode 100644 (file)
index 0000000..bc1082d
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ * @file View for adding a message group member
+ */
+
+import React from 'react';
+import MessageGroupMemberForm from '../../components/MessageGroupMemberForm';
+
+const MessageGroupMemberFormView = () => {
+  return (
+    <div className="container">
+      <MessageGroupMemberForm />
+    </div>
+  );
+};
+
+export default MessageGroupMemberFormView;
\ No newline at end of file
diff --git a/src/app/views/MessageGroupMembers/MessageGroupMembersView.js b/src/app/views/MessageGroupMembers/MessageGroupMembersView.js
new file mode 100644 (file)
index 0000000..71c532f
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ * @file View for displaying message group members
+ */
+
+import React from 'react';
+import MessageGroupMembersTable from '../../components/MessageGroupMembersTable';
+
+const MessageGroupMembersView = () => {
+  return (
+    <div className="container">
+      <MessageGroupMembersTable />
+    </div>
+  );
+};
+
+export default MessageGroupMembersView;
\ No newline at end of file
diff --git a/src/app/views/MessageGroups/MessageGroupFormView.js b/src/app/views/MessageGroups/MessageGroupFormView.js
new file mode 100644 (file)
index 0000000..3572fc8
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ * @file View for creating a new message group
+ */
+
+import React from 'react';
+import MessageGroupForm from '../../components/MessageGroupForm';
+
+const MessageGroupFormView = () => {
+  return (
+    <div className="container">
+      <MessageGroupForm />
+    </div>
+  );
+};
+
+export default MessageGroupFormView;
\ No newline at end of file
diff --git a/src/app/views/MessageGroups/MessageGroupsView.js b/src/app/views/MessageGroups/MessageGroupsView.js
new file mode 100644 (file)
index 0000000..4715eca
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ * @file View for displaying message groups
+ */
+
+import React from 'react';
+import MessageGroupsTable from '../../components/MessageGroupsTable';
+
+const MessageGroupsView = () => {
+  return (
+    <div className="container">
+      <MessageGroupsTable />
+    </div>
+  );
+};
+
+export default MessageGroupsView;
\ No newline at end of file
diff --git a/src/app/views/Messages/ChatView.js b/src/app/views/Messages/ChatView.js
new file mode 100644 (file)
index 0000000..80d3276
--- /dev/null
@@ -0,0 +1,121 @@
+/**
+ * @file View for displaying messages in a chat-like interface
+ */
+
+import React, { useState, useEffect, useRef } from 'react';
+import { TextField, Button, Typography } from '@mui/material';
+import { useSnackbar } from 'notistack';
+import { useParams, useNavigate } from 'react-router-dom';
+import { MessageService } from '../../services';
+import { formatDate, truncateContent } from '../../utils';
+
+const ChatView = () => {
+  const { group_id, recipient_id } = useParams();
+  const { enqueueSnackbar } = useSnackbar();
+  const navigate = useNavigate();
+  const [messages, setMessages] = useState([]);
+  const [newMessage, setNewMessage] = useState('');
+  const messagesEndRef = useRef(null);
+
+  // Fetch messages
+  useEffect(() => {
+    const fetchMessages = async () => {
+      try {
+        let response;
+        if (group_id) {
+          response = await MessageService.getMessagesByGroupId(group_id);
+        } else if (recipient_id) {
+          response = await MessageService.getMessagesByRecipientId(recipient_id);
+        }
+        setMessages(response.data);
+      } catch (error) {
+        enqueueSnackbar(`Error fetching messages: ${error.message}`, { variant: 'error' });
+      }
+    };
+    fetchMessages();
+  }, [group_id, recipient_id, enqueueSnackbar]);
+
+  // Scroll to bottom on new messages
+  useEffect(() => {
+    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+  }, [messages]);
+
+  // Send message
+  const handleSendMessage = async () => {
+    if (!newMessage.trim()) return;
+    try {
+      const data = {
+        sender_id: JSON.parse(localStorage.getItem('user'))?.id || 1, // Adjust based on auth
+        group_id: group_id ? parseInt(group_id) : null,
+        recipient_id: recipient_id ? parseInt(recipient_id) : null,
+        content: newMessage,
+      };
+      const response = await MessageService.createMessage(data);
+      setMessages([...messages, response.data]);
+      setNewMessage('');
+      enqueueSnackbar('Message sent successfully', { variant: 'success' });
+    } catch (error) {
+      enqueueSnackbar(`Error sending message: ${error.message}`, { variant: 'error' });
+    }
+  };
+
+  // Mark message as read
+  const handleMarkAsRead = async (messageId) => {
+    try {
+      const user = JSON.parse(localStorage.getItem('user')) || { id: 1 }; // Adjust based on auth
+      const message = messages.find(msg => msg.id === messageId);
+      if (user.id !== message.recipient_id && !group_id) {
+        enqueueSnackbar('Only the recipient can mark this message as read', { variant: 'error' });
+        return;
+      }
+      const response = await MessageService.markMessageAsRead(messageId);
+      setMessages(messages.map(msg => msg.id === messageId ? response.data : msg));
+      enqueueSnackbar('Message marked as read', { variant: 'success' });
+    } catch (error) {
+      enqueueSnackbar(`Error marking message as read: ${error.message}`, { variant: 'error' });
+    }
+  };
+
+  return (
+    <div className="chat-container">
+      <h2 className="text-2xl font-bold mb-4">
+        {group_id ? `Group Chat (ID: ${group_id})` : `Direct Chat (Recipient ID: ${recipient_id})`}
+      </h2>
+      <div className="h-96 overflow-y-auto mb-4">
+        {messages.map((msg) => (
+          <div
+            key={msg.id}
+            className={`chat-message ${msg.sender_id === (JSON.parse(localStorage.getItem('user'))?.id || 1) ? 'chat-message-sent' : 'chat-message-received'}`}
+            onClick={() => !msg.read && handleMarkAsRead(msg.id)}
+          >
+            <Typography variant="body2" color="textSecondary">
+              Sender: {msg.sender_id} | {formatDate(msg.created_at)}
+            </Typography>
+            <Typography>{truncateContent(msg.content, 200)}</Typography>
+            {msg.read && (
+              <Typography variant="caption" color="textSecondary">
+                Read at: {formatDate(msg.read_at)}
+              </Typography>
+            )}
+          </div>
+        ))}
+        <div ref={messagesEndRef} />
+      </div>
+      <div className="flex space-x-2">
+        <TextField
+          value={newMessage}
+          onChange={(e) => setNewMessage(e.target.value)}
+          placeholder="Type a message..."
+          fullWidth
+          multiline
+          rows={2}
+        />
+        <Button variant="contained" color="primary" onClick={handleSendMessage}>
+          Send
+        </Button>
+      </div>
+    </div>
+  );
+};
+
+export default ChatView;
\ No newline at end of file
diff --git a/src/app/views/Messages/MessageFormView.js b/src/app/views/Messages/MessageFormView.js
new file mode 100644 (file)
index 0000000..9149838
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ * @file View for creating a new message
+ */
+
+import React from 'react';
+import MessageForm from '../../components/MessageForm';
+
+const MessageFormView = () => {
+  return (
+    <div className="container">
+      <MessageForm />
+    </div>
+  );
+};
+
+export default MessageFormView;
\ No newline at end of file
diff --git a/src/app/views/Messages/MessagesView.js b/src/app/views/Messages/MessagesView.js
new file mode 100644 (file)
index 0000000..488eef1
--- /dev/null
@@ -0,0 +1,21 @@
+/**
+ * @file View for displaying messages
+ */
+
+import React from 'react';
+import { useSearchParams } from 'react-router-dom';
+import MessagesTable from '../../components/MessagesTable';
+
+const MessagesView = () => {
+  const [searchParams] = useSearchParams();
+  const groupId = searchParams.get('group_id');
+  const recipientId = searchParams.get('recipient_id');
+
+  return (
+    <div className="container">
+      <MessagesTable groupId={groupId} recipientId={recipientId} />
+    </div>
+  );
+};
+
+export default MessagesView;
\ No newline at end of file
diff --git a/src/app/views/Posts/PostFormView.js b/src/app/views/Posts/PostFormView.js
new file mode 100644 (file)
index 0000000..e6fbdab
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ * @file View for creating a new post
+ */
+
+import React from 'react';
+import PostForm from '../../components/PostForm';
+
+const PostFormView = () => {
+  return (
+    <div className="container">
+      <PostForm />
+    </div>
+  );
+};
+
+export default PostFormView;
\ No newline at end of file
diff --git a/src/app/views/Posts/PostsView.js b/src/app/views/Posts/PostsView.js
new file mode 100644 (file)
index 0000000..14a4d99
--- /dev/null
@@ -0,0 +1,16 @@
+/**
+ * @file View for displaying posts
+ */
+
+import React from 'react';
+import PostsTable from '../../components/PostsTable';
+
+const PostsView = () => {
+  return (
+    <div className="container">
+      <PostsTable />
+    </div>
+  );
+};
+
+export default PostsView;
\ No newline at end of file
index 010c784b8ea903a5fe30e243d7c4a33c0bb94ad5..44f48ddcb87eef203cc2f4faf270070ec55c9cf1 100644 (file)
@@ -38,6 +38,7 @@ const VPN = () => {
               setRevokeOpen( true );
             }}><BlockIcon/></IconButton>
             <IconButton size='small' color='warning' title='Suspend' onClick={() => {
+              console.log( row.original );
               setRevokeClientName( row.original.clientName );
               setSuspendOpen( true );
             }}><AccessTimeOutlined/></IconButton>
index 0a00b8f6e3cf0b20de0926f57f53a03d93a62b09..cf0105dc0e88fc28b1f7b7b7c71fdd021d753718 100755 (executable)
 .App-link {
   color: #61dafb;
 }
-
-@keyframes App-logo-spin {
-  from {
-    transform: rotate(0deg);
-  }
-  to {
-    transform: rotate(360deg);
-  }
-}