├── .gitignore ├── App ├── controllers │ ├── ErrorController.php │ ├── HomeController.php │ ├── ListingController.php │ └── UserController.php └── views │ ├── error.view.php │ ├── home.view.php │ ├── listings │ ├── create.view.php │ ├── edit.view.php │ ├── index.view.php │ └── show.view.php │ ├── partials │ ├── bottom-banner.php │ ├── errors.php │ ├── footer.php │ ├── head.php │ ├── message.php │ ├── navbar.php │ ├── showcase-search.php │ └── top-banner.php │ └── users │ ├── create.view.php │ └── login.view.php ├── Framework ├── Authorization.php ├── Database.php ├── Router.php ├── Session.php ├── Validation.php └── middleware │ └── Authorize.php ├── LICENSE.md ├── composer.json ├── composer.lock ├── config └── _db.php ├── helpers.php ├── public ├── .htaccess ├── css │ └── style.css ├── images │ ├── screen.jpg │ └── showcase.jpg └── index.php ├── readme.md ├── routes.php └── workopia.sql /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /node_modules 3 | /config/db.php 4 | .env 5 | .vscode 6 | .DS_Store -------------------------------------------------------------------------------- /App/controllers/ErrorController.php: -------------------------------------------------------------------------------- 1 | '404', 18 | 'message' => $message 19 | ]); 20 | } 21 | 22 | /* 23 | * 403 unauthorized error 24 | * 25 | * @return void 26 | */ 27 | public static function unauthorized($message = 'You are not authorized to view this resource') 28 | { 29 | http_response_code(403); 30 | 31 | loadView('error', [ 32 | 'status' => '403', 33 | 'message' => $message 34 | ]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /App/controllers/HomeController.php: -------------------------------------------------------------------------------- 1 | db = new Database($config); 15 | } 16 | 17 | /* 18 | * Show the latest listings 19 | * 20 | * @return void 21 | */ 22 | public function index() 23 | { 24 | $listings = $this->db->query('SELECT * FROM listings ORDER BY created_at DESC LIMIT 6')->fetchAll(); 25 | 26 | loadView('home', [ 27 | 'listings' => $listings 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /App/controllers/ListingController.php: -------------------------------------------------------------------------------- 1 | db = new Database($config); 19 | } 20 | 21 | /** 22 | * Show all listings 23 | * 24 | * @return void 25 | */ 26 | public function index() 27 | { 28 | $listings = $this->db->query('SELECT * FROM listings ORDER BY created_at DESC')->fetchAll(); 29 | 30 | loadView('listings/index', [ 31 | 'listings' => $listings 32 | ]); 33 | } 34 | 35 | /** 36 | * Show the create listing form 37 | * 38 | * @return void 39 | */ 40 | public function create() 41 | { 42 | loadView('listings/create'); 43 | } 44 | 45 | /** 46 | * Show a single listing 47 | * 48 | * @param array $params 49 | * @return void 50 | */ 51 | public function show($params) 52 | { 53 | $id = $params['id'] ?? ''; 54 | 55 | $params = [ 56 | 'id' => $id 57 | ]; 58 | 59 | $listing = $this->db->query('SELECT * FROM listings WHERE id = :id', $params)->fetch(); 60 | 61 | // Check if listing exists 62 | if (!$listing) { 63 | ErrorController::notFound('Listing not found'); 64 | return; 65 | } 66 | 67 | loadView('listings/show', [ 68 | 'listing' => $listing 69 | ]); 70 | } 71 | 72 | /** 73 | * Store data in database 74 | * 75 | * @return void 76 | */ 77 | public function store() 78 | { 79 | $allowedFields = ['title', 'description', 'salary', 'tags', 'company', 'address', 'city', 'state', 'phone', 'email', 'requirements', 'benefits']; 80 | 81 | $newListingData = array_intersect_key($_POST, array_flip($allowedFields)); 82 | 83 | $newListingData['user_id'] = Session::get('user')['id']; 84 | 85 | $newListingData = array_map('sanitize', $newListingData); 86 | 87 | $requiredFields = ['title', 'description', 'salary', 'email', 'city', 'state']; 88 | 89 | $errors = []; 90 | 91 | foreach ($requiredFields as $field) { 92 | if (empty($newListingData[$field]) || !Validation::string($newListingData[$field])) { 93 | $errors[$field] = ucfirst($field) . ' is required'; 94 | } 95 | } 96 | 97 | if (!empty($errors)) { 98 | // Reload view with errors 99 | loadView('listings/create', [ 100 | 'errors' => $errors, 101 | 'listing' => $newListingData 102 | ]); 103 | } else { 104 | // Submit data 105 | $fields = []; 106 | 107 | foreach ($newListingData as $field => $value) { 108 | $fields[] = $field; 109 | } 110 | 111 | $fields = implode(', ', $fields); 112 | 113 | $values = []; 114 | 115 | foreach ($newListingData as $field => $value) { 116 | // Convert empty strings to null 117 | if ($value === '') { 118 | $newListingData[$field] = null; 119 | } 120 | $values[] = ':' . $field; 121 | } 122 | 123 | $values = implode(', ', $values); 124 | 125 | $query = "INSERT INTO listings ({$fields}) VALUES ({$values})"; 126 | 127 | $this->db->query($query, $newListingData); 128 | 129 | Session::setFlashMessage('success_message', 'Listing created successfully'); 130 | 131 | redirect('/listings'); 132 | } 133 | } 134 | 135 | /** 136 | * Delete a listing 137 | * 138 | * @param array $params 139 | * @return void 140 | */ 141 | public function destroy($params) 142 | { 143 | $id = $params['id']; 144 | 145 | $params = [ 146 | 'id' => $id 147 | ]; 148 | 149 | $listing = $this->db->query('SELECT * FROM listings WHERE id = :id', $params)->fetch(); 150 | 151 | // Check if listing exists 152 | if (!$listing) { 153 | ErrorController::notFound('Listing not found'); 154 | return; 155 | } 156 | 157 | // Authorization 158 | if (!Authorization::isOwner($listing->user_id)) { 159 | Session::setFlashMessage('error_message', 'You are not authoirzed to delete this listing'); 160 | return redirect('/listings/' . $listing->id); 161 | } 162 | 163 | $this->db->query('DELETE FROM listings WHERE id = :id', $params); 164 | 165 | // Set flash message 166 | Session::setFlashMessage('success_message', 'Listing deleted successfully'); 167 | 168 | redirect('/listings'); 169 | } 170 | 171 | /** 172 | * Show the listing edit form 173 | * 174 | * @param array $params 175 | * @return void 176 | */ 177 | public function edit($params) 178 | { 179 | $id = $params['id'] ?? ''; 180 | 181 | $params = [ 182 | 'id' => $id 183 | ]; 184 | 185 | $listing = $this->db->query('SELECT * FROM listings WHERE id = :id', $params)->fetch(); 186 | 187 | // Check if listing exists 188 | if (!$listing) { 189 | ErrorController::notFound('Listing not found'); 190 | return; 191 | } 192 | 193 | // Authorization 194 | if (!Authorization::isOwner($listing->user_id)) { 195 | Session::setFlashMessage('error_message', 'You are not authoirzed to update this listing'); 196 | return redirect('/listings/' . $listing->id); 197 | } 198 | 199 | loadView('listings/edit', [ 200 | 'listing' => $listing 201 | ]); 202 | } 203 | 204 | /** 205 | * Update a listing 206 | * 207 | * @param array $params 208 | * @return void 209 | */ 210 | public function update($params) 211 | { 212 | $id = $params['id'] ?? ''; 213 | 214 | $params = [ 215 | 'id' => $id 216 | ]; 217 | 218 | $listing = $this->db->query('SELECT * FROM listings WHERE id = :id', $params)->fetch(); 219 | 220 | // Check if listing exists 221 | if (!$listing) { 222 | ErrorController::notFound('Listing not found'); 223 | return; 224 | } 225 | 226 | // Authorization 227 | if (!Authorization::isOwner($listing->user_id)) { 228 | Session::setFlashMessage('error_message', 'You are not authoirzed to update this listing'); 229 | return redirect('/listings/' . $listing->id); 230 | } 231 | 232 | $allowedFields = ['title', 'description', 'salary', 'tags', 'company', 'address', 'city', 'state', 'phone', 'email', 'requirements', 'benefits']; 233 | 234 | $updateValues = []; 235 | 236 | $updateValues = array_intersect_key($_POST, array_flip($allowedFields)); 237 | 238 | $updateValues = array_map('sanitize', $updateValues); 239 | 240 | $requiredFields = ['title', 'description', 'salary', 'email', 'city', 'state']; 241 | 242 | $errors = []; 243 | 244 | foreach ($requiredFields as $field) { 245 | if (empty($updateValues[$field]) || !Validation::string($updateValues[$field])) { 246 | $errors[$field] = ucfirst($field) . ' is required'; 247 | } 248 | } 249 | 250 | if (!empty($errors)) { 251 | loadView('listings/edit', [ 252 | 'listing' => $listing, 253 | 'errors' => $errors 254 | ]); 255 | exit; 256 | } else { 257 | // Submit to database 258 | $updateFields = []; 259 | 260 | foreach (array_keys($updateValues) as $field) { 261 | $updateFields[] = "{$field} = :{$field}"; 262 | } 263 | 264 | $updateFields = implode(', ', $updateFields); 265 | 266 | $updateQuery = "UPDATE listings SET $updateFields WHERE id = :id"; 267 | 268 | $updateValues['id'] = $id; 269 | $this->db->query($updateQuery, $updateValues); 270 | 271 | // Set flash message 272 | Session::setFlashMessage('success_message', 'Listing updated'); 273 | 274 | redirect('/listings/' . $id); 275 | } 276 | } 277 | 278 | /** 279 | * Search listings by keywords/location 280 | * 281 | * @return void 282 | */ 283 | public function search() 284 | { 285 | $keywords = isset($_GET['keywords']) ? trim($_GET['keywords']) : ''; 286 | $location = isset($_GET['location']) ? trim($_GET['location']) : ''; 287 | 288 | $query = "SELECT * FROM listings WHERE (title LIKE :keywords OR description LIKE :keywords OR tags LIKE :keywords OR company LIKE :keywords) AND (city LIKE :location OR state LIKE :location)"; 289 | 290 | $params = [ 291 | 'keywords' => "%{$keywords}%", 292 | 'location' => "%{$location}%" 293 | ]; 294 | 295 | $listings = $this->db->query($query, $params)->fetchAll(); 296 | 297 | loadView('/listings/index', [ 298 | 'listings' => $listings, 299 | 'keywords' => $keywords, 300 | 'location' => $location 301 | ]); 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /App/controllers/UserController.php: -------------------------------------------------------------------------------- 1 | db = new Database($config); 17 | } 18 | 19 | /** 20 | * Show the login page 21 | * 22 | * @return void 23 | */ 24 | public function login() 25 | { 26 | loadView('users/login'); 27 | } 28 | 29 | /** 30 | * Show the register page 31 | * 32 | * @return void 33 | */ 34 | public function create() 35 | { 36 | loadView('users/create'); 37 | } 38 | 39 | /** 40 | * Store user in database 41 | * 42 | * @return void 43 | */ 44 | public function store() 45 | { 46 | $name = $_POST['name']; 47 | $email = $_POST['email']; 48 | $city = $_POST['city']; 49 | $state = $_POST['state']; 50 | $password = $_POST['password']; 51 | $passwordConfirmation = $_POST['password_confirmation']; 52 | 53 | $errors = []; 54 | 55 | // Validation 56 | if (!Validation::email($email)) { 57 | $errors['email'] = 'Please enter a valid email address'; 58 | } 59 | 60 | if (!Validation::string($name, 2, 50)) { 61 | $errors['name'] = 'Name must be between 2 and 50 characters'; 62 | } 63 | 64 | if (!Validation::string($password, 6, 50)) { 65 | $errors['password'] = 'Password must be at least 6 characters'; 66 | } 67 | 68 | if (!Validation::match($password, $passwordConfirmation)) { 69 | $errors['password_confirmation'] = 'Passwords do not match'; 70 | } 71 | 72 | if (!empty($errors)) { 73 | loadView('users/create', [ 74 | 'errors' => $errors, 75 | 'user' => [ 76 | 'name' => $name, 77 | 'email' => $email, 78 | 'city' => $city, 79 | 'state' => $state, 80 | ] 81 | ]); 82 | exit; 83 | } 84 | 85 | // Check if email exists 86 | $params = [ 87 | 'email' => $email 88 | ]; 89 | 90 | $user = $this->db->query('SELECT * FROM users WHERE email = :email', $params)->fetch(); 91 | 92 | if ($user) { 93 | $errors['email'] = 'That email already exists'; 94 | loadView('users/create', [ 95 | 'errors' => $errors 96 | ]); 97 | exit; 98 | } 99 | 100 | // Create user account 101 | $params = [ 102 | 'name' => $name, 103 | 'email' => $email, 104 | 'city' => $city, 105 | 'state' => $state, 106 | 'password' => password_hash($password, PASSWORD_DEFAULT) 107 | ]; 108 | 109 | $this->db->query('INSERT INTO users (name, email, city, state, password) VALUES (:name, :email, :city, :state, :password)', $params); 110 | 111 | // Get new user ID 112 | $userId = $this->db->conn->lastInsertId(); 113 | 114 | // Set user session 115 | Session::set('user', [ 116 | 'id' => $userId, 117 | 'name' => $name, 118 | 'email' => $email, 119 | 'city' => $city, 120 | 'state' => $state 121 | ]); 122 | 123 | redirect('/'); 124 | } 125 | 126 | /** 127 | * Logout a user and kill session 128 | * 129 | * @return void 130 | */ 131 | public function logout() 132 | { 133 | Session::clearAll(); 134 | 135 | $params = session_get_cookie_params(); 136 | setcookie('PHPSESSID', '', time() - 86400, $params['path'], $params['domain']); 137 | 138 | redirect('/'); 139 | } 140 | 141 | /** 142 | * Authenticate a user with email and password 143 | * 144 | * @return void 145 | */ 146 | public function authenticate() 147 | { 148 | $email = $_POST['email']; 149 | $password = $_POST['password']; 150 | 151 | $errors = []; 152 | 153 | // Validation 154 | if (!Validation::email($email)) { 155 | $errors['email'] = 'Please enter a valid email'; 156 | } 157 | 158 | if (!Validation::string($password, 6, 50)) { 159 | $errors['password'] = 'Password must be at least 6 characters'; 160 | } 161 | 162 | // Check for errors 163 | if (!empty($errors)) { 164 | loadView('users/login', [ 165 | 'errors' => $errors 166 | ]); 167 | exit; 168 | } 169 | 170 | // Check for email 171 | $params = [ 172 | 'email' => $email 173 | ]; 174 | 175 | $user = $this->db->query('SELECT * FROM users WHERE email = :email', $params)->fetch(); 176 | 177 | if (!$user) { 178 | $errors['email'] = 'Incorrect credentials'; 179 | loadView('users/login', [ 180 | 'errors' => $errors 181 | ]); 182 | exit; 183 | } 184 | 185 | // Check if password is correct 186 | if (!password_verify($password, $user->password)) { 187 | $errors['email'] = 'Incorrect credentials'; 188 | loadView('users/login', [ 189 | 'errors' => $errors 190 | ]); 191 | exit; 192 | } 193 | 194 | // Set user session 195 | Session::set('user', [ 196 | 'id' => $user->id, 197 | 'name' => $user->name, 198 | 'email' => $user->email, 199 | 'city' => $user->city, 200 | 'state' => $user->state 201 | ]); 202 | 203 | redirect('/'); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /App/views/error.view.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 |
7 |

8 | 9 |

10 | Go Back To Listings 11 |
12 |
13 | 14 | -------------------------------------------------------------------------------- /App/views/home.view.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 |
Recent Jobs
10 |
11 | 12 | 13 |
14 |
15 |

title ?>

16 |

17 | description ?> 18 |

19 |
    20 |
  • Salary: salary) ?>
  • 21 |
  • 22 | Location: city ?>, state ?> 23 | 24 |
  • 25 | tags)) : ?> 26 |
  • 27 | Tags: tags ?> 28 |
  • 29 | 30 |
31 | 32 | Details 33 | 34 |
35 |
36 | 37 | 38 |
39 | 40 | 41 | Show All Jobs 42 | 43 |
44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /App/views/listings/create.view.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |

Create Job Listing

8 |
9 |

10 | Job Info 11 |

12 | $errors ?? [] 14 | ]) ?> 15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 | 26 |
27 |
28 | 29 |
30 |
31 | 32 |
33 |

34 | Company Info & Location 35 |

36 |
37 | 38 |
39 |
40 | 41 |
42 |
43 | 44 |
45 |
46 | 47 |
48 |
49 | 50 |
51 |
52 | 53 |
54 | 57 | 58 | Cancel 59 | 60 |
61 |
62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /App/views/listings/edit.view.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 |

Edit Job Listing

8 |
9 | 10 |

11 | Job Info 12 |

13 | $errors ?? [] 15 | ]) ?> 16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 | 27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 |

35 | Company Info & Location 36 |

37 |
38 | 39 |
40 |
41 | 42 |
43 |
44 | 45 |
46 |
47 | 48 |
49 |
50 | 51 |
52 |
53 | 54 |
55 | 58 | 59 | Cancel 60 | 61 |
62 |
63 |
64 | 65 | 66 | -------------------------------------------------------------------------------- /App/views/listings/index.view.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 |
10 | 11 | Search Results for: 12 | 13 | All Jobs 14 | 15 |
16 | 17 |
18 | 19 | 20 |
21 |
22 |

title ?>

23 |

24 | description ?> 25 |

26 |
    27 |
  • Salary: salary) ?>
  • 28 |
  • 29 | Location: city ?>, state ?> 30 | 31 |
  • 32 | tags)) : ?> 33 |
  • 34 | Tags: tags ?> 35 |
  • 36 | 37 |
38 | 39 | Details 40 | 41 |
42 |
43 | 44 |
45 |
46 |
47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /App/views/listings/show.view.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |
7 | 8 |
9 | 10 | 11 | Back To Listings 12 | 13 | user_id)) : ?> 14 |
15 | Edit 16 | 17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 |
25 |
26 |

title ?>

27 |

28 | description ?> 29 |

30 |
    31 |
  • Salary: salary) ?>
  • 32 |
  • 33 | Location: city ?>, state ?> 34 | 35 |
  • 36 | tags)) : ?> 37 |
  • 38 | Tags: tags ?> 39 |
  • 40 | 41 |
42 |
43 |
44 |
45 | 46 |
47 |

Job Details

48 |
49 |

50 | Job Requirements 51 |

52 |

53 | requirements ?> 54 |

55 |

Benefits

56 |

benefits ?>

57 |
58 |

59 | Put "Job Application" as the subject of your email and attach your 60 | resume. 61 |

62 | 63 | Apply Now 64 | 65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /App/views/partials/bottom-banner.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |

Looking to hire?

6 |

7 | Post your job listing now and find the perfect candidate. 8 |

9 |
10 | 11 | Post a Job 12 | 13 |
14 |
-------------------------------------------------------------------------------- /App/views/partials/errors.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | -------------------------------------------------------------------------------- /App/views/partials/footer.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /App/views/partials/head.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Workopia 11 | 12 | 13 | -------------------------------------------------------------------------------- /App/views/partials/message.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 |
9 | 10 |
11 | 12 | 13 | 14 | 15 |
16 | 17 |
18 | -------------------------------------------------------------------------------- /App/views/partials/navbar.php: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 |

9 | Workopia 10 |

11 | 27 |
28 |
-------------------------------------------------------------------------------- /App/views/partials/showcase-search.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |

Find Your Dream Job

6 |
7 | 8 | 9 | 12 |
13 |
14 |
-------------------------------------------------------------------------------- /App/views/partials/top-banner.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |

Unlock Your Career Potential

5 |

6 | Discover the perfect job opportunity for you. 7 |

8 |
9 |
-------------------------------------------------------------------------------- /App/views/users/create.view.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 |

Register

7 | $errors ?? [] 9 | ]) ?> 10 |
11 |
12 | 13 |
14 |
15 | 16 |
17 |
18 | 19 |
20 |
21 | 22 |
23 |
24 | 25 |
26 |
27 | 28 |
29 | 32 | 33 |

34 | Already have an account? 35 | Login 36 |

37 |
38 |
39 |
40 | 41 | -------------------------------------------------------------------------------- /App/views/users/login.view.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 |

Login

7 | $errors ?? [] 9 | ]) ?> 10 |
11 |
12 | 13 |
14 |
15 | 16 |
17 | 20 | 21 |

22 | Don't have an account? 23 | Register 24 |

25 |
26 |
27 |
28 | 29 | -------------------------------------------------------------------------------- /Framework/Authorization.php: -------------------------------------------------------------------------------- 1 | PDO::ERRMODE_EXCEPTION, 22 | PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ 23 | ]; 24 | 25 | try { 26 | $this->conn = new PDO($dsn, $config['username'], $config['password'], $options); 27 | } catch (PDOException $e) { 28 | throw new Exception("Database connection failed: {$e->getMessage()}"); 29 | } 30 | } 31 | 32 | /** 33 | * Query the database 34 | * 35 | * @param string $query 36 | * 37 | * @return PDOStatement 38 | * @throws PDOException 39 | */ 40 | public function query($query, $params = []) 41 | { 42 | try { 43 | $sth = $this->conn->prepare($query); 44 | 45 | // Bind named params 46 | foreach ($params as $param => $value) { 47 | $sth->bindValue(':' . $param, $value); 48 | } 49 | 50 | $sth->execute(); 51 | return $sth; 52 | } catch (PDOException $e) { 53 | throw new Exception("Query failed to execute: {$e->getMessage()}"); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Framework/Router.php: -------------------------------------------------------------------------------- 1 | routes[] = [ 27 | 'method' => $method, 28 | 'uri' => $uri, 29 | 'controller' => $controller, 30 | 'controllerMethod' => $controllerMethod, 31 | 'middleware' => $middleware 32 | ]; 33 | } 34 | 35 | /** 36 | * Add a GET route 37 | * 38 | * @param string $uri 39 | * @param string $controller 40 | * @param array $middleware 41 | * @return void 42 | */ 43 | public function get($uri, $controller, $middleware = []) 44 | { 45 | $this->registerRoute('GET', $uri, $controller, $middleware); 46 | } 47 | 48 | /** 49 | * Add a POST route 50 | * 51 | * @param string $uri 52 | * @param string $controller 53 | * @param array $middleware 54 | * 55 | * @return void 56 | */ 57 | public function post($uri, $controller, $middleware = []) 58 | { 59 | $this->registerRoute('POST', $uri, $controller, $middleware); 60 | } 61 | 62 | /** 63 | * Add a PUT route 64 | * 65 | * @param string $uri 66 | * @param string $controller 67 | * @param array $middleware 68 | * 69 | * @return void 70 | */ 71 | public function put($uri, $controller, $middleware = []) 72 | { 73 | $this->registerRoute('PUT', $uri, $controller, $middleware); 74 | } 75 | 76 | /** 77 | * Add a DELETE route 78 | * 79 | * @param string $uri 80 | * @param string $controller 81 | * @param array $middleware 82 | * 83 | * @return void 84 | */ 85 | public function delete($uri, $controller, $middleware = []) 86 | { 87 | $this->registerRoute('DELETE', $uri, $controller, $middleware); 88 | } 89 | 90 | /** 91 | * Route the request 92 | * 93 | * @param string $uri 94 | * @param string $method 95 | * @return void 96 | */ 97 | public function route($uri) 98 | { 99 | $requestMethod = $_SERVER['REQUEST_METHOD']; 100 | 101 | // Check for _method input 102 | if ($requestMethod === 'POST' && isset($_POST['_method'])) { 103 | // Override the request method with the value of _method 104 | $requestMethod = strtoupper($_POST['_method']); 105 | } 106 | 107 | foreach ($this->routes as $route) { 108 | 109 | // Split the current URI into segments 110 | $uriSegments = explode('/', trim($uri, '/')); 111 | 112 | // Split the route URI into segments 113 | $routeSegments = explode('/', trim($route['uri'], '/')); 114 | 115 | $match = true; 116 | 117 | // Check if the number of segments matches 118 | if (count($uriSegments) === count($routeSegments) && strtoupper($route['method'] === $requestMethod)) { 119 | $params = []; 120 | 121 | $match = true; 122 | 123 | for ($i = 0; $i < count($uriSegments); $i++) { 124 | // If the uri's do not match and there is no param 125 | if ($routeSegments[$i] !== $uriSegments[$i] && !preg_match('/\{(.+?)\}/', $routeSegments[$i])) { 126 | $match = false; 127 | break; 128 | } 129 | 130 | // Check for the param and add to $params array 131 | if (preg_match('/\{(.+?)\}/', $routeSegments[$i], $matches)) { 132 | $params[$matches[1]] = $uriSegments[$i]; 133 | } 134 | } 135 | 136 | if ($match) { 137 | foreach ($route['middleware'] as $middleware) { 138 | (new Authorize())->handle($middleware); 139 | } 140 | 141 | $controller = 'App\\controllers\\' . $route['controller']; 142 | $controllerMethod = $route['controllerMethod']; 143 | 144 | // Instatiate the controller and call the method 145 | $controllerInstance = new $controller(); 146 | $controllerInstance->$controllerMethod($params); 147 | return; 148 | } 149 | } 150 | } 151 | 152 | ErrorController::notFound(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /Framework/Session.php: -------------------------------------------------------------------------------- 1 | = $min && $length <= $max; 21 | } 22 | 23 | return false; 24 | } 25 | 26 | /** 27 | * Validate email address 28 | * 29 | * @param string $value 30 | * @return mixed 31 | */ 32 | public static function email($value) 33 | { 34 | $value = trim($value); 35 | 36 | return filter_var($value, FILTER_VALIDATE_EMAIL); 37 | } 38 | 39 | /** 40 | * Match a value against another 41 | * @param string $value1 42 | * @param string $value2 43 | * @return bool 44 | */ 45 | public static function match($value1, $value2) 46 | { 47 | $value1 = trim($value1); 48 | $value = trim($value2); 49 | 50 | return $value1 === $value2; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Framework/middleware/Authorize.php: -------------------------------------------------------------------------------- 1 | isAuthenticated()) { 28 | return redirect('/'); 29 | } elseif ($role === 'auth' && !$this->isAuthenticated()) { 30 | return redirect('/auth/login'); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Traversy Media 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bradtraversy/workopia-php", 3 | "description": "Job listing application", 4 | "authors": [ 5 | { 6 | "name": "Brad Traversy", 7 | "email": "support@traversymedia.com" 8 | } 9 | ], 10 | "autoload": { 11 | "psr-4": { 12 | "Framework\\": "Framework/", 13 | "App\\": "App/" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "cec86089ec333b45cac7b5df95a16ac7", 8 | "packages": [], 9 | "packages-dev": [], 10 | "aliases": [], 11 | "minimum-stability": "stable", 12 | "stability-flags": [], 13 | "prefer-stable": false, 14 | "prefer-lowest": false, 15 | "platform": [], 16 | "platform-dev": [], 17 | "plugin-api-version": "2.6.0" 18 | } 19 | -------------------------------------------------------------------------------- /config/_db.php: -------------------------------------------------------------------------------- 1 | 'localhost', 5 | 'port' => 3306, 6 | 'dbname' => 'ADD_DB_NAME', 7 | 'username' => 'ADD_DB_USER', 8 | 'password' => 'ADD_DB_PASSWORD' 9 | ]; 10 | -------------------------------------------------------------------------------- /helpers.php: -------------------------------------------------------------------------------- 1 | '; 62 | var_dump($value); 63 | echo ''; 64 | } 65 | 66 | /** 67 | * Inspect a value(s) and die 68 | * 69 | * @param mixed $value 70 | * @return void 71 | */ 72 | function inspectAndDie($value) 73 | { 74 | echo '
';
 75 |   die(var_dump($value));
 76 |   echo '
'; 77 | } 78 | 79 | /** 80 | * Format salary 81 | * 82 | * @param string $salary 83 | * @return string Formatted Salary 84 | */ 85 | function formatSalary($salary) 86 | { 87 | return '$' . number_format(floatval($salary)); 88 | } 89 | 90 | /** 91 | * Sanitize Data 92 | * 93 | * @param string $dirty 94 | * @return string 95 | */ 96 | function sanitize($dirty) 97 | { 98 | return filter_var(trim($dirty), FILTER_SANITIZE_SPECIAL_CHARS); 99 | } 100 | 101 | /** 102 | * Redirect to a given url 103 | * 104 | * @param string $url 105 | * @return void 106 | */ 107 | function redirect($url) 108 | { 109 | header("Location: {$url}"); 110 | exit; 111 | } 112 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine on 2 | RewriteCond %{REQUEST_FILENAME} !-f 3 | RewriteCond %{REQUEST_FILENAME} !-d 4 | RewriteRule ^(.*)$ /index.php [NC,L,QSA] -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | 6. Use the user's configured `sans` font-variation-settings by default. 35 | */ 36 | 37 | html { 38 | line-height: 1.5; 39 | /* 1 */ 40 | -webkit-text-size-adjust: 100%; 41 | /* 2 */ 42 | -moz-tab-size: 4; 43 | /* 3 */ 44 | -o-tab-size: 4; 45 | tab-size: 4; 46 | /* 3 */ 47 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 48 | /* 4 */ 49 | font-feature-settings: normal; 50 | /* 5 */ 51 | font-variation-settings: normal; 52 | /* 6 */ 53 | } 54 | 55 | /* 56 | 1. Remove the margin in all browsers. 57 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 58 | */ 59 | 60 | body { 61 | margin: 0; 62 | /* 1 */ 63 | line-height: inherit; 64 | /* 2 */ 65 | } 66 | 67 | /* 68 | 1. Add the correct height in Firefox. 69 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 70 | 3. Ensure horizontal rules are visible by default. 71 | */ 72 | 73 | hr { 74 | height: 0; 75 | /* 1 */ 76 | color: inherit; 77 | /* 2 */ 78 | border-top-width: 1px; 79 | /* 3 */ 80 | } 81 | 82 | /* 83 | Add the correct text decoration in Chrome, Edge, and Safari. 84 | */ 85 | 86 | abbr:where([title]) { 87 | -webkit-text-decoration: underline dotted; 88 | text-decoration: underline dotted; 89 | } 90 | 91 | /* 92 | Remove the default font size and weight for headings. 93 | */ 94 | 95 | h1, 96 | h2, 97 | h3, 98 | h4, 99 | h5, 100 | h6 { 101 | font-size: inherit; 102 | font-weight: inherit; 103 | } 104 | 105 | /* 106 | Reset links to optimize for opt-in styling instead of opt-out. 107 | */ 108 | 109 | a { 110 | color: inherit; 111 | text-decoration: inherit; 112 | } 113 | 114 | /* 115 | Add the correct font weight in Edge and Safari. 116 | */ 117 | 118 | b, 119 | strong { 120 | font-weight: bolder; 121 | } 122 | 123 | /* 124 | 1. Use the user's configured `mono` font family by default. 125 | 2. Correct the odd `em` font sizing in all browsers. 126 | */ 127 | 128 | code, 129 | kbd, 130 | samp, 131 | pre { 132 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 133 | /* 1 */ 134 | font-size: 1em; 135 | /* 2 */ 136 | } 137 | 138 | /* 139 | Add the correct font size in all browsers. 140 | */ 141 | 142 | small { 143 | font-size: 80%; 144 | } 145 | 146 | /* 147 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 148 | */ 149 | 150 | sub, 151 | sup { 152 | font-size: 75%; 153 | line-height: 0; 154 | position: relative; 155 | vertical-align: baseline; 156 | } 157 | 158 | sub { 159 | bottom: -0.25em; 160 | } 161 | 162 | sup { 163 | top: -0.5em; 164 | } 165 | 166 | /* 167 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 168 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 169 | 3. Remove gaps between table borders by default. 170 | */ 171 | 172 | table { 173 | text-indent: 0; 174 | /* 1 */ 175 | border-color: inherit; 176 | /* 2 */ 177 | border-collapse: collapse; 178 | /* 3 */ 179 | } 180 | 181 | /* 182 | 1. Change the font styles in all browsers. 183 | 2. Remove the margin in Firefox and Safari. 184 | 3. Remove default padding in all browsers. 185 | */ 186 | 187 | button, 188 | input, 189 | optgroup, 190 | select, 191 | textarea { 192 | font-family: inherit; 193 | /* 1 */ 194 | font-feature-settings: inherit; 195 | /* 1 */ 196 | font-variation-settings: inherit; 197 | /* 1 */ 198 | font-size: 100%; 199 | /* 1 */ 200 | font-weight: inherit; 201 | /* 1 */ 202 | line-height: inherit; 203 | /* 1 */ 204 | color: inherit; 205 | /* 1 */ 206 | margin: 0; 207 | /* 2 */ 208 | padding: 0; 209 | /* 3 */ 210 | } 211 | 212 | /* 213 | Remove the inheritance of text transform in Edge and Firefox. 214 | */ 215 | 216 | button, 217 | select { 218 | text-transform: none; 219 | } 220 | 221 | /* 222 | 1. Correct the inability to style clickable types in iOS and Safari. 223 | 2. Remove default button styles. 224 | */ 225 | 226 | button, 227 | [type='button'], 228 | [type='reset'], 229 | [type='submit'] { 230 | -webkit-appearance: button; 231 | /* 1 */ 232 | background-color: transparent; 233 | /* 2 */ 234 | background-image: none; 235 | /* 2 */ 236 | } 237 | 238 | /* 239 | Use the modern Firefox focus style for all focusable elements. 240 | */ 241 | 242 | :-moz-focusring { 243 | outline: auto; 244 | } 245 | 246 | /* 247 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 248 | */ 249 | 250 | :-moz-ui-invalid { 251 | box-shadow: none; 252 | } 253 | 254 | /* 255 | Add the correct vertical alignment in Chrome and Firefox. 256 | */ 257 | 258 | progress { 259 | vertical-align: baseline; 260 | } 261 | 262 | /* 263 | Correct the cursor style of increment and decrement buttons in Safari. 264 | */ 265 | 266 | ::-webkit-inner-spin-button, 267 | ::-webkit-outer-spin-button { 268 | height: auto; 269 | } 270 | 271 | /* 272 | 1. Correct the odd appearance in Chrome and Safari. 273 | 2. Correct the outline style in Safari. 274 | */ 275 | 276 | [type='search'] { 277 | -webkit-appearance: textfield; 278 | /* 1 */ 279 | outline-offset: -2px; 280 | /* 2 */ 281 | } 282 | 283 | /* 284 | Remove the inner padding in Chrome and Safari on macOS. 285 | */ 286 | 287 | ::-webkit-search-decoration { 288 | -webkit-appearance: none; 289 | } 290 | 291 | /* 292 | 1. Correct the inability to style clickable types in iOS and Safari. 293 | 2. Change font properties to `inherit` in Safari. 294 | */ 295 | 296 | ::-webkit-file-upload-button { 297 | -webkit-appearance: button; 298 | /* 1 */ 299 | font: inherit; 300 | /* 2 */ 301 | } 302 | 303 | /* 304 | Add the correct display in Chrome and Safari. 305 | */ 306 | 307 | summary { 308 | display: list-item; 309 | } 310 | 311 | /* 312 | Removes the default spacing and border for appropriate elements. 313 | */ 314 | 315 | blockquote, 316 | dl, 317 | dd, 318 | h1, 319 | h2, 320 | h3, 321 | h4, 322 | h5, 323 | h6, 324 | hr, 325 | figure, 326 | p, 327 | pre { 328 | margin: 0; 329 | } 330 | 331 | fieldset { 332 | margin: 0; 333 | padding: 0; 334 | } 335 | 336 | legend { 337 | padding: 0; 338 | } 339 | 340 | ol, 341 | ul, 342 | menu { 343 | list-style: none; 344 | margin: 0; 345 | padding: 0; 346 | } 347 | 348 | /* 349 | Reset default styling for dialogs. 350 | */ 351 | 352 | dialog { 353 | padding: 0; 354 | } 355 | 356 | /* 357 | Prevent resizing textareas horizontally by default. 358 | */ 359 | 360 | textarea { 361 | resize: vertical; 362 | } 363 | 364 | /* 365 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 366 | 2. Set the default placeholder color to the user's configured gray 400 color. 367 | */ 368 | 369 | input::-moz-placeholder, textarea::-moz-placeholder { 370 | opacity: 1; 371 | /* 1 */ 372 | color: #9ca3af; 373 | /* 2 */ 374 | } 375 | 376 | input::placeholder, 377 | textarea::placeholder { 378 | opacity: 1; 379 | /* 1 */ 380 | color: #9ca3af; 381 | /* 2 */ 382 | } 383 | 384 | /* 385 | Set the default cursor for buttons. 386 | */ 387 | 388 | button, 389 | [role="button"] { 390 | cursor: pointer; 391 | } 392 | 393 | /* 394 | Make sure disabled buttons don't get the pointer cursor. 395 | */ 396 | 397 | :disabled { 398 | cursor: default; 399 | } 400 | 401 | /* 402 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 403 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 404 | This can trigger a poorly considered lint error in some tools but is included by design. 405 | */ 406 | 407 | img, 408 | svg, 409 | video, 410 | canvas, 411 | audio, 412 | iframe, 413 | embed, 414 | object { 415 | display: block; 416 | /* 1 */ 417 | vertical-align: middle; 418 | /* 2 */ 419 | } 420 | 421 | /* 422 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 423 | */ 424 | 425 | img, 426 | video { 427 | max-width: 100%; 428 | height: auto; 429 | } 430 | 431 | /* Make elements with the HTML hidden attribute stay hidden by default */ 432 | 433 | [hidden] { 434 | display: none; 435 | } 436 | 437 | *, ::before, ::after { 438 | --tw-border-spacing-x: 0; 439 | --tw-border-spacing-y: 0; 440 | --tw-translate-x: 0; 441 | --tw-translate-y: 0; 442 | --tw-rotate: 0; 443 | --tw-skew-x: 0; 444 | --tw-skew-y: 0; 445 | --tw-scale-x: 1; 446 | --tw-scale-y: 1; 447 | --tw-pan-x: ; 448 | --tw-pan-y: ; 449 | --tw-pinch-zoom: ; 450 | --tw-scroll-snap-strictness: proximity; 451 | --tw-gradient-from-position: ; 452 | --tw-gradient-via-position: ; 453 | --tw-gradient-to-position: ; 454 | --tw-ordinal: ; 455 | --tw-slashed-zero: ; 456 | --tw-numeric-figure: ; 457 | --tw-numeric-spacing: ; 458 | --tw-numeric-fraction: ; 459 | --tw-ring-inset: ; 460 | --tw-ring-offset-width: 0px; 461 | --tw-ring-offset-color: #fff; 462 | --tw-ring-color: rgb(59 130 246 / 0.5); 463 | --tw-ring-offset-shadow: 0 0 #0000; 464 | --tw-ring-shadow: 0 0 #0000; 465 | --tw-shadow: 0 0 #0000; 466 | --tw-shadow-colored: 0 0 #0000; 467 | --tw-blur: ; 468 | --tw-brightness: ; 469 | --tw-contrast: ; 470 | --tw-grayscale: ; 471 | --tw-hue-rotate: ; 472 | --tw-invert: ; 473 | --tw-saturate: ; 474 | --tw-sepia: ; 475 | --tw-drop-shadow: ; 476 | --tw-backdrop-blur: ; 477 | --tw-backdrop-brightness: ; 478 | --tw-backdrop-contrast: ; 479 | --tw-backdrop-grayscale: ; 480 | --tw-backdrop-hue-rotate: ; 481 | --tw-backdrop-invert: ; 482 | --tw-backdrop-opacity: ; 483 | --tw-backdrop-saturate: ; 484 | --tw-backdrop-sepia: ; 485 | } 486 | 487 | ::backdrop { 488 | --tw-border-spacing-x: 0; 489 | --tw-border-spacing-y: 0; 490 | --tw-translate-x: 0; 491 | --tw-translate-y: 0; 492 | --tw-rotate: 0; 493 | --tw-skew-x: 0; 494 | --tw-skew-y: 0; 495 | --tw-scale-x: 1; 496 | --tw-scale-y: 1; 497 | --tw-pan-x: ; 498 | --tw-pan-y: ; 499 | --tw-pinch-zoom: ; 500 | --tw-scroll-snap-strictness: proximity; 501 | --tw-gradient-from-position: ; 502 | --tw-gradient-via-position: ; 503 | --tw-gradient-to-position: ; 504 | --tw-ordinal: ; 505 | --tw-slashed-zero: ; 506 | --tw-numeric-figure: ; 507 | --tw-numeric-spacing: ; 508 | --tw-numeric-fraction: ; 509 | --tw-ring-inset: ; 510 | --tw-ring-offset-width: 0px; 511 | --tw-ring-offset-color: #fff; 512 | --tw-ring-color: rgb(59 130 246 / 0.5); 513 | --tw-ring-offset-shadow: 0 0 #0000; 514 | --tw-ring-shadow: 0 0 #0000; 515 | --tw-shadow: 0 0 #0000; 516 | --tw-shadow-colored: 0 0 #0000; 517 | --tw-blur: ; 518 | --tw-brightness: ; 519 | --tw-contrast: ; 520 | --tw-grayscale: ; 521 | --tw-hue-rotate: ; 522 | --tw-invert: ; 523 | --tw-saturate: ; 524 | --tw-sepia: ; 525 | --tw-drop-shadow: ; 526 | --tw-backdrop-blur: ; 527 | --tw-backdrop-brightness: ; 528 | --tw-backdrop-contrast: ; 529 | --tw-backdrop-grayscale: ; 530 | --tw-backdrop-hue-rotate: ; 531 | --tw-backdrop-invert: ; 532 | --tw-backdrop-opacity: ; 533 | --tw-backdrop-saturate: ; 534 | --tw-backdrop-sepia: ; 535 | } 536 | 537 | .container { 538 | width: 100%; 539 | } 540 | 541 | @media (min-width: 640px) { 542 | .container { 543 | max-width: 640px; 544 | } 545 | } 546 | 547 | @media (min-width: 768px) { 548 | .container { 549 | max-width: 768px; 550 | } 551 | } 552 | 553 | @media (min-width: 1024px) { 554 | .container { 555 | max-width: 1024px; 556 | } 557 | } 558 | 559 | @media (min-width: 1280px) { 560 | .container { 561 | max-width: 1280px; 562 | } 563 | } 564 | 565 | @media (min-width: 1536px) { 566 | .container { 567 | max-width: 1536px; 568 | } 569 | } 570 | 571 | .relative { 572 | position: relative; 573 | } 574 | 575 | .z-10 { 576 | z-index: 10; 577 | } 578 | 579 | .mx-5 { 580 | margin-left: 1.25rem; 581 | margin-right: 1.25rem; 582 | } 583 | 584 | .mx-6 { 585 | margin-left: 1.5rem; 586 | margin-right: 1.5rem; 587 | } 588 | 589 | .mx-auto { 590 | margin-left: auto; 591 | margin-right: auto; 592 | } 593 | 594 | .my-3 { 595 | margin-top: 0.75rem; 596 | margin-bottom: 0.75rem; 597 | } 598 | 599 | .my-4 { 600 | margin-top: 1rem; 601 | margin-bottom: 1rem; 602 | } 603 | 604 | .my-5 { 605 | margin-top: 1.25rem; 606 | margin-bottom: 1.25rem; 607 | } 608 | 609 | .my-6 { 610 | margin-top: 1.5rem; 611 | margin-bottom: 1.5rem; 612 | } 613 | 614 | .mb-2 { 615 | margin-bottom: 0.5rem; 616 | } 617 | 618 | .mb-4 { 619 | margin-bottom: 1rem; 620 | } 621 | 622 | .mb-6 { 623 | margin-bottom: 1.5rem; 624 | } 625 | 626 | .ml-2 { 627 | margin-left: 0.5rem; 628 | } 629 | 630 | .mt-2 { 631 | margin-top: 0.5rem; 632 | } 633 | 634 | .mt-20 { 635 | margin-top: 5rem; 636 | } 637 | 638 | .mt-4 { 639 | margin-top: 1rem; 640 | } 641 | 642 | .mb-3 { 643 | margin-bottom: 0.75rem; 644 | } 645 | 646 | .block { 647 | display: block; 648 | } 649 | 650 | .flex { 651 | display: flex; 652 | } 653 | 654 | .grid { 655 | display: grid; 656 | } 657 | 658 | .h-72 { 659 | height: 18rem; 660 | } 661 | 662 | .w-full { 663 | width: 100%; 664 | } 665 | 666 | .cursor-pointer { 667 | cursor: pointer; 668 | } 669 | 670 | .list-disc { 671 | list-style-type: disc; 672 | } 673 | 674 | .grid-cols-1 { 675 | grid-template-columns: repeat(1, minmax(0, 1fr)); 676 | } 677 | 678 | .items-center { 679 | align-items: center; 680 | } 681 | 682 | .justify-center { 683 | justify-content: center; 684 | } 685 | 686 | .justify-between { 687 | justify-content: space-between; 688 | } 689 | 690 | .gap-4 { 691 | gap: 1rem; 692 | } 693 | 694 | .space-x-4 > :not([hidden]) ~ :not([hidden]) { 695 | --tw-space-x-reverse: 0; 696 | margin-right: calc(1rem * var(--tw-space-x-reverse)); 697 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); 698 | } 699 | 700 | .rounded { 701 | border-radius: 0.25rem; 702 | } 703 | 704 | .rounded-full { 705 | border-radius: 9999px; 706 | } 707 | 708 | .rounded-lg { 709 | border-radius: 0.5rem; 710 | } 711 | 712 | .border { 713 | border-width: 1px; 714 | } 715 | 716 | .border-gray-300 { 717 | --tw-border-opacity: 1; 718 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 719 | } 720 | 721 | .bg-blue-500 { 722 | --tw-bg-opacity: 1; 723 | background-color: rgb(59 130 246 / var(--tw-bg-opacity)); 724 | } 725 | 726 | .bg-blue-800 { 727 | --tw-bg-opacity: 1; 728 | background-color: rgb(30 64 175 / var(--tw-bg-opacity)); 729 | } 730 | 731 | .bg-blue-900 { 732 | --tw-bg-opacity: 1; 733 | background-color: rgb(30 58 138 / var(--tw-bg-opacity)); 734 | } 735 | 736 | .bg-gray-100 { 737 | --tw-bg-opacity: 1; 738 | background-color: rgb(243 244 246 / var(--tw-bg-opacity)); 739 | } 740 | 741 | .bg-green-100 { 742 | --tw-bg-opacity: 1; 743 | background-color: rgb(220 252 231 / var(--tw-bg-opacity)); 744 | } 745 | 746 | .bg-indigo-100 { 747 | --tw-bg-opacity: 1; 748 | background-color: rgb(224 231 255 / var(--tw-bg-opacity)); 749 | } 750 | 751 | .bg-red-100 { 752 | --tw-bg-opacity: 1; 753 | background-color: rgb(254 226 226 / var(--tw-bg-opacity)); 754 | } 755 | 756 | .bg-white { 757 | --tw-bg-opacity: 1; 758 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 759 | } 760 | 761 | .bg-yellow-500 { 762 | --tw-bg-opacity: 1; 763 | background-color: rgb(234 179 8 / var(--tw-bg-opacity)); 764 | } 765 | 766 | .bg-red-500 { 767 | --tw-bg-opacity: 1; 768 | background-color: rgb(239 68 68 / var(--tw-bg-opacity)); 769 | } 770 | 771 | .bg-green-500 { 772 | --tw-bg-opacity: 1; 773 | background-color: rgb(34 197 94 / var(--tw-bg-opacity)); 774 | } 775 | 776 | .bg-cover { 777 | background-size: cover; 778 | } 779 | 780 | .bg-center { 781 | background-position: center; 782 | } 783 | 784 | .bg-no-repeat { 785 | background-repeat: no-repeat; 786 | } 787 | 788 | .p-3 { 789 | padding: 0.75rem; 790 | } 791 | 792 | .p-4 { 793 | padding: 1rem; 794 | } 795 | 796 | .p-8 { 797 | padding: 2rem; 798 | } 799 | 800 | .px-2 { 801 | padding-left: 0.5rem; 802 | padding-right: 0.5rem; 803 | } 804 | 805 | .px-4 { 806 | padding-left: 1rem; 807 | padding-right: 1rem; 808 | } 809 | 810 | .px-5 { 811 | padding-left: 1.25rem; 812 | padding-right: 1.25rem; 813 | } 814 | 815 | .py-1 { 816 | padding-top: 0.25rem; 817 | padding-bottom: 0.25rem; 818 | } 819 | 820 | .py-2 { 821 | padding-top: 0.5rem; 822 | padding-bottom: 0.5rem; 823 | } 824 | 825 | .py-2\.5 { 826 | padding-top: 0.625rem; 827 | padding-bottom: 0.625rem; 828 | } 829 | 830 | .py-6 { 831 | padding-top: 1.5rem; 832 | padding-bottom: 1.5rem; 833 | } 834 | 835 | .pl-6 { 836 | padding-left: 1.5rem; 837 | } 838 | 839 | .text-center { 840 | text-align: center; 841 | } 842 | 843 | .text-3xl { 844 | font-size: 1.875rem; 845 | line-height: 2.25rem; 846 | } 847 | 848 | .text-4xl { 849 | font-size: 2.25rem; 850 | line-height: 2.5rem; 851 | } 852 | 853 | .text-base { 854 | font-size: 1rem; 855 | line-height: 1.5rem; 856 | } 857 | 858 | .text-lg { 859 | font-size: 1.125rem; 860 | line-height: 1.75rem; 861 | } 862 | 863 | .text-xl { 864 | font-size: 1.25rem; 865 | line-height: 1.75rem; 866 | } 867 | 868 | .text-xs { 869 | font-size: 0.75rem; 870 | line-height: 1rem; 871 | } 872 | 873 | .text-2xl { 874 | font-size: 1.5rem; 875 | line-height: 2rem; 876 | } 877 | 878 | .font-bold { 879 | font-weight: 700; 880 | } 881 | 882 | .font-medium { 883 | font-weight: 500; 884 | } 885 | 886 | .font-semibold { 887 | font-weight: 600; 888 | } 889 | 890 | .text-black { 891 | --tw-text-opacity: 1; 892 | color: rgb(0 0 0 / var(--tw-text-opacity)); 893 | } 894 | 895 | .text-blue-700 { 896 | --tw-text-opacity: 1; 897 | color: rgb(29 78 216 / var(--tw-text-opacity)); 898 | } 899 | 900 | .text-blue-900 { 901 | --tw-text-opacity: 1; 902 | color: rgb(30 58 138 / var(--tw-text-opacity)); 903 | } 904 | 905 | .text-gray-200 { 906 | --tw-text-opacity: 1; 907 | color: rgb(229 231 235 / var(--tw-text-opacity)); 908 | } 909 | 910 | .text-gray-500 { 911 | --tw-text-opacity: 1; 912 | color: rgb(107 114 128 / var(--tw-text-opacity)); 913 | } 914 | 915 | .text-gray-700 { 916 | --tw-text-opacity: 1; 917 | color: rgb(55 65 81 / var(--tw-text-opacity)); 918 | } 919 | 920 | .text-indigo-700 { 921 | --tw-text-opacity: 1; 922 | color: rgb(67 56 202 / var(--tw-text-opacity)); 923 | } 924 | 925 | .text-white { 926 | --tw-text-opacity: 1; 927 | color: rgb(255 255 255 / var(--tw-text-opacity)); 928 | } 929 | 930 | .text-blue-500 { 931 | --tw-text-opacity: 1; 932 | color: rgb(59 130 246 / var(--tw-text-opacity)); 933 | } 934 | 935 | .shadow-md { 936 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 937 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 938 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 939 | } 940 | 941 | .shadow-sm { 942 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 943 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); 944 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 945 | } 946 | 947 | .transition { 948 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; 949 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 950 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; 951 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 952 | transition-duration: 150ms; 953 | } 954 | 955 | .duration-300 { 956 | transition-duration: 300ms; 957 | } 958 | 959 | .overlay { 960 | position: absolute; 961 | top: 0; 962 | left: 0; 963 | width: 100%; 964 | height: 100%; 965 | background-color: rgba(0, 0, 0, 0.6); 966 | /* Adjust opacity as needed */ 967 | z-index: 1; 968 | } 969 | 970 | .showcase { 971 | background-image: url('../images/showcase.jpg'); 972 | } 973 | 974 | .hover\:bg-blue-600:hover { 975 | --tw-bg-opacity: 1; 976 | background-color: rgb(37 99 235 / var(--tw-bg-opacity)); 977 | } 978 | 979 | .hover\:bg-indigo-200:hover { 980 | --tw-bg-opacity: 1; 981 | background-color: rgb(199 210 254 / var(--tw-bg-opacity)); 982 | } 983 | 984 | .hover\:bg-yellow-600:hover { 985 | --tw-bg-opacity: 1; 986 | background-color: rgb(202 138 4 / var(--tw-bg-opacity)); 987 | } 988 | 989 | .hover\:bg-red-600:hover { 990 | --tw-bg-opacity: 1; 991 | background-color: rgb(220 38 38 / var(--tw-bg-opacity)); 992 | } 993 | 994 | .hover\:bg-green-600:hover { 995 | --tw-bg-opacity: 1; 996 | background-color: rgb(22 163 74 / var(--tw-bg-opacity)); 997 | } 998 | 999 | .hover\:underline:hover { 1000 | text-decoration-line: underline; 1001 | } 1002 | 1003 | .hover\:shadow-md:hover { 1004 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 1005 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); 1006 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1007 | } 1008 | 1009 | .focus\:outline-none:focus { 1010 | outline: 2px solid transparent; 1011 | outline-offset: 2px; 1012 | } 1013 | 1014 | @media (min-width: 768px) { 1015 | .md\:mx-auto { 1016 | margin-left: auto; 1017 | margin-right: auto; 1018 | } 1019 | 1020 | .md\:w-500 { 1021 | width: 500px; 1022 | } 1023 | 1024 | .md\:w-600 { 1025 | width: 600px; 1026 | } 1027 | 1028 | .md\:w-auto { 1029 | width: auto; 1030 | } 1031 | 1032 | .md\:grid-cols-3 { 1033 | grid-template-columns: repeat(3, minmax(0, 1fr)); 1034 | } 1035 | } 1036 | -------------------------------------------------------------------------------- /public/images/screen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradtraversy/workopia-php/1163726eaeb668da4be3c452d0feeff696d4f82c/public/images/screen.jpg -------------------------------------------------------------------------------- /public/images/showcase.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bradtraversy/workopia-php/1163726eaeb668da4be3c452d0feeff696d4f82c/public/images/showcase.jpg -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | route($uri); 22 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Workopia 2 | 3 | Workopia is a job listing website from my [PHP From Scratch course](https://www.traversymedia.com/php-from-scratch). It includes a custom Laravel-like router, controller classes, views, a database layer and a project structure using namespaces and PSR-4 autoloading. It highlights how to structure a PHP project without using any frameworks or libraries. 4 | 5 | ![Workopia](/public/images/screen.jpg) 6 | 7 | ## Usage 8 | 9 | ### Requirements 10 | 11 | - PHP 7.4 or higher 12 | - MySQL 5.7 or higher 13 | 14 | ### Installation 15 | 16 | 1. Clone the repo into your document root (www, htdocs, etc) 17 | 2. Create a database called `workopia` 18 | 3. Import the `workopia.sql` file into your database 19 | 4. Rename `config/_db.php` to `config/db.php` and update with your credentials 20 | 5. Run `composer install` to set up the autoloading 21 | 6. Set your document root to the `public` directory 22 | 23 | ### Setting the Document Root 24 | 25 | You will need to set your document root to the `public` directory. Here are some instructions for setting the document root for some popular local development tools: 26 | 27 | ##### PHP built-in server 28 | 29 | If you are using the PHP built-in server, you can run the following command from the project root: 30 | 31 | `php -S localhost:8000 -t public` 32 | 33 | ##### XAMPP 34 | 35 | If you are using XAMPP, you can set the document root in the `httpd.conf` file. Here is an example: 36 | 37 | ```conf 38 | DocumentRoot "C:/xampp/htdocs/workopia/public" 39 | 40 | ``` 41 | 42 | ##### MAMP 43 | 44 | If you are using MAMP, you can set the document root in the `httpd.conf` file. Here is an example: 45 | 46 | ```conf 47 | DocumentRoot "/Applications/MAMP/htdocs/workopia/public" 48 | 49 | ``` 50 | 51 | ##### Laragon 52 | 53 | If you are using Laragon, you can set right-click the icon in the system tray and go to `Apache > sites-enabled > auto.workopia.test.conf`. Your file may be named differently. 54 | 55 | You can then set the document root. Here is an example: 56 | 57 | ```conf 58 | 59 | DocumentRoot "C:/laragon/www/workopia/public" 60 | ServerName workopia.test 61 | ServerAlias *.workopia.test 62 | 63 | AllowOverride All 64 | Require all granted 65 | 66 | 67 | ``` 68 | 69 | ## Project Structure and Notes 70 | 71 | #### Custom Laravel-like router 72 | 73 | Creating a route in `routes.php` looks like this: 74 | 75 | `$router->get('/lisings', 'ListingController@index');` 76 | 77 | This would load the `index` method in the `App/controllers/ListingController.php` file. 78 | 79 | #### Authorization Middleware 80 | 81 | You can pass in middleware for authorization. This is an array of roles. If you want the route to be accessible only to logged-in users, you would add the `auth` role: 82 | 83 | `$router->get('/listings/create', 'ListingController@create', ['auth']);` 84 | 85 | If you only want non-logged-in users to access the route, you would add the `guest` role: 86 | 87 | `$router->get('/register', 'AuthController@register', ['guest']);` 88 | 89 | #### Public Directory 90 | 91 | This project has a public directory that contains all of the assets like CSS, JS and images. This is where the `index.php` file is located, which is the entry point for the application. 92 | 93 | You will need to set your document root to the `public` directory. 94 | 95 | #### Framework Directory 96 | 97 | All of the core files for this project are in the `Framework` directory. This includes the following files: 98 | 99 | - **Database.php** - Database connection and query method (PDO) 100 | - **Router.php** - Router logic 101 | - **Session.php** - Session logic 102 | - **Validation.php** - Simple validations for strings, email and matching passwords 103 | - **Authorization.php** - Handles resource authorization 104 | - **middleware/Authorize.php** - Handles authorization middleware for routes 105 | 106 | #### PSR-4 Autoloading 107 | 108 | This project uses PSR-4 autoloading. All of the classes are loaded in the `composer.json` file: 109 | 110 | ```json 111 | "autoload": { 112 | "psr-4": { 113 | "Framework\\": "Framework/", 114 | "App\\": "App/" 115 | } 116 | } 117 | ``` 118 | 119 | #### Namespaces 120 | 121 | This project uses namespaces for all of the classes. Here are the namespaces used: 122 | 123 | - **Framework** - All of the core framework classes 124 | - **Framework\Router** - All of the router classes 125 | - **Framework\Session** - All of the session classes 126 | - **Framework\Validation** - All of the validation classes 127 | - **Framework\Authorization** - All of the authorization classes 128 | - **Framework\Middleware\Authorize** - Authorization middleware 129 | - **App\Controllers** - All of the controllers 130 | 131 | #### App Directory 132 | 133 | The `App` directory contains all of the main application files like controllers, views, etc. Here is the directory structure: 134 | 135 | - **controllers/** - Contains all of the controllers, including listings, users, home and error 136 | - **views/** - Contains all of the views 137 | - **views/partials/** - Contains all of the partial views 138 | 139 | #### Other Files 140 | 141 | - **/index.php** - Entry point for the application 142 | - **public/.htaccess** - Handles the URL rewriting 143 | - **/helpers.php** - Contains helper functions 144 | - **/routes.php** - Contains all of the routes 145 | - **/config/db.php** - Contains the database configuration 146 | - **composer.json** - Contains the composer dependencies 147 | - **workopia.sql** - Contains the database dump 148 | 149 | ## License 150 | 151 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 152 | -------------------------------------------------------------------------------- /routes.php: -------------------------------------------------------------------------------- 1 | get('/', 'HomeController@index'); 3 | $router->get('/listings', 'ListingController@index'); 4 | $router->get('/listings/create', 'ListingController@create', ['auth']); 5 | $router->get('/listings/edit/{id}', 'ListingController@edit', ['auth']); 6 | $router->get('/listings/search', 'ListingController@search'); 7 | $router->get('/listings/{id}', 'ListingController@show'); 8 | 9 | $router->post('/listings', 'ListingController@store', ['auth']); 10 | $router->put('/listings/{id}', 'ListingController@update', ['auth']); 11 | $router->delete('/listings/{id}', 'ListingController@destroy', ['auth']); 12 | 13 | $router->get('/auth/register', 'UserController@create', ['guest']); 14 | $router->get('/auth/login', 'UserController@login', ['guest']); 15 | 16 | $router->post('/auth/register', 'UserController@store', ['guest']); 17 | $router->post('/auth/logout', 'UserController@logout', ['auth']); 18 | $router->post('/auth/login', 'UserController@authenticate', ['guest']); 19 | -------------------------------------------------------------------------------- /workopia.sql: -------------------------------------------------------------------------------- 1 | -- Create the 'workopia' database 2 | CREATE DATABASE IF NOT EXISTS workopia; 3 | USE workopia; 4 | 5 | -- Table structure for 'users' table 6 | CREATE TABLE IF NOT EXISTS `users` ( 7 | `id` int NOT NULL AUTO_INCREMENT, 8 | `name` varchar(255) DEFAULT NULL, 9 | `email` varchar(255) NOT NULL, 10 | `password` varchar(255) NOT NULL, 11 | `city` varchar(45) DEFAULT NULL, 12 | `state` varchar(45) DEFAULT NULL, 13 | `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, 14 | PRIMARY KEY (`id`) 15 | ) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; 16 | 17 | -- Insert data into 'users' table 18 | INSERT INTO `users` VALUES 19 | (1,'John Doe','user1@gmail.com','$2y$10$UkdJDaWLRHPVwOu3lb9XW.FZWZmFaLM0BJbaj0/7dvPIqs7sdDlvK','Boston','MA','2023-11-18 13:55:59'), 20 | (2,'Jane Doe','user2@gmail.com','$2y$10$UkdJDaWLRHPVwOu3lb9XW.FZWZmFaLM0BJbaj0/7dvPIqs7sdDlvK','San Francisco','CA','2023-11-18 13:58:26'), 21 | (3,'Steve Smith','user3@gmail.com','$2y$10$UkdJDaWLRHPVwOu3lb9XW.FZWZmFaLM0BJbaj0/7dvPIqs7sdDlvK','Chicago','IL','2023-11-18 13:59:13'); 22 | 23 | -- Table structure for 'listings' table 24 | CREATE TABLE IF NOT EXISTS `listings` ( 25 | `id` int NOT NULL AUTO_INCREMENT, 26 | `user_id` int NOT NULL, 27 | `title` varchar(255) NOT NULL, 28 | `description` longtext, 29 | `salary` varchar(45) DEFAULT NULL, 30 | `tags` varchar(255) DEFAULT NULL, 31 | `company` varchar(45) DEFAULT NULL, 32 | `address` varchar(255) DEFAULT NULL, 33 | `city` varchar(45) DEFAULT NULL, 34 | `state` varchar(45) DEFAULT NULL, 35 | `phone` varchar(45) DEFAULT NULL, 36 | `email` varchar(45) DEFAULT NULL, 37 | `requirements` longtext, 38 | `benefits` longtext, 39 | `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, 40 | PRIMARY KEY (`id`), 41 | KEY `fk_listings_users_idx` (`user_id`), 42 | CONSTRAINT `fk_listings_users` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 43 | ) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; 44 | 45 | -- Insert data into 'listings' table 46 | INSERT INTO `listings` VALUES 47 | (1,1,'Software Engineer','We are seeking a skilled software engineer to develop high-quality software solutions','90000','development, coding, java, python','Tech Solutions Inc.','123 Main St','Albany','NY','348-334-3949','info@techsolutions.com','Bachelors degree in Computer Science or related field, 3+ years of software development experience','Healthcare, 401(k) matching, flexible work hours','2023-11-18 14:04:36'), 48 | (2,2,'Marketing Specialist','We are looking for a Marketing Specialist to create and manage marketing campaigns','80000','marketing, advertising','Marketing Pros','123 Market St','San Francisco','CA','454-344-3344','info@marketingpros.com','Bachelors degree in Marketing or related field, experience in digital marketing','Health and dental insurance, paid time off, remote work options','2023-11-18 14:06:33'), 49 | (3,3,'Web Developer','Join our team as a Web Developer and create amazing web applications','85000','web development, programming','WebTech Solutions','789 Web Ave','Chicago','IL','456-876-5432','info@webtechsolutions.com','Bachelors degree in Computer Science or related field, proficiency in HTML, CSS, JavaScript','Competitive salary, professional development opportunities, friendly work environment','2023-11-18 14:08:44'), 50 | (4,1,'Data Analyst','We are hiring a Data Analyst to analyze and interpret data for insights','75000','data analysis, statistics','Data Insights LLC','101 Data St','Chicago','IL','444-555-5555','info@datainsights.com','Bachelors degree in Data Science or related field, strong analytical skills','Health benefits, remote work options, casual dress code','2023-11-18 14:11:55'), 51 | (5,2,'Graphic Designer','Join our creative team as a Graphic Designer and bring ideas to life','70000','graphic design, creative','CreativeWorks Inc','234 Design Blvd','Albany','NY','499-321-9876','info@creativeworks.com','Bachelors degree in Graphic Design or related field, proficiency in Adobe Creative Suite','Flexible work hours, creative work environment, opportunities for growth','2023-11-18 14:13:35'), 52 | (7,1,'Frontend Web Developer','This is a frontend position working with React','70000','frontend, development','Traversy Media','10 main st','Boston','MA','555-555-5555','info@test.com','Bachelors degree','401K and Health insurance','2023-11-21 14:07:24'); 53 | --------------------------------------------------------------------------------