Specjalne ostrzeżenia
Lactulosum Polfarmex
Produkt leczniczy Lactulosum Polfarmex wymaga szczególnej ostrożności podczas długotrwałego stosowania, zwłaszcza u pacjentów geriatrycznych, gdzie konieczne jest regularne monitorowanie stężenia elektrolitów, w tym potasu i chlorków, w osoczu. Preparat zawiera substancje pomocnicze takie jak etanol (0,00963 g na 15 ml syropu), fruktozę (0,075 g na 15 ml), laktozę (0,75 g na 15 ml) oraz galaktozę (1,125 g na 15 ml), które mogą mieć kliniczne znaczenie u wybranych grup pacjentów. Zawartość etanolu w dawkach terapeutycznych jest niska (np. 45 ml syropu zawiera 29,07 mg etanolu, co odpowiada mniej niż 1 ml piwa lub wina) i nie wywołuje istotnych efektów farmakologicznych.
- Specjalne ostrzeżenia i środki ostrożności dotyczące stosowania
- Monitorowanie elektrolitów podczas długotrwałej terapii
- Substancje pomocnicze o znanym działaniu
- Zawartość etanolu
- Zawartość fruktozy
- Zawartość laktozy
- Zawartość galaktozy
- Zawartość siarczyn# kevinkirbyuk/Opta-F24-Data-Parser # OptaParser.py import json import os import time import re import pandas as pd from time import sleep def preprocess_text_to_valid_json(text): „”” Ensure the text is valid JSON by handling common syntax errors. Parameters: – text (str): The JSON text to be preprocessed Returns: – str: Preprocessed JSON text that should be valid „”” # Common preprocessing steps # Remove trailing commas in arrays and objects text = re.sub(r’,s*([}]])’, r’1′, text) # Ensure properties are in double quotes text = re.sub(r'([{,])s*([a-zA-Z0-9_]+):’, r’1″2″:’, text) # Replace single quotes with double quotes for string values text = re.sub(r”'([^’]*)’”, r’”1″’, text) return text def read_opta_files(filenames): „”” Read multiple Opta F24 XML files and store them in a list. Parameters: – filenames (List[str]): List of Opta F24 filenames Returns: – List: List of parsed XML file contents „”” opta_files = [] for filename in filenames: # Skip if file doesn’t exist or is directory if not os.path.isfile(filename): print(f”Skipping {filename}: file not found.”) continue # Read the file with open(filename, 'r’, encoding=’utf-8′) as file: opta_files.append(file.read()) return opta_files def parse_opta_f24_to_json(xml_string): „”” Parse Opta F24 XML data into JSON format. Parameters: – xml_string (str): Opta F24 XML data as string Returns: – dict: Parsed data in JSON format „”” # Extract necessary components match_pattern = r’’ match_time = re.search(match_pattern, xml_string) timestamp = match_time.group(1) if match_time else „” game_pattern = r’’ match = re.search(game_pattern, xml_string) if not match: print(„Failed to extract game information.”) return None # Or handle the error in a way that makes sense for your application game_id = match.group(1) away_team_id = match.group(2) away_team_name = match.group(3) competition_id = match.group(4) competition_name = match.group(5) game_date = match.group(6) home_team_id = match.group(7) home_team_name = match.group(8) matchday = match.group(9) period_1_start = match.group(10) period_2_start = match.group(11) season_id = match.group(12) season_name = match.group(13) # Extract all event data pattern = r’)*’ r’.*?’ events = [] for match in re.finditer(pattern, xml_string): event_id = match.group(1) type_id = match.group(3) period_id = match.group(4) minute = match.group(5) second = match.group(6) team_id = match.group(7) outcome = match.group(8) x_pos = match.group(9) y_pos = match.group(10) event_timestamp = match.group(11) # Extract qualifiers for this event qualifier_pattern = r’’ qualifiers = [] for q_match in re.finditer(qualifier_pattern, match.group(0)): q_id = q_match.group(1) qualifier_id = q_match.group(2) value = q_match.group(3) qualifiers.append({ „id”: q_id, „qualifier_id”: qualifier_id, „value”: value }) # Create event object event = { „id”: event_id, „type_id”: type_id, „period_id”: period_id, „minute”: minute, „second”: second, „team_id”: team_id, „outcome”: outcome, „x”: x_pos, „y”: y_pos, „timestamp”: event_timestamp, „qualifiers”: qualifiers } events.append(event) # Create the final JSON structure json_data = { „OptaFeed”: { „timestamp”: timestamp, „Game”: { „id”: game_id, „away_team_id”: away_team_id, „away_team_name”: away_team_name, „competition_id”: competition_id, „competition_name”: competition_name, „game_date”: game_date, „home_team_id”: home_team_id, „home_team_name”: home_team_name, „matchday”: matchday, „period_1_start”: period_1_start, „period_2_start”: period_2_start, „season_id”: season_id, „season_name”: season_name, „Events”: events } } } return json_data def save_to_json(data, filename): „”” Save data as JSON to a file. Parameters: – data (dict): The data to save – filename (str): The output filename Returns: – bool: True if successful, False otherwise „”” try: with open(filename, 'w’, encoding=’utf-8′) as file: json.dump(data, file, indent=4) return True except Exception as e: print(f”Error saving JSON to {filename}: {e}”) return False def extract_match_info(json_data): „”” Extract match information from Opta F24 JSON data. Parameters: – json_data (dict): Parsed Opta F24 data Returns: – dict: Extracted match information „”” game = json_data[„OptaFeed”][„Game”] return { „match_id”: game[„id”], „home_team_id”: game[„home_team_id”], „home_team”: game[„home_team_name”], „away_team_id”: game[„away_team_id”], „away_team”: game[„away_team_name”], „competition”: game[„competition_name”], „season”: game[„season_name”], „date”: game[„game_date”] } def events_to_dataframe(json_data): „”” Convert Opta F24 events from JSON to a pandas DataFrame. Parameters: – json_data (dict): Parsed Opta F24 data Returns: – DataFrame: Events data in tabular format „”” events = json_data[„OptaFeed”][„Game”][„Events”] match_info = extract_match_info(json_data) # Initialize an empty list to store flattened events flattened_events = [] for event in events: # Start with basic event information flat_event = { „event_id”: event[„id”], „type_id”: event[„type_id”], „period_id”: event[„period_id”], „minute”: event[„minute”], „second”: event[„second”], „team_id”: event[„team_id”], „outcome”: event[„outcome”], „x”: event[„x”], „y”: event[„y”], „timestamp”: event[„timestamp”], „match_id”: match_info[„match_id”], „home_team”: match_info[„home_team”], „away_team”: match_info[„away_team”], „competition”: match_info[„competition”], „season”: match_info[„season”], „date”: match_info[„date”] } # Add team information if event[„team_id”] == match_info[„home_team_id”]: flat_event[„team”] = match_info[„home_team”] elif event[„team_id”] == match_info[„away_team_id”]: flat_event[„team”] = match_info[„away_team”] # Add qualifiers as separate columns for qualifier in event[„qualifiers”]: qualifier_key = f”q{qualifier[’qualifier_id’]}” flat_event[qualifier_key] = qualifier[„value”] flattened_events.append(flat_event) # Convert to DataFrame df = pd.DataFrame(flattened_events) return df def main(filenames, output_format=”json”): „”” Main function to process Opta F24 files. Parameters: – filenames (List[str]): List of Opta F24 filenames to process – output_format (str): Output format (json or csv) Returns: – None: Results are saved to files „”” # Read in all the specified files opta_files = read_opta_files(filenames) # Process each file for i, file_content in enumerate(opta_files): try: # Parse XML to JSON json_data = parse_opta_f24_to_json(file_content) if json_data: # Extract match info for filename match_info = extract_match_info(json_data) base_filename = f”{match_info[’home_team’]}_{match_info[’away_team’]}_{match_info[’date’]}” base_filename = base_filename.replace(” „, „_”).replace(„/”, „-„) # Save in requested format(s) if output_format.lower() == „json” or output_format.lower() == „both”: json_filename = f”{base_filename}.json” save_to_json(json_data, json_filename) print(f”Saved {json_filename}”) if output_format.lower() == „csv” or output_format.lower() == „both”: # Convert to DataFrame and save as CSV df = events_to_dataframe(json_data) csv_filename = f”{base_filename}.csv” df.to_csv(csv_filename, index=False) print(f”Saved {csv_filename}”) else: print(f”Failed to parse file {filenames[i]}”) except Exception as e: print(f”Error processing {filenames[i]}: {e}”) print(„Processing complete.”) if __name__ == „__main__”: # Example usage: filenames = [„f24-20-2019-2245250-eventdetails.xml”] main(filenames, output_format=”both”) # Save as both JSON and CSV # serbogdanov/project service = $service; $this->repository = $repository; $this->contactRepository = $contactRepository; $this->appointmentRepository = $appointmentRepository; } /** * @param Request $request * @return IlluminateContractsViewFactory|IlluminateViewView */ public function index(Request $request) { $sort_by = $request->get(’sort_by’, 'created_at’); $sort = $request->get(’sort’, 'desc’); $search = trim($request->get(’search’)); $user = Auth::user(); $per_page = config(’project.per_page’); $hasPermission = auth()->user()->hasPermission(’patient’); $patients = $this->repository->getUserWithContactByRole(’patient’, $sort_by, $sort, $search, $per_page, false); return view(’users::patient.index’, compact(’patients’, 'user’, 'hasPermission’)); } /** * @return IlluminateContractsViewFactory|IlluminateViewView */ public function create() { $isCreate = true; $doctors = $this->repository->getUsersByRole(’doctor’); $treatments = $this->service->getPreDefinedData(’treatment’); $bloodGroups = $this->service->getPreDefinedData(’blood_group’); $statuses = $this->service->getPreDefinedData(’status’); return view(’users::patient.create_edit’, compact(’doctors’, 'isCreate’, 'bloodGroups’, 'treatments’, 'statuses’)); } /** * @param Request $request * @return IlluminateHttpRedirectResponse */ public function store(CreateUserRequest $request) { try { $data = $request->all(); $this->service->createPatientContact($data); return redirect()->route(’patients.index’) ->with(’success’, trans(’users::messages.record_was_successfully_created’)); } catch (Exception $e) { return back() ->with(’error’, „Cannot save patient: „.$e->getMessage()); } } /** * @param $id * @return IlluminateContractsViewFactory|IlluminateViewView */ public function show($id) { $per_page = config(’project.per_page’); $user = $this->service->findPatientById($id); $doctors = $this->repository->getUsersByRole(’doctor’); return view(’users::patient.view’, compact(’user’, 'doctors’)); } /** * @param $id * @return IlluminateContractsViewFactory|IlluminateViewView */ public function edit($id) { $isCreate = false; $user = $this->service->findPatientById($id); $doctors = $this->repository->getUsersByRole(’doctor’); $bloodGroups = $this->service->getPreDefinedData(’blood_group’); $treatments = $this->service->getPreDefinedData(’treatment’); $statuses = $this->service->getPreDefinedData(’status’); return view(’users::patient.create_edit’, compact(’user’, 'doctors’, 'isCreate’, 'bloodGroups’, 'treatments’, 'statuses’)); } /** * @param Request $request * @param $id * @return IlluminateHttpRedirectResponse */ public function update(UpdateUserRequest $request, $id) { try { $data = $request->all(); $this->service->updatePatientContact($id, $data); return redirect()->route(’patients.index’) ->with(’success’, trans(’users::messages.record_was_successfully_updated’)); } catch (Exception $e) { return back() ->with(’error’, „Cannot update patient: „.$e->getMessage()); } } /** * @param $id * @return IlluminateHttpRedirectResponse */ public function destroy($id) { try { $this->repository->delete($id); return back() ->with(’success’, trans(’users::messages.record_was_successfully_deleted’)); } catch (Exception $e) { return back() ->with(’error’, trans(’users::messages.record_was_not_deleted_due_to_system_error’).$e->getMessage()); } } /** * @param Request $request * @param $id * @return string */ public function events(Request $request, $id) { $data = $this->appointmentRepository->getAppointmentByContactId($id); return json_encode($data); } /** * @param $id * @return IlluminateHttpJsonResponse */ public function getPatient($id) { try { $data = $this->contactRepository->findById($id, true); return response()->json([ 'status’ => 'success’, 'data’ => $data ]); } catch(Exception $e) { return response()->json([ 'status’ => 'error’, 'error’ => $e->getMessage() ]); } } /** * Copy patient data * * @param IlluminateHttpRequest $request * @return IlluminateHttpJsonResponse */ public function copyPatientData(Request $request) { try { $data = $request->only(’patient_ids’); $patientIds = !empty($data[’patient_ids’]) ? explode(’,’, $data[’patient_ids’]) : []; if (empty($patientIds)) { throw new Exception(’Please choose patient(s) for copy.’); } $patients = []; foreach ($patientIds as $patientId) { $patients[] = $this->service->findPatientById($patientId); } $data = [ 'datas’ => $patients ]; $view = view(’users::patient.includes.patient_data_mail_table’, $data)->render(); return response()->json([ 'status’ => 'success’, 'view’ => $view ]); } catch(Exception $e) { return response()->json([ 'status’ => 'error’, 'error’ => $e->getMessage() ]); } } public function patientStatus($id) { try { $this->service->updatePatientStatus($id); return back() ->with(’success’, trans(’users::messages.patient_status_successfully_updated’)); } catch (Exception $e) { return back() ->with(’error’, 'Cannot update patient: ’.$e->getMessage()); } } } End FileuserModel = $userModel; $this->roleUserModel = $roleUserModel; $this->roleModel = $roleModel; $this->contactModel = $contactModel; $this->fileEntityModel = $fileEntityModel; } /** * get All User Except Current User * @return IlluminateDatabaseEloquentCollection|static[] */ public function getAllUsers() { return $this->userModel->where(’id’, ’’, Auth::user()->id)->get(); } /** * get users by role * @param string $roleName * @return mixed */ public function getUsersByRole($roleName = 'user’) { $role = $this->roleModel->select(’id’)->where(’name’, $roleName)->first(); $userIds = $this->roleUserModel->select(’user_id’)->where(’role_id’, $role->id)->get()->pluck(’user_id’)->toArray(); return $this->userModel->whereIn(’id’, $userIds)->get(); } /** * @param String $search * @return Collection */ public function searchUserName($search) { $users = DB::table(’users’) ->whereRaw(’CONCAT(first_name, ” „, last_name) like ?’, [„%{$search}%”]) ->orderBy(’first_name’) ->orderBy(’last_name’) ->get(); $role = $this->roleModel->select(’id’)->where(’name’, 'patient’)->first(); return $users; } /** * @param $userId * @param bool $isDoctorView * @return Contact|Contact[]|IlluminateDatabaseEloquentCollection|IlluminateDatabaseEloquentModel|null */ public function getUserContactProfileByUserId($userId, $isDoctorView = false) { $role = $this->roleModel->select(’id’)->where(’name’, 'patient’)->first(); $userIds = $this->roleUserModel->select(’user_id’)->where(’role_id’, $role->id)->get()->pluck(’user_id’)->toArray(); if ($isDoctorView) { return $this->contactModel ->whereIn(’user_id’, $userIds) ->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’) ->where(’assigned_dr_id’, $userId) ->get(); } else { return $this->contactModel ->whereIn(’user_id’, $userIds) ->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’) ->get(); } } /** * @param $userId * @return mixed */ public function getUserById($userId) { return $this->userModel->where(’id’, $userId)->with(’profile’)->first(); } /** * @param $roleName * @param string $sort_by * @param string $sort * @param string $search * @param int $per_page * @param bool $isDoctorAssigned * @return IlluminateContractsPaginationLengthAwarePaginator */ public function getUserWithContactByRole($roleName, $sort_by = 'created_at’, $sort = 'desc’, $search = ”, $per_page = 10, $isDoctorAssigned = false) { $role = $this->roleModel->select(’id’)->where(’name’, $roleName)->first(); $userIds = $this->roleUserModel->select(’user_id’)->where(’role_id’, $role->id)->get()->pluck(’user_id’)->toArray(); $query = $this->contactModel->whereIn(’user_id’, $userIds) ->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’); if ($isDoctorAssigned === true) { $query = $query->where(’assigned_dr_id’, Auth::user()->id); } if (!is_null($search) && !empty($search)) { $query = $query->where(function ($q) use ($search) { $q->where(’first_name’, 'like’, '%’.$search.’%’) ->orWhere(’last_name’, 'like’, '%’.$search.’%’) ->orWhere(’surname’, 'like’, '%’.$search.’%’) ->orWhere(’email’, 'like’, '%’.$search.’%’) ; }); } return $query->orderBy($sort_by, $sort)->paginate($per_page); } /** * @param $id * @return bool */ public function delete($id) { $user = $this->userModel->where(’id’, $id)->first(); return $user->delete(); } /** * @param $userId * @param $entityId * @param $category * @return bool */ public function saveUserAvatar($userId, $entityId, $category) { $object = $this->fileEntityModel ->where(’entity_id’, $userId) ->where(’category’, $category) ->first(); if (is_null($object)) { $this->fileEntityModel->entity_id = $userId; $this->fileEntityModel->file_id = $entityId; $this->fileEntityModel->category = $category; return $this->fileEntityModel->save(); } else { return $object ->where(’entity_id’, $userId) ->where(’category’, $category) ->update([’file_id’ => $entityId]); } } /** * @param $userId * @param $category * @return FileEntity */ public function getUserAvatar($userId, $category) { return $this->fileEntityModel ->where(’entity_id’, $userId) ->where(’category’, $category) ->first(); } /** * @param $roleId * @return IlluminateDatabaseEloquentCollection|static[] */ public function findAllUsersByRoleId($roleId) { return $this->userModel ->join(’role_user’, 'role_user.user_id’, '=’, 'users.id’) ->select(’users.*’, 'role_user.created_at as user_role_created_at’) ->where(’role_user.role_id’, $roleId) ->get(); } /** * @return IlluminateDatabaseEloquentCollection|static[] */ public function getAdminUser() { return $this->userModel ->join(’role_user’, 'role_user.user_id’, '=’, 'users.id’) ->join(’roles’, 'roles.id’, '=’, 'role_user.role_id’) ->where(’role_user.role_id’, env(’ROLE_ADMIN_USER_ID’)) ->select(’users.*’) ->get(); } /** * @param $userId * @return Contact[]|IlluminateDatabaseEloquentCollection|IlluminateDatabaseEloquentModel|mixed|null */ public function getContactData($userId) { $contact = $this->contactModel ->where(’assigned_dr_id’, $userId) ->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’) ->get(); return $contact; } /** * @param $doctor_ids * @return Contact[]|IlluminateDatabaseEloquentCollection */ public function getReferredDoctorPatients($doctor_ids) { return $this->contactModel ->whereIn(’assigned_dr_id’, $doctor_ids) ->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’) ->get(); } /** * @param $user_ids * @return mixed */ public function getContactsByUserIds($user_ids) { return $this->contactModel ->whereIn(’user_id’, $user_ids) ->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’) ->get(); } }End FileuserRepository = $userRepository; $this->roleRepository = $roleRepository; $this->contactService = $contactService; $this->preDefinedService = $preDefinedService; } /** * @param $data * @param $is_creating * @return User */ public function createOrUpdate($data, $is_creating) { $user = new User(); if (!$is_creating) { $user = User::find($data[’id’]); } if ($is_creating) { $user->email = $data[’email’]; $user->first_name = $data[’first_name’]; $user->last_name = $data[’last_name’]; $user->password = Hash::make($data[’password’]); $user->status = true; } else { if (isset($data[’password’]) && !empty($data[’password’])) { $user->password = Hash::make($data[’password’]); } } $user->save(); return $user; } /** * @param $user * @param $data * @param $entityId * @param $type * @param bool $is_creating * @return mixed */ public function saveRole($user, $data, $entityId, $type, $is_creating = true) { if (!empty($data[’roles’])) { $roles = []; foreach ($data[’roles’] as $roleId => $value) { $roles[] = $roleId; } $user->syncRoles($roles); } return $user; } /** * @param array $data * @return mixed */ public function createUserProfile(array $data) { try { DB::beginTransaction(); $data[’password’] = str_random(8); $data[’password_confirmation’] = $data[’password’]; $data[’email’] = (!empty($data[’email’])) ? $data[’email’] : 'john.’.$data[’last_name’].’@example.com’; $user = $this->createOrUpdate($data, true); $data[’roles’][$data[’role’]] = 1; $this->saveRole($user, $data, $user->id, 'user’); $contact = $this->contactService->setOwner($user->id); $this->contactService->storeContact($data); DB::commit(); return $contact; } catch (Exception $e) { DB::rollBack(); return $e->getMessage(); } } /** * @param array $data * @return mixed */ public function createPatientContact(array $data) { try { DB::beginTransaction(); $data[’password’] = str_random(8); $data[’password_confirmation’] = $data[’password’]; $user = $this->createOrUpdate($data, true); $data[’roles’][config(’project.roles.patient’)] = 1; $this->saveRole($user, $data, $user->id, 'user’); $contact = $this->contactService->setOwner($user->id); $this->contactService->storeContact($data); $this->updateContactAssignedDoctor($contact->id, isset($data[’doctor’]) ? $data[’doctor’] : ”); DB::commit(); return true; } catch (Exception $e) { DB::rollBack(); throw $e; } } /** * find patient information and details by id * @param $id * @return mixed */ public function findPatientById($id) { $user = $this->contactService->findContactByUserId($id); return $user; } /** * update patient contact by id * @param $id * @param $data * @return bool * @throws Exception */ public function updatePatientContact($id, $data) { try { DB::beginTransaction(); $contact = $this->contactService->findById($id, true); if (!empty($data[’password’])) { $user = User::find($contact->user_id); $user->password = Hash::make($data[’password’]); $user->email = $data[’email’]; $user->save(); } $this->contactService->updateContact($contact, $data); $this->updateContactAssignedDoctor($id, isset($data[’doctor’]) ? $data[’doctor’] : ”); DB::commit(); return true; } catch (Exception $e) { DB::rollBack(); throw $e; } } /** * @param $category * @return mixed */ public function getPreDefinedData($category) { return $this->preDefinedService->getDataByCategory($category); } /** * @param $contactId * @param $doctorID * @return mixed */ public function updateContactAssignedDoctor($contactId, $doctorID = ”) { return $this->contactService->updateContactAssignedDoctor($contactId, $doctorID); } /** * @param $userId * @return mixed */ public function updatePatientStatus($userId) { $contact = $this->contactService->findContactByUserId($userId); return $this->contactService->updatePatientStatus($contact->id); } } End File# serbogdanov/project # app/Modules/Appointments/Repositories/AppointmentRepository.php appointmentModel = $appointmentModel; $this->userRepository = $userRepository; } /** * @param array $data * @return Appointment|IlluminateDatabaseEloquentModel */ public function create(array $data) { $start = Carbon::createFromFormat(’Y-m-d H:i’, $data[’date’].’ ’.$data[’start’]); $end = Carbon::createFromFormat(’Y-m-d H:i’, $data[’date’].’ ’.$data[’end’]); $query = $this->appointmentModel->create([ 'contact_id’ => $data[’contact_id’], 'title’ => $data[’title’], 'dentist_id’ => $data[’dentist_id’], 'type_id’ => $data[’type_id’], 'duration’ => $data[’duration’], 'date’ => $data[’date’], 'treatment_id’ => $data[’treatment_id’], 'start_time’ => $data[’start’], 'end_time’ => $data[’end’], 'all_day’ => 0, 'notes’ => isset($data[’notes’]) ? $data[’notes’] : ”, 'color’ => isset($data[’color’]) ? $data[’color’] : ”, 'is_block’ => isset($data[’is_block’]) ? $data[’is_block’] : 0, 'is_initial’ => isset($data[’is_initial’]) ? $data[’is_initial’] : 0, 'status_id’ => $data[’status_id’], 'start’ => $start, 'end’ => $end, ]); return $query; } /** * Update appointment * @param int $id * @param array $data * @return Appointment * @throws Exception */ public function update($id, array $data) { $appointment = $this->findById($id); if (empty($appointment)) { throw new Exception(’No appointment found.’); } $start = Carbon::createFromFormat(’Y-m-d H:i’, $data[’date’].’ ’.$data[’start’]); $end = Carbon::createFromFormat(’Y-m-d H:i’, $data[’date’].’ ’.$data[’end’]); if (array_key_exists(’contact_id’, $data)) { $appointment->contact_id = $data[’contact_id’]; } if (array_key_exists(’title’, $data)) { $appointment->title = $data[’title’]; } if (array_key_exists(’dentist_id’, $data)) { $appointment->dentist_id = $data[’dentist_id’]; } if (array_key_exists(’type_id’, $data)) { $appointment->type_id = $data[’type_id’]; } if (array_key_exists(’duration’, $data)) { $appointment->duration = $data[’duration’]; } if (array_key_exists(’date’, $data)) { $appointment->date = $data[’date’]; } if (array_key_exists(’treatment_id’, $data)) { $appointment->treatment_id = $data[’treatment_id’]; } if (array_key_exists(’start’, $data)) { $appointment->start_time = $data[’start’]; } if (array_key_exists(’end’, $data)) { $appointment->end_time = $data[’end’]; } if (array_key_exists(’all_day’, $data)) { $appointment->all_day = $data[’all_day’]; } if (array_key_exists(’notes’, $data)) { $appointment->notes = $data[’notes’]; } if (array_key_exists(’color’, $data)) { $appointment->color = $data[’color’]; } if (array_key_exists(’is_block’, $data)) { $appointment->is_block = $data[’is_block’]; } if (array_key_exists(’is_initial’, $data)) { $appointment->is_initial = $data[’is_initial’]; } if (array_key_exists(’status_id’, $data)) { $appointment->status_id = $data[’status_id’]; } $appointment->start = $start; $appointment->end = $end; $appointment->save(); return $appointment; } /** * Delete appointment * @param $id * @return bool * @throws Exception */ public function delete($id) { $appointment = $this->findById($id); if (empty($appointment)) { throw new Exception(’No appointment found.’); } return $appointment->delete(); } /** * @param $id * @return Appointment|IlluminateDatabaseEloquentModel|null|object */ public function findById($id) { return $this->appointmentModel->where(’id’, $id)->with([’contact’])->first(); } /** * @param $id * @return Appointment|IlluminateDatabaseEloquentModel|null|object */ public function findDetailById($id) { return $this->appointmentModel->where(’id’, $id) ->with([’contact’,’dentist’,’type’, 'status’, 'treatment’]) ->first(); } /** * @param null $doctorId * @param null $startStr * @param null $endStr * @return mixed */ public function getDentistAppointment($doctorId = null, $startStr = null, $endStr = null) { $query = $this->appointmentModel->where(’dentist_id’, $doctorId); if (!is_null($startStr) && !is_null($endStr)) { $start = Carbon::parse($startStr)->format(’Y-m-d’); $end = Carbon::parse($endStr)->format(’Y-m-d’); // Use date based conditions directly on date column for more accurate search $query = $query->whereRaw(’date(date) >= ?’, [$start]) ->whereRaw(’date(date) with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]); return $query->get(); } /** * @param $contactId * @return mixed */ public function getAppointmentByContactId($contactId) { $query = $this->appointmentModel ->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]) ->where(’contact_id’, $contactId); return $query->get(); } /** * @param $startDate * @param $endDate * @return mixed */ public function getAppointmentByRange($startDate, $endDate) { $doctorIds = []; $contactIds = []; $user = Auth::user(); if ($user->hasRole(’doctor’)) { $doctorIds = [$user->id]; } else { $result = $this->userRepository->getUsersByRole(’doctor’); foreach($result as $doctor) { $doctorIds[] = $doctor->id; } } if ($user->hasRole(’doctor’)) { $contacts = $this->userRepository->getContactData($user->id); foreach($contacts as $contact) { $contactIds[] = $contact->id; } } else { $query = $this->appointmentModel ->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]) ->where(’date’, ’>=’, $startDate) ->where(’date’, ’whereIn(’dentist_id’, $doctorIds); } return $query->get(); } /** * @param string $sort_by * @param string $sort * @param string $search * @param int $per_page * @return IlluminateContractsPaginationLengthAwarePaginator */ public function getAllAppointments($sort_by = 'date’, $sort = 'desc’, $search = ”, $per_page = 10) { $query = $this->appointmentModel->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]); if (!is_null($search) && !empty($search)) { $query = $query->where(function ($q) use ($search) { $q->where(’title’, 'like’, '%’.$search.’%’) ->orWhere(’notes’, 'like’, '%’.$search.’%’) ->orWhereHas(’contact’, function ($query) use ($search) { $query->where(’first_name’, 'like’, '%’.$search.’%’) ->orWhere(’last_name’, 'like’, '%’.$search.’%’); }) ->orWhereHas(’dentist’, function ($query) use ($search) { $query->where(’first_name’, 'like’, '%’.$search.’%’) ->orWhere(’last_name’, 'like’, '%’.$search.’%’); }); }); } return $query->orderBy($sort_by, $sort)->paginate($per_page); } /** * @param $user_id * @param string $sort_by * @param string $sort * @param string $search * @param int $per_page * @return IlluminateContractsPaginationLengthAwarePaginator */ public function getAppointmentsByDentist($user_id, $sort_by = 'date’, $sort = 'desc’, $search = ”, $per_page = 10) { $query = $this->appointmentModel->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]) ->where(’dentist_id’, $user_id); if (!is_null($search) && !empty($search)) { $query = $query->where(function ($q) use ($search) { $q->where(’title’, 'like’, '%’.$search.’%’) ->orWhere(’notes’, 'like’, '%’.$search.’%’) ->orWhereHas(’contact’, function ($query) use ($search) { $query->where(’first_name’, 'like’, '%’.$search.’%’) ->orWhere(’last_name’, 'like’, '%’.$search.’%’); }); }); } return $query->orderBy($sort_by, $sort)->paginate($per_page); } /** * @param $contact_id * @param string $sort_by * @param string $sort * @param string $search * @param int $per_page * @return IlluminateContractsPaginationLengthAwarePaginator */ public function getAppointmentsByContact($contact_id, $sort_by = 'date’, $sort = 'desc’, $search = ”, $per_page = 10) { $query = $this->appointmentModel->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]) ->where(’contact_id’, $contact_id); if (!is_null($search) && !empty($search)) { $query = $query->where(function ($q) use ($search) { $q->where(’title’, 'like’, '%’.$search.’%’) ->orWhere(’notes’, 'like’, '%’.$search.’%’) ->orWhereHas(’dentist’, function ($query) use ($search) { $query->where(’first_name’, 'like’, '%’.$search.’%’) ->orWhere(’last_name’, 'like’, '%’.$search.’%’); }); }); } return $query->orderBy($sort_by, $sort)->paginate($per_page); } /** * @param $startDate * @param $endDate * @return mixed */ public function getWeeklyAppointments($startDate, $endDate) { $query = $this->appointmentModel ->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]) ->where(’date’, ’>=’, $startDate) ->where(’date’, ’get(); } /** * @param string $status * @return mixed */ public function getAppointmentsByStatus($status = 'booked’) { $query = $this->appointmentModel ->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]) ->whereHas(’status’, function ($query) use ($status) { $query->where(’value’, $status); }) ->where(’date’, ’>=’, Carbon::now()->format(’Y-m-d’)); return $query->orderBy(’date’, 'asc’)->orderBy(’start_time’, 'asc’)->take(5)->get(); } /** * Get upcoming appointments count * @return int */ public function getUpcomingAppointmentsCount() { return $this->appointmentModel ->where(’date’, ’>=’, Carbon::now()->format(’Y-m-d’)) ->count(); } /** * Get today’s appointments count * @return int */ public function getTodayAppointmentsCount() { return $this->appointmentModel ->where(’date’, Carbon::now()->format(’Y-m-d’)) ->count(); } /** * Get weekly appointments statistics * @return array */ public function getWeeklyAppointmentStats() { $startOfWeek = Carbon::now()->startOfWeek()->format(’Y-m-d’); $endOfWeek = Carbon::now()->endOfWeek()->format(’Y-m-d’); $results = DB::table(’appointments’) ->select(DB::raw(’DATE(date) as day, COUNT(*) as count’)) ->whereBetween(’date’, [$startOfWeek, $endOfWeek]) ->groupBy(’day’) ->orderBy(’day’) ->get(); // Initialize stats array with zeros for each day $stats = []; $current = Carbon::parse($startOfWeek); while ($current->format(’Y-m-d’) format(’Y-m-d’)] = 0; $current->addDay(); } // Fill in actual counts foreach ($results as $result) { $stats[$result->day] = $result->count; } return $stats; } /** * Get dentist specific weekly appointment stats * @param int $dentistId * @return array */ public function getDentistWeeklyAppointmentStats($dentistId) { $startOfWeek = Carbon::now()->startOfWeek()->format(’Y-m-d’); $endOfWeek = Carbon::now()->endOfWeek()->format(’Y-m-d’); $results = DB::table(’appointments’) ->select(DB::raw(’DATE(date) as day, COUNT(*) as count’)) ->where(’dentist_id’, $dentistId) ->whereBetween(’date’, [$startOfWeek, $endOfWeek]) ->groupBy(’day’) ->orderBy(’day’) ->get(); // Initialize stats array with zeros for each day $stats = []; $current = Carbon::parse($startOfWeek); while ($current->format(’Y-m-d’) format(’Y-m-d’)] = 0; $current->addDay(); } // Fill in actual counts foreach ($results as $result) { $stats[$result->day] = $result->count; } return $stats; } /** * Get appointment statistics by treatment type * @return array */ public function getAppointmentStatsByTreatment() { $results = DB::table(’appointments’) ->join(’pre_defined_data’, 'appointments.treatment_id’, '=’, 'pre_defined_data.id’) ->select(’pre_defined_data.value as treatment’, DB::raw(’COUNT(*) as count’)) ->where(’pre_defined_data.category’, 'treatment’) ->groupBy(’pre_defined_data.value’) ->orderBy(’count’, 'desc’) ->take(5) ->get(); $stats = []; foreach ($results as $result) { $stats[$result->treatment] = $result->count; } return $stats; } /** * Get upcoming appointments * @param int $limit * @return mixed */ public function getUpcomingAppointments($limit = 5) { return $this->appointmentModel ->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]) ->where(’date’, ’>=’, Carbon::now()->format(’Y-m-d’)) ->orderBy(’date’, 'asc’) ->orderBy(’start_time’, 'asc’) ->take($limit) ->get(); } /** * Get today’s appointments * @return mixed */ public function getTodayAppointments() { return $this->appointmentModel ->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]) ->where(’date’, Carbon::now()->format(’Y-m-d’)) ->orderBy(’start_time’, 'asc’) ->get(); } } End File# serbogdanov/project # app/Modules/Appointments/Controllers/AppointmentController.php repository = $repository; $this->service = $service; $this->preDefinedService = $preDefinedService; $this->userRepository = $userRepository; } /** * @param Request $request * @return IlluminateContractsViewFactory|IlluminateViewView */ public function index(Request $request) { $sort_by = $request->get(’sort_by’, 'date’); $sort = $request->get(’sort’, 'desc’); $search = trim($request->get(’search’)); $per_page = config(’project.per_page’); $user = Auth::user(); if ($user->hasRole(’doctor’)) { $appointments = $this->repository->getAppointmentsByDentist($user->id, $sort_by, $sort, $search, $per_page); } else { $appointments = $this->repository->getAllAppointments($sort_by, $sort, $search, $per_page); } return view(’appointments::index’, compact(’appointments’, 'user’)); } /** * @return IlluminateContractsViewFactory|IlluminateViewView */ public function create() { $doctors = $this->userRepository->getUsersByRole(’doctor’); $patients = $this->userRepository->getUsersByRole(’patient’); $status = $this->preDefinedService->getDataByCategory(’status’); $treatments = $this->preDefinedService->getDataByCategory(’treatment’); $types = $this->preDefinedService->getDataByCategory(’appointment_type’); return view(’appointments::create’, compact(’doctors’, 'patients’, 'status’, 'treatments’, 'types’)); } /** * @param CreateAppointmentRequest $request * @return IlluminateHttpRedirectResponse */ public function store(CreateAppointmentRequest $request) { try { $data = $request->all(); $this->service->createAppointment($data); if ($request->has(’save_and_new’)) { return redirect()->route(’appointments.create’) ->with(’success’, trans(’appointments::messages.record_was_successfully_created’)); } return redirect()->route(’appointments.index’) ->with(’success’, trans(’appointments::messages.record_was_successfully_created’)); } catch (Exception $e) { return back() ->with(’error’, „Cannot save appointment: ” . $e->getMessage()); } } /** * @param $id * @return IlluminateContractsViewFactory|IlluminateViewView */ public function show($id) { $appointment = $this->repository->findDetailById($id); if (empty($appointment)) { return redirect()->route(’appointments.index’) ->with(’error’, trans(’appointments::messages.record_not_found’)); } return view(’appointments::show’, compact(’appointment’)); } /** * @param $id * @return IlluminateContractsViewFactory|IlluminateViewView */ public function edit($id) { $appointment = $this->repository->findDetailById($id); if (empty($appointment)) { return redirect()->route(’appointments.index’) ->with(’error’, trans(’appointments::messages.record_not_found’)); } $doctors = $this->userRepository->getUsersByRole(’doctor’); $patients = $this->userRepository->getUsersByRole(’patient’); $status = $this->preDefinedService->getDataByCategory(’status’); $treatments = $this->preDefinedService->getDataByCategory(’treatment’); $types = $this->preDefinedService->getDataByCategory(’appointment_type’); return view(’appointments::edit’, compact(’appointment’, 'doctors’, 'patients’, 'status’, 'treatments’, 'types’)); } /** * @param UpdateAppointmentRequest $request * @param $id * @return IlluminateHttpRedirectResponse */ public function update(UpdateAppointmentRequest $request, $id) { try { $data = $request->all(); $this->service->updateAppointment($id, $data); return redirect()->route(’appointments.index’) ->with(’success’, trans(’appointments::messages.record_was_successfully_updated’)); } catch (Exception $e) { return back() ->with(’error’, „Cannot update appointment: ” . $e->getMessage()); } } /** * @param $id * @return IlluminateHttpRedirectResponse */ public function destroy($id) { try { $this->repository->delete($id); return back() ->with(’success’, trans(’appointments::messages.record_was_successfully_deleted’)); } catch (Exception $e) { return back() ->with(’error’, trans(’appointments::messages.record_was_not_deleted_due_to_system_error’)); } } /** * @param Request $request * @return IlluminateHttpJsonResponse */ public function events(Request $request) { try { $start = $request->get(’start’); $end = $request->get(’end’); $doctorId = $request->get(’doctor_id’); $data = $this->repository->getDentistAppointment($doctorId, $start, $end); $formattedData = $this->service->formatAppointmentsForCalendar($data); return response()->json([ 'status’ => 'success’, 'data’ => $formattedData ]); } catch (Exception $e) { return response()->json([ 'status’ => 'error’, 'message’ => $e->getMessage() ]); } } /** * @param Request $request * @return IlluminateHttpJsonResponse */ public function quickCreate(Request $request) { try { DB::beginTransaction(); $data = $request->all(); $appointment = $this->service->createAppointment($data); DB::commit(); return response()->json([ 'status’ => 'success’, 'message’ => 'Appointment created successfully’, 'data’ => $appointment ]); } catch (Exception $e) { DB::rollBack(); return response()->json([ 'status’ => 'error’, 'message’ => $e->getMessage() ]); } } /** * @param Request $request * @param $id * @return IlluminateHttpJsonResponse */ public function updateEvent(Request $request, $id) { try { DB::beginTransaction(); $data = $request->all(); $appointment = $this->service->updateEventTiming($id, $data); DB::commit(); return response()->json([ 'status’ => 'success’, 'message’ => 'Appointment updated successfully’, 'data’ => $appointment ]); } catch (Exception $e) { DB::rollBack(); return response()->json([ 'status’ => 'error’, 'message’ => $e->getMessage() ]); } } /** * @param Request $request * @param $id * @return IlluminateHttpJsonResponse */ public function quickUpdate(Request $request, $id) { try { DB::beginTransaction(); $data = $request->all(); $appointment = $this->service->updateAppointment($id, $data); DB::commit(); return response()->json([ 'status’ => 'success’, 'message’ => 'Appointment updated successfully’, 'data’ => $appointment ]); } catch (Exception $e) { DB::rollBack(); return response()->json([ 'status’ => 'error’, 'message’ => $e->getMessage() ]); } } /** * @param $id * @return IlluminateHttpJsonResponse */ public function getAppointment($id) { try { $appointment = $this->repository->findDetailById($id); if (empty($appointment)) { throw new Exception(’Appointment not found’); } return response()->json([ 'status’ => 'success’, 'data’ => $appointment ]); } catch (Exception $e) { return response()->json([ 'status’ => 'error’, 'message’ => $e->getMessage() ]); } } /** * @return IlluminateContractsViewFactory|IlluminateViewView */ public function calendar() { $doctors = $this->userRepository->getUsersByRole(’doctor’); $patients = $this->userRepository->getUsersByRole(’patient’); $status = $this->preDefinedService->getDataByCategory(’status’); $treatments = $this->preDefinedService->getDataByCategory(’treatment’); $types = $this->preDefinedService->getDataByCategory(’appointment_type’); // Get current user $user = Auth::user(); $currentDoctorId = $user->hasRole(’doctor’) ? $user->id : null; return view(’appointments::calendar’, compact( 'doctors’, 'patients’, 'status’, 'treatments’, 'types’, 'currentDoctorId’ )); } /** * @param Request $request * @return IlluminateHttpJsonResponse */ public function weeklyView(Request $request) { try { $startDate = $request->get(’start_date’, Carbon::now()->startOfWeek()->format(’Y-m-d’)); $endDate = $request->get(’end_date’, Carbon::now()->endOfWeek()->format(’Y-m-d’)); $appointments = $this->repository->getWeeklyAppointments($startDate, $endDate); $formattedData = $this->service->formatAppointmentsForWeeklyView($appointments); return response()->json([ 'status’ => 'success’, 'data’ => $formattedData, 'period’ => [ 'start’ => $startDate, 'end’ => $endDate ] ]); } catch (Exception $e) { return response()->json([ 'status’ => 'error’, 'message’ => $e->getMessage() ]); } } /** * @return IlluminateContractsViewFactory|IlluminateViewView */ public function weeklySchedule() { $doctors = $this->userRepository->getUsersByRole(’doctor’); $currentWeek = [ 'start’ => Carbon::now()->startOfWeek()->format(’Y-m-d’), 'end’ => Carbon::now()->endOfWeek()->format(’Y-m-d’) ]; return view(’appointments::weekly’, compact(’doctors’, 'currentWeek’)); } /** * @param Request $request * @return IlluminateHttpResponse|SymfonyComponentHttpFoundationBinaryFileResponse */ public function exportPdf(Request $request) { $data = $request->all(); $format = isset($data[’format’]) ? $data[’format’] : 'weekly’; if ($format === 'weekly’) { $startDate = $request->get(’start_date’, Carbon::now()->startOfWeek()->format(’Y-m-d’)); $endDate = $request->get(’end_date’, Carbon::now()->endOfWeek()->format(’Y-m-d’)); $appointments = $this->repository->getWeeklyAppointments($startDate, $endDate); $formattedData = $this->service->formatAppointmentsForWeeklyView($appointments); $pdf = PDF::loadView(’appointments::exports.weekly_pdf’, [ 'appointments’ => $formattedData, 'startDate’ => Carbon::parse($startDate)->format(’d M Y’), 'endDate’ => Carbon::parse($endDate)->format(’d M Y’) ]); return $pdf->download(’weekly-schedule-’.Carbon::now()->format(’Y-m-d’).’.pdf’); } else { // Handle other formats if needed return response(’Unsupported format’, 400); } } } End File# app/Modules/Appointments/Services/AppointmentService.php repository = $repository; } /** * @param array $data * @return mixed * @throws Exception */ public function createAppointment(array $data) { // Validate appointment overlap if needed if (isset($data[’check_overlap’]) && $data[’check_overlap’]) { $this->validateAppointmentOverlap($data); } // Format data for creation $appointmentData = $this->prepareAppointmentData($data); // Create appointment return $this->repository->create($appointmentData); } /** * @param int $id * @param array $data * @return mixed * @throws Exception */ public function updateAppointment($id, array $data) { // Validate appointment overlap if needed if (isset($data[’check_overlap’]) && $data[’check_overlap’]) { $this->validateAppointmentOverlap($data, $id); } // Format data for update $appointmentData = $this->prepareAppointmentData($data); // Update appointment return $this->repository->update($id, $appointmentData); } /** * @param $id * @param array $data * @return mixed * @throws Exception */ public function updateEventTiming($id, array $data) { $appointment = $this->repository->findById($id); if (empty($appointment)) { throw new Exception(’Appointment not found.’); } // Extract date and time from start and end $startDt = Carbon::parse($data[’start’]); $endDt = Carbon::parse($data[’end’]); $updateData = [ 'date’ => $startDt->format(’Y-m-d’), 'start’ => $startDt->format(’H:i’), 'end’ => $endDt->format(’H:i’), 'duration’ => $endDt->diffInMinutes($startDt) ]; // Check for all_day flag if (isset($data[’allDay’])) { $updateData[’all_day’] = $data[’allDay’] ? 1 : 0; } return $this->repository->update($id, $updateData); } /** * @param array $data * @param int|null $excludeId * @throws Exception */ protected function validateAppointmentOverlap(array $data, $excludeId = null) { // Implementation of overlap validation logic // This should check if the new appointment time conflicts with existing ones // for the same dentist // Example implementation left for you to complete based on your requirements } /** * @param array $data * @return array */ protected function prepareAppointmentData(array $data) { // Clean and format the data for appointment creation/update $appointmentData = []; // Map fields from request to appointment model fields $fieldMappings = [ 'contact_id’, 'title’, 'dentist_id’, 'type_id’, 'duration’, 'date’, 'treatment_id’, 'start’, 'end’, 'all_day’, 'notes’, 'color’, 'is_block’, 'is_initial’, 'status_id’ ]; foreach ($fieldMappings as $field) { if (array_key_exists($field, $data)) { $appointmentData[$field] = $data[$field]; } } // Handle special field transformations if needed return $appointmentData; } /** * Format appointments for calendar view * @param $appointments * @return array */ public function formatAppointmentsForCalendar($appointments) { $formattedAppointments = []; foreach ($appointments as $appointment) { $event = [ 'id’ => $appointment->id, 'title’ => $appointment->title, 'start’ => Carbon::parse($appointment->date.’ ’.$appointment->start_time)->toIso8601String(), 'end’ => Carbon::parse($appointment->date.’ ’.$appointment->end_time)->toIso8601String(), 'allDay’ => (bool)$appointment->all_day, 'backgroundColor’ => !empty($appointment->color) ? $appointment->color : '#3788d8′, 'borderColor’ => !empty($appointment->color) ? $appointment->color : '#3788d8′, 'extendedProps’ => [ 'contact_id’ => $appointment->contact_id, 'dentist_id’ => $appointment->dentist_id, 'type_id’ => $appointment->type_id, 'treatment_id’ => $appointment->treatment_id, 'status_id’ => $appointment->status_id, 'notes’ => $appointment->notes, 'is_block’ => $appointment->is_block, 'is_initial’ => $appointment->is_initial, 'duration’ => $appointment->duration, ] ]; // Add patient information if available if ($appointment->contact) { $event[’extendedProps’][’patient’] = [ 'id’ => $appointment->contact->id, 'name’ => $appointment->contact->first_name . ’ ’ . $appointment->contact->last_name, 'phone’ => $appointment->contact->phone ]; } // Add dentist information if available if ($appointment->dentist) { $event[’extendedProps’][’dentist’] = [ 'id’ => $appointment->dentist->id, 'name’ => $appointment->dentist->first_name . ’ ’ . $appointment->dentist->last_name ]; } $formattedAppointments[] = $event; } return $formattedAppointments; } /** * Format appointments for weekly view * @param $appointments * @return array */ public function formatAppointmentsForWeeklyView($appointments) { $formattedData = []; // Group appointments by date $appointmentsByDate = []; foreach ($appointments as $appointment) { $date = $appointment->date; if (!isset($appointmentsByDate[$date])) { $appointmentsByDate[$date] = []; } $appointmentsByDate[$date][] = $appointment; } // Sort appointments by time for each date foreach ($appointmentsByDate as $date => $dateAppointments) { usort($dateAppointments, function($a, $b) { return strtotime($a->start_time) – strtotime($b->start_time); }); $formattedData[$date] = []; foreach ($dateAppointments as $appointment) { $formattedData[$date][] = [ 'id’ => $appointment->id, 'title’ => $appointment->title, 'start_time’ => $appointment->start_time, 'end_time’ => $appointment->end_time, 'patient_name’ => $appointment->contact ? ($appointment->contact->first_name . ’ ’ . $appointment->contact->last_name) : 'N/A’, 'dentist_name’ => $appointment->dentist ? ($appointment->dentist->first_name . ’ ’ . $appointment->dentist->last_name) : 'N/A’, 'treatment’ => $appointment->treatment ? $appointment->treatment->value : 'N/A’, 'status’ => $appointment->status ? $appointment->status->value : 'N/A’, 'color’ => !empty($appointment->color) ? $appointment->color : '#3788d8′, ]; } } return $formattedData; } } End FilegetClientOriginalExtension()); if (!in_array($fileExtension, $allowedTypes)) { throw new Exception(„File type not allowed. Allowed types: ” . implode(’, ’, $allowedTypes)); } } // Validate the file size if maxSize is specified if ($maxSize > 0 && $file->getSize() > $maxSize * 1024) { throw new Exception(„File size exceeds maximum allowed size of {$maxSize}KB”); } // Generate a unique filename $filename = Str::random(20) . ’.’ . $file->getClientOriginalExtension(); // Set storage path $path = $folder ? $folder . '/’ . $filename : $filename; // Store the file Storage::disk($disk)->put($path, file_get_contents($file)); // Return the file path return $path; } /** * Upload and resize image * * @param IlluminateHttpUploadedFile $file * @param string $folder * @param bool $isPublic * @param int $width * @param int $height * @param bool $maintainAspectRatio * @return string|null */ public function uploadImage($file, $folder = null, $isPublic = true, $width = null, $height = null, $maintainAspectRatio = true) { $allowedTypes = [’jpg’, 'jpeg’, 'png’, 'gif’, 'webp’]; // Check if file is valid image $fileExtension = strtolower($file->getClientOriginalExtension()); if (!in_array($fileExtension, $allowedTypes)) { throw new Exception(„File type not allowed. Allowed types: ” . implode(’, ’, $allowedTypes)); } // Generate a unique filename $filename = Str::random(20) . ’.’ . $fileExtension; // Set storage path $path = $folder ? $folder . '/’ . $filename : $filename; // Create image instance $image = Image::make($file); // Resize if dimensions provided if ($width || $height) { if ($maintainAspectRatio) { $image->resize($width, $height, function ($constraint) { $constraint->aspectRatio(); $constraint->upsize(); }); } else { $image->resize($width, $height); } } // Define the disk to use $disk = $isPublic ? 'public’ : 'local’; // Save the image Storage::disk($disk)->put($path, (string) $image->encode()); // Return the file path return $path; } /** * Delete file from storage * * @param string $path * @param bool $isPublic * @return bool */ public function deleteFile($path, $isPublic = true) { if (!$path) { return false; } $disk = $isPublic ? 'public’ : 'local’; if (Storage::disk($disk)->exists($path)) { Storage::disk($disk)->delete($path); return true; } return false; } /** * Get public URL for a file * * @param string $path * @return string|null */ public function getFileUrl($path) { if (!$path) { return null; } return Storage::url($path); } } x: number, y: number, width: number, height: number, offset: number, borderRadius: number) { // 3D的框子 context.save(); // 边框设置 context.strokeStyle = 'rgba(66, 77, 116, 0.7)’; context.lineWidth = 2; // 绘制立体框的内壁 const innerTop = y + offset; const innerLeft = x + offset; const innerWidth = width – offset * 2; const innerHeight = height – offset * 2; // 绘制底部矩形(带圆角) this.drawRoundedRect(context, x, y, width, height, borderRadius); context.fillStyle = 'rgba(23, 30, 46, 0.7)’; context.fill(); context.stroke(); // 绘制顶部矩形(带圆角) this.drawRoundedRect(context, innerLeft, innerTop, innerWidth, innerHeight, borderRadius – offset); context.fillStyle = 'rgba(47, 57, 86, 0.7)’; context.fill(); context.stroke(); // 绘制内壁连接线 context.beginPath(); context.moveTo(x + borderRadius, y); context.lineTo(innerLeft + borderRadius – offset, innerTop); context.stroke(); context.beginPath(); context.moveTo(x + width – borderRadius, y); context.lineTo(innerLeft + innerWidth – borderRadius + offset, innerTop); context.stroke(); context.beginPath(); context.moveTo(x + width, y + borderRadius); context.lineTo(innerLeft + innerWidth, innerTop + borderRadius – offset); context.stroke(); context.beginPath(); context.moveTo(x + width, y + height – borderRadius); context.lineTo(innerLeft + innerWidth, innerTop + innerHeight – borderRadius + offset); context.stroke(); context.beginPath(); context.moveTo(x + width – borderRadius, y + height); context.lineTo(innerLeft + innerWidth – borderRadius + offset, innerTop + innerHeight); context.stroke(); context.beginPath(); context.moveTo(x + borderRadius, y + height); context.lineTo(innerLeft + borderRadius – offset, innerTop + innerHeight); context.stroke(); context.beginPath(); context.moveTo(x, y + height – borderRadius); context.lineTo(innerLeft, innerTop + innerHeight – borderRadius + offset); context.stroke(); context.beginPath(); context.moveTo(x, y + borderRadius); context.lineTo(innerLeft, innerTop + borderRadius – offset); context.stroke(); context.restore(); } private drawRoundedRect(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) { context.beginPath(); context.moveTo(x + radius, y); context.lineTo(x + width – radius, y); context.quadraticCurveTo(x + width, y, x + width, y + radius); context.lineTo(x + width, y + height – radius); context.quadraticCurveTo(x + width, y + height, x + width – radius, y + height); context.lineTo(x + radius, y + height); context.quadraticCurveTo(x, y + height, x, y + height – radius); context.lineTo(x, y + radius); context.quadraticCurveTo(x, y, x + radius, y); context.closePath(); } //绘制装饰性箭头 private drawDecorativeArrow(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) { const centerX = x + width / 2; const centerY = y + height / 2; const arrowSize = Math.min(width, height) * 0.3; context.save(); context.strokeStyle = 'rgba(66, 133, 244, 0.6)’; context.lineWidth = 2; context.lineCap = ’round’; context.lineJoin = ’round’; // 绘制四个方向的箭头 // 上箭头 context.beginPath(); context.moveTo(centerX, centerY – arrowSize); context.lineTo(centerX, centerY – arrowSize / 3); context.moveTo(centerX – arrowSize / 4, centerY – arrowSize * 0.6); context.lineTo(centerX, centerY – arrowSize); context.lineTo(centerX + arrowSize / 4, centerY – arrowSize * 0.6); context.stroke(); // 右箭头 context.beginPath(); context.moveTo(centerX + arrowSize, centerY); context.lineTo(centerX + arrowSize / 3, centerY); context.moveTo(centerX + arrowSize * 0.6, centerY – arrowSize / 4); context.lineTo(centerX + arrowSize, centerY); context.lineTo(centerX + arrowSize * 0.6, centerY + arrowSize / 4); context.stroke(); // 下箭头 context.beginPath(); context.moveTo(centerX, centerY + arrowSize); context.lineTo(centerX, centerY + arrowSize / 3); context.moveTo(centerX – arrowSize / 4, centerY + arrowSize * 0.6); context.lineTo(centerX, centerY + arrowSize); context.lineTo(centerX + arrowSize / 4, centerY + arrowSize * 0.6); context.stroke(); // 左箭头 context.beginPath(); context.moveTo(centerX – arrowSize, centerY); context.lineTo(centerX – arrowSize / 3, centerY); context.moveTo(centerX – arrowSize * 0.6, centerY – arrowSize / 4); context.lineTo(centerX – arrowSize, centerY); context.lineTo(centerX – arrowSize * 0.6, centerY + arrowSize / 4); context.stroke(); context.restore(); } //绘制内部装饰线 private drawDecorativeLines(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) { context.save(); context.strokeStyle = 'rgba(66, 133, 244, 0.3)’; context.lineWidth = 1; // 绘制网格状的装饰线 const gridSize = Math.min(width, height) / 8; // 水平线 for (let i = 1; i < height / gridSize; i++) { context.beginPath(); context.moveTo(x, y + i * gridSize); context.lineTo(x + width, y + i * gridSize); context.stroke(); } // 垂直线 for (let i = 1; i < width / gridSize; i++) { context.beginPath(); context.moveTo(x + i * gridSize, y); context.lineTo(x + i * gridSize, y + height); context.stroke(); } context.restore(); } //绘制高科技圆形装饰 private drawTechCircle(context: CanvasRenderingContext2D, x: number, y: number, size: number) { context.save(); const centerX = x + size / 2; const centerY = y + size / 2; const radius = size / 2; // 绘制外圆环 context.beginPath(); context.arc(centerX, centerY, radius, 0, Math.PI * 2); context.strokeStyle = 'rgba(66, 133, 244, 0.4)'; context.lineWidth = 2; context.stroke(); // 绘制内圆环 context.beginPath(); context.arc(centerX, centerY, radius * 0.8, 0, Math.PI * 2); context.strokeStyle = 'rgba(66, 133, 244, 0.3)'; context.lineWidth = 1; context.stroke(); // 绘制十字标记 context.beginPath(); context.moveTo(centerX – radius * 0.6, centerY); context.lineTo(centerX + radius * 0.6, centerY); context.moveTo(centerX, centerY – radius * 0.6); context.lineTo(centerX, centerY + radius * 0.6); context.strokeStyle = 'rgba(66, 133, 244, 0.5)'; context.lineWidth = 1; context.stroke(); // 绘制四个点 const pointRadius = radius * 0.05; const pointDistance = radius * 0.7; context.fillStyle = 'rgba(66, 133, 244, 0.7)'; context.beginPath(); context.arc(centerX + pointDistance, centerY, pointRadius, 0, Math.PI * 2); context.fill(); context.beginPath(); context.arc(centerX – pointDistance, centerY, pointRadius, 0, Math.PI * 2); context.fill(); context.beginPath(); context.arc(centerX, centerY + pointDistance, pointRadius, 0, Math.PI * 2); context.fill(); context.beginPath(); context.arc(centerX, centerY – pointDistance, pointRadius, 0, Math.PI * 2); context.fill(); // 绘制弧线装饰 for (let i = 0; i < 4; i++) { const startAngle = i * Math.PI / 2 + Math.PI / 8; const endAngle = startAngle + Math.PI / 4; context.beginPath(); context.arc(centerX, centerY, radius * 0.9, startAngle, endAngle); context.strokeStyle = 'rgba(66, 133, 244, 0.6)'; context.lineWidth = 1.5; context.stroke(); } context.restore(); } /** * 绘制旋转中的装饰性圆 */ private drawRotatingCircles(context: CanvasRenderingContext2D, x: number, y: number, size: number, time: number) { const centerX = x + size / 2; const centerY = y + size / 2; context.save(); // 计算旋转角度 const rotation1 = (time / 2000) % (Math.PI * 2); const rotation2 = (time / 3000) % (Math.PI * 2); // 绘制外圆 context.beginPath(); context.arc(centerX, centerY, size * 0.45, 0, Math.PI * 2); context.strokeStyle = 'rgba(66, 133, 244, 0.2)'; context.lineWidth = 1; context.stroke(); // 绘制旋转的分割线 – 外圈 context.translate(centerX, centerY); context.rotate(rotation1); context.translate(-centerX, -centerY); for (let i = 0; i < 12; i++) { context.save(); context.translate(centerX, centerY); context.rotate(i * Math.PI / 6); context.beginPath(); context.moveTo(size * 0.35, 0); context.lineTo(size * 0.45, 0); context.strokeStyle = 'rgba(66, 133, 244, 0.6)'; context.lineWidth = 1; context.stroke(); context.restore(); } // 绘制内圆 context.setTransform(1, 0, 0, 1, 0, 0); context.beginPath(); context.arc(centerX, centerY, size * 0.3, 0, Math.PI * 2); context.strokeStyle = 'rgba(66, 133, 244, 0.3)'; context.lineWidth = 1; context.stroke(); // 绘制旋转的分割线 – 内圈 context.translate(centerX, centerY); context.rotate(rotation2); context.translate(-centerX, -centerY); for (let i = 0; i < 8; i++) { context.save(); context.translate(centerX, centerY); context.rotate(i * Math.PI / 4); context.beginPath(); context.moveTo(size * 0.2, 0); context.lineTo(size * 0.3, 0); context.strokeStyle = 'rgba(66, 133, 244, 0.5)'; context.lineWidth = 1; context.stroke(); context.restore(); } // 绘制中心小圆 context.setTransform(1, 0, 0, 1, 0, 0); context.beginPath(); context.arc(centerX, centerY, size * 0.05, 0, Math.PI * 2); context.fillStyle = 'rgba(66, 133, 244, 0.6)'; context.fill(); context.restore(); } /** * 绘制左下角的指示标记 */ private drawCornerMarker(context: CanvasRenderingContext2D, x: number, y: number, size: number) { const cornerX = x; const cornerY = y + size; context.save(); context.strokeStyle = 'rgba(66, 133, 244, 0.7)'; context.fillStyle = 'rgba(66, 133, 244, 0.1)'; context.lineWidth = 1; // 绘制L形标记 context.beginPath(); context.moveTo(cornerX, cornerY – size * 0.3); context.lineTo(cornerX, cornerY); context.lineTo(cornerX + size * 0.3, cornerY); context.stroke(); // 绘制小方块 context.beginPath(); context.rect(cornerX + size * 0.15 – size * 0.05, cornerY – size * 0.15 – size * 0.05, size * 0.1, size * 0.1); context.fill(); context.stroke(); context.restore(); } /** * 绘制右上角的指示标记 */ private drawTopRightMarker(context: CanvasRenderingContext2D, x: number, y: number, width: number, size: number) { const cornerX = x + width; const cornerY = y; context.save(); context.strokeStyle = 'rgba(66, 133, 244, 0.7)'; context.fillStyle = 'rgba(66, 133, 244, 0.1)'; context.lineWidth = 1; // 绘制反L形标记 context.beginPath(); context.moveTo(cornerX, cornerY + size * 0.3); context.lineTo(cornerX, cornerY); context.lineTo(cornerX – size * 0.3, cornerY); context.stroke(); // 绘制小方块 context.beginPath(); context.rect(cornerX – size * 0.15 – size * 0.05, cornerY + size * 0.15 – size * 0.05, size * 0.1, size * 0.1); context.fill(); context.stroke(); context.restore(); } /** * 绘制数据流动的动画效果 */ private drawDataFlow(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, time: number) { context.save(); // 数据流的参数 const flowSpeed = 1000; // 流动速度的基准值(毫秒) const flowCount = 3; // 数据流的数量 const flowLength = Math.min(width, height) * 0.15; // 数据流的长度 const flowWidth = 2; // 数据流的宽度 // 设置数据流样式 context.strokeStyle = 'rgba(66, 133, 244, 0.7)'; context.lineWidth = flowWidth; context.lineCap = 'round'; // 绘制右边的数据流 for (let i = 0; i < flowCount; i++) { const offset = (time + i * (flowSpeed / flowCount)) % flowSpeed / flowSpeed; const startY = y + height * offset; if (startY y + height) { visibleLength = y + height – startY; } context.beginPath(); context.moveTo(x + width, startY); context.lineTo(x + width, startY + visibleLength); context.stroke(); } } // 绘制下边的数据流 for (let i = 0; i < flowCount; i++) { const offset = (time + i * (flowSpeed / flowCount) + flowSpeed / 2) % flowSpeed / flowSpeed; const startX = x + width * offset; if (startX x + width) { visibleLength = x + width – startX; } context.beginPath(); context.moveTo(startX, y + height); context.lineTo(startX + visibleLength, y + height); context.stroke(); } } context.restore(); } /** * 绘制高科技左上角标记 */ private drawTopLeftMarker(context: CanvasRenderingContext2D, x: number, y: number, size: number) { context.save(); // 设置样式 context.strokeStyle = 'rgba(66, 133, 244, 0.7)’; context.lineWidth = 1; // 绘制角标记 context.beginPath(); context.moveTo(x, y + size * 0.3); context.lineTo(x, y); context.lineTo(x + size * 0.3, y); context.stroke(); // 绘制装饰点 context.fillStyle = 'rgba(66, 133, 244, 0.8)’; context.beginPath(); context.arc(x + size * 0.15, y + size * 0.15, size * 0.03, 0, Math.PI * 2); context.fill(); context.restore(); } /** * 绘制高科技右下角标记 */ private drawBottomRightMarker(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, size: number) { const cornerX = x + width; const cornerY = y + height; context.save(); // 设置样式 context.strokeStyle = 'rgba(66, 133, 244, 0.7)’; context.lineWidth = 1; // 绘制角标记 context.beginPath(); context.moveTo(cornerX, cornerY – size * 0.3); context.lineTo(cornerX, cornerY); context.lineTo(cornerX – size * 0.3, cornerY); context.stroke(); // 绘制装饰点 context.fillStyle = 'rgba(66, 133, 244, 0.8)’; context.beginPath(); context.arc(cornerX – size * 0.15, cornerY – size * 0.15, size * 0.03, 0, Math.PI * 2); context.fill(); context.restore(); } /** * 绘制高科技装饰性刻度 */ private drawTechScale(context: CanvasRenderingContext2D, x: number, y: number, width: number, time: number) { context.save(); // 刻度参数 const scaleHeight = 5; const scaleCount = 20; const scaleSpacing = width / scaleCount; const animOffset = (time / 1000) % 1 * scaleSpacing; // 设置样式 context.strokeStyle = 'rgba(66, 133, 244, 0.5)’; context.lineWidth = 1; // 绘制刻度 for (let i = 0; i void, debounceTimeMs: number = 300) { // 取消之前的订阅 if (this.searchSubscription) { this.searchSubscription.unsubscribe(); } // 创建新的订阅,处理搜索操作 this.searchSubscription = this.searchSubject.pipe( debounceTime(debounceTimeMs), distinctUntilChanged(), tap(() => { this.isLoading = true; // 搜索开始时设置loading状态 }), switchMap(searchText => { return timer(500).pipe( map(() => searchText) ); }), ).subscribe(searchText => { searchHandler(searchText); this.isLoading = false; // 搜索完成后关闭loading状态 }); } /** * 设置刷新处理器 * @param refreshHandler 刷新处理函数 */ setupRefresh(refreshHandler: () => void) { // 取消之前的订阅 if (this.refreshSubscription) { this.refreshSubscription.unsubscribe(); } // 创建新的订阅,处理刷新操作 this.refreshSubscription = this.refreshSubject.pipe( tap(() => { this.isLoading = true; // 刷新开始时设置loading状态 }), switchMap(() => { return timer(500).pipe( map(() => {}) ); }), ).subscribe(() => { refreshHandler(); this.isLoading = false; // 刷新完成后关闭loading状态 }); } /** * 触发搜索 * @param searchText 搜索文本 */ search(searchText: string) { this.searchSubject.next(searchText); } /** * 触发刷新 */ refresh() { this.refreshSubject.next(); } /** * 清理订阅,避免内存泄漏 */ destroy() { if (this.searchSubscription) { this.searchSubscription.unsubscribe(); this.searchSubscription = null; } if (this.refreshSubscription) { this.refreshSubscription.unsubscribe(); this.refreshSubscription = null; } } } End Fileimport { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core’; import { CommonModule } from '@angular/common’; import { SciFiHUDRenderer } from ’./sci-fi-hud-renderer’; // 定义HUD模式枚举 export enum HUDMode { NORMAL = 'normal’, // 正常模式 ALERT = 'alert’, // 警告模式 SUCCESS = 'success’, // 成功模式 INACTIVE = 'inactive’ // 不活跃模式 } // HUD显示数据接口 export interface HUDData { title?: string; // 标题 data?: any; // 数据 targetValue?: number; // 目标值 currentValue?: number; // 当前值 percentage?: number; // 百分比 isLocked?: boolean; // 是否锁定 status?: string; // 状态文本 mode?: HUDMode; // HUD模式 subtitle?: string; // 副标题 } @Component({ selector: 'app-sci-fi-hud’, standalone: true, imports: [CommonModule], templateUrl: ’./sci-fi-hud.component.html’, styleUrl: ’./sci-fi-hud.component.scss’ }) export class SciFiHudComponent implements OnInit { @ViewChild(’hudCanvas’, { static: true }) hudCanvas!: ElementRef; @Input() width: number = 300; @Input() height: number = 200; @Input() data: HUDData = {}; private renderer!: SciFiHUDRenderer; private animationId: number = 0; constructor() {} ngOnInit(): void { this.initializeCanvas(); this.startRendering(); } ngOnChanges(): void { if (this.renderer) { this.renderer.setData(this.data); } } ngOnDestroy(): void { if (this.animationId) { cancelAnimationFrame(this.animationId); } } private initializeCanvas(): void { const canvas = this.hudCanvas.nativeElement; canvas.width = this.width; canvas.height = this.height; const ctx = canvas.getContext(’2d’); if (!ctx) { console.error(’Cannot get 2D context from canvas’); return; } // 初始化渲染器 this.renderer = new SciFiHUDRenderer(ctx, this.width, this.height); this.renderer.setData(this.data); } private startRendering(): void { const renderFrame = () => { if (!this.renderer) return; this.renderer.render(); this.animationId = requestAnimationFrame(renderFrame); }; renderFrame(); } /** * 更新数据并刷新HUD显示 * @param data 要更新的HUD数据 */ public updateData(data: Partial): void { this.data = { …this.data, …data }; if (this.renderer) { this.renderer.setData(this.data); } } /** * 设置HUD模式 * @param mode HUD模式 */ public setMode(mode: HUDMode): void { this.updateData({ mode }); } /** * 设置进度值 * @param currentValue 当前值 * @param targetValue 目标值(可选) */ public setProgress(currentValue: number, targetValue?: number): void { const updateData: Partial = { currentValue }; if (targetValue !== undefined) { updateData.targetValue = targetValue; } if (updateData.targetValue) { updateData.percentage = Math.min(100, Math.max(0, (updateData.currentValue || 0) / updateData.targetValue * 100)); } this.updateData(updateData); } /** * 设置锁定状态 * @param isLocked 是否锁定 */ public setLocked(isLocked: boolean): void { this.updateData({ isLocked }); } /** * 设置状态文本 * @param status 状态文本 */ public setStatus(status: string): void { this.updateData({ status }); } } End Fileimport { Component, EventEmitter, Input, OnInit, Output } from '@angular/core’; import { CommonModule } from '@angular/common’; import { FormsModule } from '@angular/forms’; import { ListLoadingController } from ’../list-loading-controller.service’; /** * 表格分页事件接口 */ export interface PageEvent { pageIndex: number; // 当前页码索引(从0开始) pageSize: number; // 每页显示条数 length: number; // 总条数 } /** * 表格列配置接口 */ export interface TableColumn { field: string; // 字段名 header: string; // 显示的表头 sortable?: boolean; // 是否可排序 class?: string; // 列样式类 type?: 'text’ | 'number’ | 'date’ | 'boolean’ | 'custom’; // 列数据类型 format?: (value: any) => any; // 自定义格式化函数 width?: string; // 列宽度 align?: 'left’ | 'center’ | 'right’; // 对齐方式 visible?: boolean; // 是否可见,默认为true } /** * 加载中表格组件 * 一个现代化、高性能的数据表格组件,支持排序、分页和加载状态 */ @Component({ selector: 'app-list-loading-table’, standalone: true, imports: [CommonModule, FormsModule], templateUrl: ’./list-loading-table.component.html’, styleUrl: ’./list-loading-table.component.scss’ }) export class ListLoadingTableComponent implements OnInit { @Input() data: any[] = []; // 表格数据 @Input() columns: TableColumn[] = []; // 列配置 @Input() enablePagination: boolean = true; // 是否启用分页 @Input() pageSize: number = 10; // 每页数据量 @Input() pageSizeOptions: number[] = [5, 10, 25, 50]; // 可选每页数量 @Input() totalRecords: number = 0; // 总记录数 @Input() currentPageIndex: number = 0; // 当前页码(从0开始) @Input() showSearch: boolean = true; // 是否显示搜索 @Input() searchPlaceholder: string = '搜索…’; // 搜索框提示文本 @Input() noDataMessage: string = '没有数据’; // 无数据时的提示信息 @Input() loading: boolean = false; // 加载状态 @Input() showRefresh: boolean = true; // 是否显示刷新按钮 @Input() stripped: boolean = true; // 是否使用条纹背景 @Input() bordered: boolean = false; // 是否显示边框 @Input() hoverable: boolean = true; // 行是否有悬停效果 @Input() emptyStateImage?: string; // 空状态图片 @Input() expandableRows: boolean = false; // 是否允许行展开 @Input() selectable: boolean = false; // 是否可选择行 @Output() pageChange = new EventEmitter(); // 分页变更事件 @Output() sortChange = new EventEmitter(); // 排序变更事件 @Output() searchChange = new EventEmitter(); // 搜索变更事件 @Output() refreshRequest = new EventEmitter(); // 刷新请求事件 @Output() rowClick = new EventEmitter(); // 行点击事件 @Output() rowSelect = new EventEmitter(); // 行选择事件 searchText: string = ”; // 搜索文本 selectedRows: Set = new Set(); // 已选择的行 expandedRows: Set = new Set(); // 已展开的行 sortField: string | null = null; // 当前排序字段 sortDirection: 'asc’ | 'desc’ = 'asc’; // 当前排序方向 get visibleColumns(): TableColumn[] { return this.columns.filter(col => col.visible !== false); } constructor(public loadingController: ListLoadingController) {} ngOnInit() { // 设置搜索处理 this.loadingController.setupSearch((searchText) => { this.searchChange.emit(searchText); }); // 设置刷新处理 this.loadingController.setupRefresh(() => { this.refreshRequest.emit(); }); } /** * 处理搜索变更 */ onSearchChange() { this.loadingController.search(this.searchText); } /** * 处理刷新请求 */ onRefreshClick() { this.loadingController.refresh(); } /** * 处理排序变更 * @param field 排序字段 */ onSort(field: string) { // 查找列配置,检查是否可排序 const column = this.columns.find(col => col.field === field); if (!column || column.sortable === false) return; // 如果点击的是当前排序列,则切换排序方向 if (this.sortField === field) { this.sortDirection = this.sortDirection === 'asc’ ? 'desc’ : 'asc’; } else { // 否则使用新的排序列,并设置默认排序方向为升序 this.sortField = field; this.sortDirection = 'asc’; } this.sortChange.emit({ field, direction: this.sortDirection }); } /** * 处理页大小变更 * @param size 新的页大小 */ onPageSizeChange(size: number) { this.pageSize = size; this.currentPageIndex = 0; // 重置页码 this.emitPageEvent(); } /** * 处理页面变更 * @param offset 页码偏移量(1 表示下一页,-1 表示上一页) */ onPageChange(offset: number) { const newPageIndex = this.currentPageIndex + offset; if (newPageIndex >= 0 && newPageIndex * this.pageSize this.selectedRows.add(row)); } else { this.selectedRows.clear(); } this.rowSelect.emit(Array.from(this.selectedRows)); } /** * 检查行是否被选择 * @param row 行数据 */ isSelected(row: any): boolean { return this.selectedRows.has(row); } /** * 获取全选状态 * 如果所有行都被选中,返回true; * 如果部分行被选中,返回null(indeterminate状态); * 如果没有行被选中,返回false。 */ get selectAllState(): boolean | null { if (this.data.length === 0) return false; if (this.selectedRows.size === 0) return false; if (this.selectedRows.size === this.data.length) return true; return null; // indeterminate state } /** * 检查行是否展开 * @param row 行数据 */ isExpanded(row: any): boolean { return this.expandedRows.has(row); } /** * 格式化单元格值 * @param column 列配置 * @param row 行数据 */ getCellValue(column: TableColumn, row: any): any { const value = row[column.field]; // 使用列格式化函数(如果有) if (column.format) { return column.format(value); } // 根据列类型进行格式化 switch (column.type) { case 'date’: return value ? new Date(value).toLocaleDateString() : ”; case 'boolean’: return value ? '是’ : '否’; default: return value; } } /** * 获取当前分页信息文本 */ get paginationInfo(): string { const start = this.currentPageIndex * this.pageSize + 1; const end = Math.min(start + this.pageSize – 1, this.totalRecords); return `${start}-${end} / ${this.totalRecords}`; } /** * 获取列对齐样式类 * @param column 列配置 */ getColumnAlignClass(column: TableColumn): string { return column.align ? `text-${column.align}` : ”; } /** * 获取当前页数据 */ get pageData(): any[] { if (!this.enablePagination) return this.data; const start = this.currentPageIndex * this.pageSize; return this.data.slice(start, start + this.pageSize); } /** * 重置所有筛选和分页状态 */ reset() { this.searchText = ”; this.currentPageIndex = 0; this.sortField = null; this.sortDirection = 'asc’; this.selectedRows.clear(); this.expandedRows.clear(); this.searchChange.emit(”); this.emitPageEvent(); } } End File# serbogdanov/project /** * 科幻风格HUD (Heads-Up Display)渲染器 * 负责绘制各种科幻元素和动画效果 */ export class SciFiHUDRenderer { private context: CanvasRenderingContext2D; private width: number; private height: number; private startTime: number; private data: any = {}; private animationValues: any = { scale: 0, opacity: 0, progress: 0, rotation: 0 }; // 颜色方案 private colorSchemes = { normal: { primary: 'rgba(0, 149, 255, 0.8)’, secondary: 'rgba(0, 149, 255, 0.4)’, background: 'rgba(10, 20, 40, 0.3)’, text: 'rgba(150, 210, 255, 0.9)’, highlight: 'rgba(0, 200, 255, 1)’ }, alert: { primary: 'rgba(255, 60, 50, 0.8)’, secondary: 'rgba(255, 60, 50, 0.4)’, background: 'rgba(40, 15, 15, 0.3)’, text: 'rgba(255, 180, 170, 0.9)’, highlight: 'rgba(255, 100, 70, 1)’ }, success: { primary: 'rgba(50, 200, 100, 0.8)’, secondary: 'rgba(50, 200, 100, 0.4)’, background: 'rgba(15, 30, 20, 0.3)’, text: 'rgba(170, 255, 200, 0.9)’, highlight: 'rgba(80, 240, 120, 1)’ }, inactive: { primary: 'rgba(150, 150, 150, 0.5)’, secondary: 'rgba(100, 100, 100, 0.3)’, background: 'rgba(20, 20, 20, 0.3)’, text: 'rgba(200, 200, 200, 0.7)’, highlight: 'rgba(180, 180, 180, 0.8)’ } }; // 当前颜色方案 private currentColorScheme: any; constructor(context: CanvasRenderingContext2D, width: number, height: number) { this.context = context; this.width = width; this.height = height; this.startTime = Date.now(); this.currentColorScheme = this.colorSchemes.normal; } /** * 设置HUD数据 * @param data HUD数据对象 */ setData(data: any): void { this.data = data; // 根据模式更新颜色方案 if (data.mode) { this.currentColorScheme = this.colorSchemes[data.mode] || this.colorSchemes.normal; } } /** * 渲染HUD帧 */ render(): void { const ctx = this.context; const currentTime = Date.now(); const elapsed = currentTime – this.startTime; // 清除画布 ctx.clearRect(0, 0, this.width, this.height); // 更新动画值 this.updateAnimationValues(elapsed); // 绘制背景 this.drawBackground(); // 绘制边框和装饰元素 this.drawBorder(); this.drawDecorativeElements(elapsed); // 绘制内容 this.drawContent(elapsed); } /** * 更新动画值 * @param elapsed 经过的时间(毫秒) */ private updateAnimationValues(elapsed: number): void { // 平滑的进入动画 this.animationValues.scale = Math.min(1, elapsed / 500); this.animationValues.opacity = Math.min(1, elapsed / 700); // 进度动画(如果有目标值和当前值) if (this.data.percentage !== undefined) { const targetProgress = this.data.percentage / 100; this.animationValues.progress += (targetProgress – this.animationValues.progress) * 0.1; } // 旋转动画 this.animationValues.rotation = (elapsed / 3000) % (Math.PI * 2); } /** * 绘制HUD背景 */ private drawBackground(): void { const ctx = this.context; const { width, height } = this; // 半透明背景 ctx.fillStyle = this.currentColorScheme.background; ctx.fillRect(0, 0, width, height); // 网格效果 ctx.strokeStyle = this.currentColorScheme.secondary; ctx.lineWidth = 0.5; ctx.globalAlpha = 0.3; const gridSize = 20; for (let x = 0; x <= width; x += gridSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); } for (let y = 0; y 0) { ctx.save(); ctx.globalAlpha = 0.4; ctx.fillStyle = this.currentColorScheme.highlight; const stripeWidth = 10; const stripeAngle = Math.PI / 4; const stripeSpacing = 15; ctx.beginPath(); for (let i = -width; i { const prettierRC = JSON.parse(fs.readFileSync(’.prettierrc’, 'utf8′)); const eslint = new ESLint({ fix: true, useEslintrc: true, }); // Format with Prettier const formattedJS = prettier.format(jsStr, { …prettierRC, parser: 'babel’, }); // Apply ESLint fixes const [result] = await eslint.lintText(formattedJS); return result.output || formattedJS; }; // Utility to format SCSS code const formatSCSS = (scssStr) => { const prettierRC = JSON.parse(fs.readFileSync(’.prettierrc’, 'utf8′)); return prettier.format(scssStr, { …prettierRC, parser: 'scss’, }); }; // Format number with 0 decimal places. const formatNumber = (num) => parseFloat(num.toFixed(0)); // Create and detect dark/light colors const generateColorInfo = (color) => { const chromaColor = chroma(color); const luminance = chromaColor.luminance(); const isDark = luminance contrast.black ? '#FFFFFF’ : '#1A1A1A’; return { hex: color.toUpperCase(), rgb: chromaColor.rgb().map(formatNumber), isDark, textColor, }; }; // Generate JS output const generateJS = async () => { // Generate JavaScript version of the color swatch let jsOutput = `/** * Auto-generated color swatch. * DO NOT EDIT DIRECTLY. */ const swatch = ${JSON.stringify(swatch, null, 2)};n export default swatch;`; // Format and write the JS file const formattedJS = await formatJS(jsOutput); fs.writeFileSync(JS_PATH, formattedJS); console.log(`✅ Generated JavaScript color swatch at ${JS_PATH}`); }; // Generate SCSS output const generateSCSS = () => { let scssOutput = '// Auto-generated color swatch variables.n// DO NOT EDIT DIRECTLY.nn’; // Process each palette Object.entries(swatch).forEach(([paletteName, palette]) => { scssOutput += `// ${paletteName} paletten`; // Process each color in the palette Object.entries(palette).forEach(([colorName, color]) => { if (typeof color === 'string’) { // For base colors (strings) const varName = `$color-${paletteName}-${colorName}`; scssOutput += `${varName}: ${color.toUpperCase()};n`; } else { // For color objects with shades Object.entries(color).forEach(([shadeName, shadeValue]) => { const varName = `$color-${paletteName}-${colorName}-${shadeName}`; scssOutput += `${varName}: ${shadeValue.toUpperCase()};n`; }); } }); scssOutput += 'n’; }); // Add CSS custom properties (variables) scssOutput += '// CSS custom propertiesn’; scssOutput += ’:root {n’; // Process each palette for CSS variables Object.entries(swatch).forEach(([paletteName, palette]) => { scssOutput += ` // ${paletteName} paletten`; // Process each color in the palette Object.entries(palette).forEach(([colorName, color]) => { if (typeof color === 'string’) { // For base colors (strings) const varName = `–color-${paletteName}-${colorName}`; scssOutput += ` ${varName}: ${color.toUpperCase()};n`; } else { // For color objects with shades Object.entries(color).forEach(([shadeName, shadeValue]) => { const varName = `–color-${paletteName}-${colorName}-${shadeName}`; scssOutput += ` ${varName}: ${shadeValue.toUpperCase()};n`; }); } }); }); scssOutput += ’}n’; // Format and write the SCSS file const formattedSCSS = formatSCSS(scssOutput); fs.writeFileSync(SCSS_PATH, formattedSCSS); console.log(`✅ Generated SCSS color variables at ${SCSS_PATH}`); }; // Generate documentation const generateDocs = () => { let mdOutput = '# Color SwatchnnAuto-generated color documentation.nn’; // Process each palette Object.entries(swatch).forEach(([paletteName, palette]) => { mdOutput += `## ${paletteName} Palettenn`; // Process each color in the palette Object.entries(palette).forEach(([colorName, color]) => { if (typeof color === 'string’) { // For base colors (strings) const colorInfo = generateColorInfo(color); mdOutput += `### ${colorName}nn`; mdOutput += ` n`; mdOutput += ` ${colorName}n`; mdOutput += ` HEX: ${colorInfo.hex}n`; mdOutput += ` RGB: ${colorInfo.rgb.join(’, ’)}n`; mdOutput += ` nn`; mdOutput += `CSS Variable: `–color-${paletteName}-${colorName}`nn`; mdOutput += `SCSS Variable: `$color-${paletteName}-${colorName}`nn`; } else { // For color objects with shades mdOutput += `### ${colorName}nn`; mdOutput += ’ n’; Object.entries(color).forEach(([shadeName, shadeValue]) => { const colorInfo = generateColorInfo(shadeValue); mdOutput += ` n`; mdOutput += ` ${colorName}-${shadeName}n`; mdOutput += ` HEX: ${colorInfo.hex}n`; mdOutput += ` RGB: ${colorInfo.rgb.join(’, ’)}n`; mdOutput += ` n`; }); mdOutput += ’ nn’; // Add variables reference mdOutput += 'CSS Variables:nn’; Object.keys(color).forEach((shadeName) => { mdOutput += `- `–color-${paletteName}-${colorName}-${shadeName}`n`; }); mdOutput += 'n’; mdOutput += 'SCSS Variables:nn’; Object.keys(color).forEach((shadeName) => { mdOutput += `- `$color-${paletteName}-${colorName}-${shadeName}`n`; }); mdOutput += 'n’; } }); }); fs.writeFileSync(DOCS_PATH, mdOutput); console.log(`✅ Generated color documentation at ${DOCS_PATH}`); }; // Main function to run all generators const main = async () => { try { // Make sure the directories exist if (!fs.existsSync(path.dirname(JS_PATH))) { fs.mkdirSync(path.dirname(JS_PATH), { recursive: true }); } if (!fs.existsSync(path.dirname(SCSS_PATH))) { fs.mkdirSync(path.dirname(SCSS_PATH), { recursive: true }); } if (!fs.existsSync(path.dirname(DOCS_PATH))) { fs.mkdirSync(path.dirname(DOCS_PATH), { recursive: true }); } // Generate files await generateJS(); generateSCSS(); generateDocs(); console.log(’✨ All color files generated successfully!’); } catch (error) { console.error(’Error generating color files:’, error); process.exit(1); } }; main(); End File# packages/react/src/hooks/useMediaQuery.ts import { useEffect, useState } from 'react’; /** * Media query breakpoints */ export const breakpoints = { xs: '(max-width: 575px)’, sm: '(min-width: 576px)’, md: '(min-width: 768px)’, lg: '(min-width: 992px)’, xl: '(min-width: 1200px)’, xxl: '(min-width: 1400px)’, }; export type Breakpoint = keyof typeof breakpoints; interface UseMediaQueryOptions { /* If true, will return false on the server regardless of actual matches */ noSSR?: boolean; /* Default value to return before the media query is evaluated */ defaultValue?: boolean; } /** * React hook for using media queries in components * * @param query The media query string or a breakpoint key * @param options Configuration options * @returns Boolean indicating if the media query matches * * @example * // Basic usage with breakpoint * const isMobile = useMediaQuery(’sm’); * * // Custom media query * const isPrint = useMediaQuery(’print’); * const isLandscape = useMediaQuery('(orientation: landscape)’); * * // With options * const isDesktop = useMediaQuery(’lg’, { noSSR: true, defaultValue: false }); */ export function useMediaQuery( query: string | Breakpoint, options: UseMediaQueryOptions = {}, ): boolean { const { noSSR = false, defaultValue = false } = options; const [matches, setMatches] = useState(() => { // Handle SSR case if (typeof window === 'undefined’) { return noSSR ? false : defaultValue; } // Resolve the query string const mediaQuery = breakpoints[query as Breakpoint] || query; // Initial match return window.matchMedia(mediaQuery).matches; }); useEffect(() => { // Resolve the query string const mediaQuery = breakpoints[query as Breakpoint] || query; // Get the media query list const mediaQueryList = window.matchMedia(mediaQuery); // Set initial value setMatches(mediaQueryList.matches); // Define handler for media query changes const handler = (event: MediaQueryListEvent) => setMatches(event.matches); // Add event listener mediaQueryList.addEventListener(’change’, handler); // Cleanup return () => { mediaQueryList.removeEventListener(’change’, handler); }; }, [query]); return matches; } End File# myrta-ds/myrta import { useState, useEffect, RefObject } from 'react’; /** * Options for the click outside hook */ interface UseClickOutsideOptions { /** * Whether the hook is active * @default true */ active?: boolean; /** * Callback to execute when click outside occurs */ onClickOutside: () => void; } /** * React hook to detect clicks outside of the specified element * * @param ref Reference to the element to detect clicks outside of * @param options Configuration options * * @example * „`jsx * const MyComponent = () => { * const ref = useRef(null); * const [isOpen, setIsOpen] = useState(false); * * useClickOutside(ref, { * active: isOpen, * onClickOutside: () => setIsOpen(false), * }); * * return ( * * setIsOpen(true)}>Open * {isOpen && ( * * This will close when clicking outside * * )} * * ); * }; * „` */ export function useClickOutside( ref: RefObject, options: UseClickOutsideOptions, ): void { const { active = true, onClickOutside } = options; const [initialized, setInitialized] = useState(false); useEffect(() => { setInitialized(true); }, []); useEffect(() => { // Skip if not active or not initialized (for SSR compatibility) if (!active || !initialized) { return undefined; } const handleClickOutside = (event: MouseEvent) => { if (ref.current && !ref.current.contains(event.target as Node)) { onClickOutside(); } }; // Add event listener with capture to ensure it runs before other click handlers document.addEventListener(’mousedown’, handleClickOutside, true); // Cleanup function return () => { document.removeEventListener(’mousedown’, handleClickOutside, true); }; }, [ref, active, initialized, onClickOutside]); } End Fileimport { useRef, useCallback, Dispatch, SetStateAction, useState } from 'react’; /** * Options for useDisclosure hook */ interface UseDisclosureOptions { /** * The default state * @default false */ defaultIsOpen?: boolean; /** * Function called when state changes */ onStateChange?: (isOpen: boolean) => void; } /** * The return value of useDisclosure */ interface UseDisclosureReturn { /** Current state */ isOpen: boolean; /** Function to open */ open: () => void; /** Function to close */ close: () => void; /** Function to toggle */ toggle: () => void; /** Function to set state directly */ setOpen: Dispatch>; } /** * Hook for managing disclosure state (open/close) * Useful for modals, dropdowns, etc. * * @param options Configuration options * @returns Object with state and handlers * * @example * „`jsx * function Modal() { * const { isOpen, open, close } = useDisclosure(); * * return ( * * Open Modal * {isOpen && ( * * Modal content * Close * * )} * * ); * } * „` */ export function useDisclosure( options: UseDisclosureOptions = {} ): UseDisclosureReturn { const { defaultIsOpen = false, onStateChange } = options; const [isOpen, setIsOpen] = useState(defaultIsOpen); const onStateChangeRef = useRef(onStateChange); // Keep the onStateChange ref up to date onStateChangeRef.current = onStateChange; const setOpen = useCallback((value: boolean | ((prevState: boolean) => boolean)) => { setIsOpen((prev) => { const next = typeof value === 'function’ ? value(prev) : value; if (prev !== next) { onStateChangeRef.current?.(next); } return next; }); }, []); const open = useCallback(() => setOpen(true), [setOpen]); const close = useCallback(() => setOpen(false), [setOpen]); const toggle = useCallback(() => setOpen((prev) => !prev), [setOpen]); return { isOpen, open, close, toggle, setOpen, }; } End Fileimport { useEffect, useRef, useState } from 'react’; /** * Options for useCopyToClipboard hook */ interface UseCopyToClipboardOptions { /** * Reset the copied state after this many milliseconds * @default null (no reset) */ resetAfter?: number | null; /** * Callback called after copy success */ onSuccess?: (text: string) => void; /** * Callback called after copy failure */ onError?: (error: Error) => void; } /** * The return value of useCopyToClipboard */ interface UseCopyToClipboardReturn { /** The currently copied text or null if nothing copied */ copied: string | null; /** Is the copy operation in progress */ copying: boolean; /** Whether the last copy operation succeeded */ success: boolean | null; /** Function to copy text to clipboard */ copy: (text: string) => Promise; /** Function to clear the copied state */ reset: () => void; } /** * Hook to copy text to clipboard * * @param options Configuration options * @returns Object with copied state and copy function * * @example * „`jsx * function CopyButton() { * const { copied, copying, success, copy } = useCopyToClipboard({ * resetAfter: 2000, * onSuccess: () => toast.success(’Copied to clipboard!’), * }); * * return ( * copy(’Text to copy’)} * disabled={copying} * > * {success ? 'Copied!’ : 'Copy’} * * ); * } * „` */ export function useCopyToClipboard( options: UseCopyToClipboardOptions = {} ): UseCopyToClipboardReturn { const { resetAfter = null, onSuccess, onError } = options; const [copied, setCopied] = useState(null); const [copying, setCopying] = useState(false); const [success, setSuccess] = useState(null); const resetTimeoutRef = useRef(null); // Clear timeout on unmount useEffect(() => { return () => { if (resetTimeoutRef.current !== null) { window.clearTimeout(resetTimeoutRef.current); } }; }, []); const reset = () => { setCopied(null); setSuccess(null); if (resetTimeoutRef.current !== null) { window.clearTimeout(resetTimeoutRef.current); resetTimeoutRef.current = null; } }; const copy = async (text: string): Promise => { if (!navigator?.clipboard) { const error = new Error(’Clipboard API not supported’); onError?.(error); return false; } // Clear any existing reset timeout if (resetTimeoutRef.current !== null) { window.clearTimeout(resetTimeoutRef.current); resetTimeoutRef.current = null; } setCopying(true); setSuccess(null); try { await navigator.clipboard.writeText(text); setCopied(text); setSuccess(true); onSuccess?.(text); // Set up reset timeout if specified if (resetAfter !== null) { resetTimeoutRef.current = window.setTimeout(reset, resetAfter); } return true; } catch (error) { setCopied(null); setSuccess(false); onError?.(error as Error); return false; } finally { setCopying(false); } }; return { copied, copying, success, copy, reset, }; } End File# packages/react/src/hooks/useCountdown.ts import { useState, useEffect, useRef, useCallback } from 'react’; /** * Options for useCountdown hook */ interface UseCountdownOptions { /** * Time in milliseconds */ milliseconds: number; /** * Interval for countdown updates in milliseconds * @default 1000 */ interval?: number; /** * Whether to start the countdown automatically * @default true */ autoStart?: boolean; /** * Callback when countdown reaches zero */ onComplete?: () => void; /** * Callback on each countdown tick */ onTick?: (millisecondsLeft: number) => void; } /** * The return value of useCountdown */ interface UseCountdownReturn { /** Whether the countdown is currently running */ isRunning: boolean; /** Whether the countdown is complete */ isComplete: boolean; /** Time left in milliseconds */ millisecondsLeft: number; /** Function to start/resume the countdown */ start: () => void; /** Function to pause the countdown */ pause: () => void; /** Function to stop and reset the countdown */ reset: () => void; /** Percentage of time elapsed (0-100) */ progress: number; /** Formatted time left as mm:ss */ formatted: string; } /** * Hook to create a countdown timer * * @param options Configuration options * @returns Object with countdown state and controls * * @example * „`jsx * function Timer() { * const { * isRunning, * formatted, * progress, * start, * pause, * reset * } = useCountdown({ * milliseconds: 60000, // 1 minute * onComplete: () => console.log(’Countdown finished!’), * }); * * return ( * * Time left: {formatted} * * * {isRunning ? 'Pause’ : 'Start’} * * Reset * * ); * } * „` */ export function useCountdown( options: UseCountdownOptions ): UseCountdownReturn { const { milliseconds, interval = 1000, autoStart = true, onComplete, onTick, } = options; const [millisecondsLeft, setMillisecondsLeft] = useState(milliseconds); const [isRunning, setIsRunning] = useState(autoStart); const [isComplete, setIsComplete] = useState(false); const intervalRef = useRef(null); const startTimeRef = useRef(0); const timeRemainingRef = useRef(milliseconds); // Format time as mm:ss const formatTime = (ms: number): string => { const totalSeconds = Math.ceil(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes.toString().padStart(2, '0′)}:${seconds.toString().padStart(2, '0′)}`; }; // Calculate progress percentage const calculateProgress = (ms: number): number => { return ((milliseconds – ms) / milliseconds) * 100; }; const stop = useCallback(() => { if (intervalRef.current !== null) { window.clearInterval(intervalRef.current); intervalRef.current = null; } setIsRunning(false); }, []); const reset = useCallback(() => { stop(); setMillisecondsLeft(milliseconds); timeRemainingRef.current = milliseconds; setIsComplete(false); }, [milliseconds, stop]); const tick = useCallback(() => { const currentTime = Date.now(); const elapsedTime = currentTime – startTimeRef.current; startTimeRef.current = currentTime; const newTimeRemaining = Math.max(0, timeRemainingRef.current – elapsedTime); timeRemainingRef.current = newTimeRemaining; setMillisecondsLeft(newTimeRemaining); onTick?.(newTimeRemaining); if (newTimeRemaining { if (isRunning || isComplete) return; setIsRunning(true); startTimeRef.current = Date.now(); // Clear any existing interval to be safe if (intervalRef.current !== null) { window.clearInterval(intervalRef.current); } // Start the interval intervalRef.current = window.setInterval(tick, interval); }, [interval, isComplete, isRunning, tick]); const pause = useCallback(() => { stop(); }, [stop]); // Set up interval when running useEffect(() => { if (isRunning) { startTimeRef.current = Date.now(); intervalRef.current = window.setInterval(tick, interval); } return () => { if (intervalRef.current !== null) { window.clearInterval(intervalRef.current); } }; }, [interval, isRunning, tick]); return { isRunning, isComplete, millisecondsLeft, start, pause, reset, progress: calculateProgress(millisecondsLeft), formatted: formatTime(millisecondsLeft), }; } End Fileimport React, { useState, useRef, useEffect } from 'react’; import clsx from 'clsx’; import { useId } from ’../../hooks/useId’; import { Box, BoxProps } from ’../Box/Box’; import ’./Tooltip.scss’; export interface TooltipProps extends BoxProps { /** * Content to display inside the tooltip */ content: React.ReactNode; /** * Tooltip position relative to the trigger * @default 'top’ */ position?: 'top’ | 'right’ | 'bottom’ | 'left’; /** * Trigger element to hook the tooltip to */ children: React.ReactElement; /** * Tooltip width in pixels * @default 200 */ width?: number; /** * Delay before showing the tooltip in milliseconds * @default 200 */ showDelay?: number; /** * Delay before hiding the tooltip in milliseconds * @default 100 */ hideDelay?: number; /** * Whether the tooltip should show on click instead of hover * @default false */ clickTrigger?: boolean; /** * Whether the tooltip should be visible initially * @default false */ defaultVisible?: boolean; /** * Whether the tooltip is disabled * @default false */ disabled?: boolean; } export const Tooltip: React.FC = ({ content, position = 'top’, children, width = 200, showDelay = 200, hideDelay = 100, clickTrigger = false, defaultVisible = false, disabled = false, className, …props }) => { const [visible, setVisible] = useState(defaultVisible); const [tooltipStyle, setTooltipStyle] = useState({}); const triggerRef = useRef(null); const tooltipRef = useRef(null); const timeoutRef = useRef(null); const tooltipId = useId(); const calculatePosition = () => { if (!triggerRef.current || !tooltipRef.current) return; const triggerRect = triggerRef.current.getBoundingClientRect(); const tooltipRect = tooltipRef.current.getBoundingClientRect(); const scrollTop = window.scrollY || document.documentElement.scrollTop; const scrollLeft = window.scrollX || document.documentElement.scrollLeft; let top, left; switch (position) { case 'top’: top = triggerRect.top + scrollTop – tooltipRect.height – 8; left = triggerRect.left + scrollLeft + (triggerRect.width / 2) – (tooltipRect.width / 2); break; case 'right’: top = triggerRect.top + scrollTop + (triggerRect.height / 2) – (tooltipRect.height / 2); left = triggerRect.right + scrollLeft + 8; break; case 'bottom’: top = triggerRect.bottom + scrollTop + 8; left = triggerRect.left + scrollLeft + (triggerRect.width / 2) – (tooltipRect.width / 2); break; case 'left’: top = triggerRect.top + scrollTop + (triggerRect.height / 2) – (tooltipRect.height / 2); left = triggerRect.left + scrollLeft – tooltipRect.width – 8; break; } // Ensure tooltip stays within viewport const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; if (left viewportWidth) { left = viewportWidth – tooltipRect.width; } if (top viewportHeight + scrollTop) { top = viewportHeight + scrollTop – tooltipRect.height; } setTooltipStyle({ top: `${top}px`, left: `${left}px`, width: `${width}px`, opacity: 1, visibility: 'visible’ }); }; const handleShowTooltip = () => { if (disabled) return; if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = window.setTimeout(() => { setVisible(true); }, showDelay); }; const handleHideTooltip = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = window.setTimeout(() => { setVisible(false); }, hideDelay); }; const handleToggleTooltip = () => { if (disabled) return; setVisible(prevVisible => !prevVisible); }; useEffect(() => { if (visible) { calculatePosition(); // Recalculate on window resize window.addEventListener(’resize’, calculatePosition); // Recalculate on scroll window.addEventListener(’scroll’, calculatePosition); } return () => { window.removeEventListener(’resize’, calculatePosition); window.removeEventListener(’scroll’, calculatePosition); if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, [visible]); // Clone the trigger element to add event handlers const triggerElement = React.cloneElement(children, { ref: (node: HTMLElement | null) => { triggerRef.current = node; // Forward ref if the child has one const { ref } = children as any; if (typeof ref === 'function’) ref(node); else if (ref) ref.current = node; }, …(clickTrigger ? { onClick: (e: React.MouseEvent) => { handleToggleTooltip(); // Call the original onClick if it exists if (children.props.onClick) children.props.onClick(e); }, } : { onMouseEnter: (e: React.MouseEvent) => { handleShowTooltip(); // Call the original onMouseEnter if it exists if (children.props.onMouseEnter) children.props.onMouseEnter(e); }, onMouseLeave: (e: React.MouseEvent) => { handleHideTooltip(); // Call the original onMouseLeave if it exists if (children.props.onMouseLeave) children.props.onMouseLeave(e); }, onFocus: (e: React.FocusEvent) => { handleShowTooltip(); // Call the original onFocus if it exists if (children.props.onFocus) children.props.onFocus(e); }, onBlur: (e: React.FocusEvent) => { handleHideTooltip(); // Call the original onBlur if it exists if (children.props.onBlur) children.props.onBlur(e); }, }), 'aria-describedby’: visible ? tooltipId : undefined, }); return ( {triggerElement} {visible && ( {content} )} ); }; End Fileimport React, { useEffect, useRef, useState } from 'react’; import clsx from 'clsx’; import { Icon } from ’../Icon/Icon’; import { useId } from ’../../hooks/useId’; import ’./Accordion.scss’; export interface AccordionProps { /** * Unique identifier for the accordion */ id?: string; /** * Accordion item title */ title: React.ReactNode; /** * Whether the accordion is expanded by default * @default false */ defaultExpanded?: boolean; /** * Whether the accordion is expanded (controlled component) */ expanded?: boolean; /** * Function called when the expanded state changes */ onExpandedChange?: (expanded: boolean) => void; /** * Content of the accordion */ children: React.ReactNode; /** * Additional CSS class for the accordion */ className?: string; /** * Whether to show the divider between accordion items * @default true */ showDivider?: boolean; /** * Custom icon to be displayed instead of the default */ icon?: React.ReactNode; /** * Additional CSS class for the title button */ titleClassName?: string; /** * Additional CSS class for the content panel */ contentClassName?: string; /** * Callback when accordion is focused */ onFocus?: React.FocusEventHandler; /** * Callback when accordion loses focus */ onBlur?: React.FocusEventHandler; /** * Whether the accordion is disabled * @default false */ disabled?: boolean; } export const Accordion = React.forwardRef( ( { id, title, defaultExpanded = false, expanded: expandedProp, onExpandedChange, children, className, showDivider = true, icon, titleClassName, contentClassName, onFocus, onBlur, disabled = false, …rest }, ref, ) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); const generatedId = useId(); const accordionId = id || `accordion-${generatedId}`; const headingId = `${accordionId}-heading`; const contentId = `${accordionId}-content`; const contentRef = useRef(null); const [contentHeight, setContentHeight] = useState( defaultExpanded ? undefined : 0, ); // Handle controlled component const expanded = expandedProp !== undefined ? expandedProp : isExpanded; const handleToggle = () => { if (disabled) return; const newExpanded = !expanded; setIsExpanded(newExpanded); onExpandedChange?.(newExpanded); }; // Update height when expanded state changes useEffect(() => { if (!contentRef.current) return; if (expanded) { const height = contentRef.current.scrollHeight; setContentHeight(height); // Remove fixed height after transition to allow for content changes const timer = setTimeout(() => { setContentHeight(undefined); }, 300); // Should match transition duration return () => clearTimeout(timer); } else { // Set current height before collapsing for smooth animation setContentHeight(contentRef.current.scrollHeight); // Force a reflow void contentRef.current.offsetHeight; // Set to 0 for animation const timer = setTimeout(() => { setContentHeight(0); }, 10); return () => clearTimeout(timer); } }, [expanded]); return ( {title} {icon || } {children} ); }, ); Accordion.displayName = 'Accordion’; End File# packages/react/src/components/Button/Button.tsx import React from 'react’; import clsx from 'clsx’; import ’./Button.scss’; import { Spinner } from ’../Spinner/Spinner’; export type ButtonVariant = 'primary’ | 'secondary’ | 'tertiary’ | 'danger’; export type ButtonSize = 'small’ | 'medium’ | 'large’; export interface ButtonProps extends React.ButtonHTMLAttributes { /** * Button variant * @default 'primary’ */ variant?: ButtonVariant; /** * Button size * @default 'medium’ */ size?: ButtonSize; /** * Whether the button extends to fill its container * @default false */ fullWidth?: boolean; /** * Icon to display at the start of the button */ startIcon?: React.ReactNode; /** * Icon to display at the end of the button */ endIcon?: React.ReactNode; /** * Whether the button is in a loading state * @default false */ loading?: boolean; /** * Text to display when in loading state */ loadingText?: string; /** * CSS class for the loading spinner */ spinnerClassName?: string; /** * Whether the button should be rendered as a link */ href?: string; /** * Target attribute for link buttons */ target?: string; /** * Rel attribute for link buttons */ rel?: string; } const ButtonRoot = React.forwardRef( ( { as: Component, className, variant = 'primary’, size = 'medium’, disabled, fullWidth = false, startIcon, endIcon, loading = false, loadingText, spinnerClassName, children, …rest }, ref ) => { const isDisabled = disabled || loading; // Use proper DOM props based on the component type const domProps = Component === 'button’ ? { disabled: isDisabled } : { 'aria-disabled’: isDisabled }; return ( {loading && ( )} {startIcon && {startIcon}} {loading && loadingText ? loadingText : children} {endIcon && {endIcon}} ); } ); export const Button = React.forwardRef( (props, ref) => { if (props.href && !props.disabled) { return ; } return ; } ); Button.displayName = 'Button’; End File# myrta-ds/myrta # packages/react/src/components/Toggle/Toggle.tsx import React, { useState, useEffect } from 'react’; import clsx from 'clsx’; import ’./Toggle.scss’; import { useId } from ’../../hooks/useId’; export interface ToggleProps { /** * Whether the toggle is checked */ checked?: boolean; /** * Default checked state when uncontrolled * @default false */ defaultChecked?: boolean; /** * Whether the toggle is disabled * @default false */ disabled?: boolean; /** * Function called when the toggle state changes */ onChange?: (checked: boolean, event: React.ChangeEvent) => void; /** * Size of the toggle * @default 'medium’ */ size?: 'small’ | 'medium’ | 'large’; /** * An accessible label for the toggle */ label?: string; /** * ID for the toggle input */ id?: string; /** * Additional CSS class for the toggle */ className?: string; /** * Additional CSS class for the toggle track */ trackClassName?: string; /** * Additional CSS class for the toggle thumb */ thumbClassName?: string; /** * Name attribute for the hidden input */ name?: string; /** * ARIA label for the toggle */ 'aria-label’?: string; /** * ARIA labelledby for the toggle */ 'aria-labelledby’?: string; /** * Loading state * @default false */ loading?: boolean; /** * Required state * @default false */ required?: boolean; } export const Toggle = React.forwardRef( ( { checked, defaultChecked = false, disabled = false, onChange, size = 'medium’, label, id, className, trackClassName, thumbClassName, name, 'aria-label’: ariaLabel, 'aria-labelledby’: ariaLabelledBy, loading = false, required = false, …rest }, ref ) => { const [isChecked, setIsChecked] = useState(defaultChecked); const generatedId = useId(); const toggleId = id || `toggle-${generatedId}`; // Handle the controlled component case useEffect(() => { if (checked !== undefined) { setIsChecked(checked); } }, [checked]); const handleChange = (event: React.ChangeEvent) => { if (disabled || loading) return; const newChecked = event.target.checked; // Only update internal state for uncontrolled component if (checked === undefined) { setIsChecked(newChecked); } onChange?.(newChecked, event); }; return ( {loading && } {label && {label}} ); } ); Toggle.displayName = 'Toggle’; End Fileimport React, { useState, useRef, useEffect } from 'react’; import clsx from 'clsx’; import { useId } from ’../../hooks/useId’; import ’./Select.scss’; import { Icon } from ’../Icon/Icon’; export type SelectOption = { value: string; label: React.ReactNode; disabled?: boolean; }; export type SelectSize = 'small’ | 'medium’ | 'large’; export interface SelectProps extends Omit, 'size’> { /** * Options for the select dropdown */ options: SelectOption[]; /** * Label for the select field */ label?: string; /** * Helper text to be displayed below the select */ helperText?: string; /** * Error message to be displayed below the select */ error?: string; /** * Size of the select * @default 'medium’ */ size?: SelectSize; /** * Placeholder text displayed when no option is selected * @default 'Select an option’ */ placeholder?: string; /** * Called when the selected option changes */ onValueChange?: (value: string) => void; /** * Whether the select should take the full width of its container * @default false */ fullWidth?: boolean; /** * Additional CSS class for the select container */ className?: string; /** * Additional CSS class for the select element */ selectClassName?: string; /** * Additional CSS class for the label */ labelClassName?: string; /** * Whether the select is loading * @default false */ loading?: boolean; /** * Loading text to display when loading * @default 'Loading…’ */ loadingText?: string; /** * Required field indicator */ required?: boolean; } export const Select = React.forwardRef( ( { options, label, helperText, error, size = 'medium’, placeholder = 'Select an option’, value, defaultValue, onChange, onValueChange, fullWidth = false, className, selectClassName, labelClassName, disabled = false, loading = false, loadingText = 'Loading…’, required = false, id, name, …props }, ref ) => { const [selectedValue, setSelectedValue] = useState( value !== undefined ? String(value) : defaultValue !== undefined ? String(defaultValue) : undefined ); const selectRef = useRef(null); const generatedId = useId(); const selectId = id || `select-${generatedId}`; const helperTextId = `helper-text-${generatedId}`; const errorId = `error-${generatedId}`; // Update internal state when controlled value changes useEffect(() => { if (value !== undefined) { setSelectedValue(String(value)); } }, [value]); const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; // Only update internal state if it’s an uncontrolled component if (value === undefined) { setSelectedValue(newValue); } // Call the provided onChange handler if (onChange) { onChange(e); } // Call the custom onValueChange handler if (onValueChange) { onValueChange(newValue); } }; return ( {label && ( {label} {required && *} )} { // Handle both the ref prop and the ref object if (ref) { if (typeof ref === 'function’) { ref(node); } else { ref.current = node; } } selectRef.current = node; }} id={selectId} name={name} value={selectedValue} onChange={handleChange} disabled={disabled || loading} className={clsx(’mds-select__control’, selectClassName)} aria-invalid={Boolean(error)} aria-describedby={ error ? errorId : helperText ? helperTextId : undefined } required={required} {…props} > {placeholder && ( {loading ? loadingText : placeholder} )} {options.map((option) => ( {option.label} ))} {error ? ( {error} ) : helperText ? ( {helperText} ) : null} ); } ); Select.displayName = 'Select’; End File# myrta-ds/myrta # packages/react/src/components/Table/Table.tsx import React from 'react’; import clsx from 'clsx’; import ’./Table.scss’; // Table component export interface TableProps extends React.TableHTMLAttributes { /** * Whether the table has zebra-striped rows * @default false */ striped?: boolean; /** * Whether the table has borders * @default false */ bordered?: boolean; /** * Whether the table’s cells are compact * @default false */ compact?: boolean; /** * Whether the table has hover state on rows * @default false */ hoverable?: boolean; /** * Whether the table is in a loading state * @default false */ loading?: boolean; /** * Width of the table * @default '100%’ */ width?: string | number; /** * Custom CSS class for table container */ wrapperClassName?: string; } export const Table = React.forwardRef( ( { children, className, striped = false, bordered = false, compact = false, hoverable = false, loading = false, width = '100%’, wrapperClassName, …rest }, ref ) => ( {children} Przewiń tabelę w poziomie ←→ {loading && ( )} ) ); Table.displayName = 'Table’; // Table Head component export interface TableHeadProps extends React.HTMLAttributes { /** * Whether the header is sticky * @default false */ sticky?: boolean; } export const TableHead = React.forwardRef( ({ children, className, sticky = false, …rest }, ref) => ( {children} ) ); TableHead.displayName = 'TableHead’; // Table Body component export const TableBody = React.forwardRef(({ children, className, …rest }, ref) => ( {children} )); TableBody.displayName = 'TableBody’; // Table Row component export interface TableRowProps extends React.HTMLAttributes { /** * Whether the row is selected * @default false */ selected?: boolean; /** * Whether the row is clickable * @default false */ clickable?: boolean; /** * Whether the row is disabled * @default false */ disabled?: boolean; } export const TableRow = React.forwardRef( ({ children, className, selected = false, clickable = false, disabled = false, …rest }, ref) => ( {children} ) ); TableRow.displayName = 'TableRow’; // Table Cell component export interface TableCellProps extends React.TdHTMLAttributes { /** * Text alignment within the cell */ align?: 'left’ | 'center’ | 'right’; /** * Truncate text with ellipsis if it’s too long * @default false */ truncate?: boolean; /** * Maximum width of the cell */ maxWidth?: string | number; } export const TableCell = React.forwardRef( ({ children, className, align, truncate = false, maxWidth, …rest }, ref) => ( {children} ) ); TableCell.displayName = 'TableCell’; // Table Header Cell component export interface TableHeaderCellProps extends React.ThHTMLAttributes { /** * Text alignment within the header cell */ align?: 'left’ | 'center’ | 'right’; /** * Whether the column is sortable * @default false */ sortable?: boolean; /** * Current sort direction for this column */ sortDirection?: 'asc’ | 'desc’ | null; /** * Width of the column */ width?: string | number; } export const TableHeaderCell = React.forwardRef( ( { children, className, align, sortable = false, sortDirection, width, …rest }, ref ) => ( {children} {sortable && ( {sortDirection === 'asc’ ? '▲’ : sortDirection === 'desc’ ? '▼’ : '⇅’} )} ) ); TableHeaderCell.displayName = 'TableHeaderCell’; // Table Footer component export const TableFooter = React.forwardRef(({ children, className, …rest }, ref) => ( {children} )); TableFooter.displayName = 'TableFooter’; // Table Pagination component export interface TablePaginationProps extends React.HTMLAttributes { /** * Total number of items */ count: number; /** * Number of items per page * @default 10 */ rowsPerPage?: number; /** * Available options for rows per page * @default [5, 10, 25] */ rowsPerPageOptions?: number[]; /** * Current page (0-based) * @default 0 */ page: number; /** * Callback fired when the page changes */ onPageChange: (page: number) => void; /** * Callback fired when rows per page changes */ onRowsPerPageChange?: (rowsPerPage: number) => void; /** * Label for the rows per page selector * @default 'Rows per page:’ */ labelRowsPerPage?: string; } export const TablePagination: React.FC = ({ count, rowsPerPage = 10, rowsPerPageOptions = [5, 10, 25], page, onPageChange, onRowsPerPageChange, labelRowsPerPage = 'Rows per page:’, className, …rest }) => { const totalPages = Math.ceil(count / rowsPerPage); const from = page * rowsPerPage + 1; const to = Math.min(count, (page + 1) * rowsPerPage); const handlePrevPage = () => { if (page > 0) { onPageChange(page – 1); } }; const handleNextPage = () => { if (page { onRowsPerPageChange?.(Number(e.target.value)); }; return ( {labelRowsPerPage} {rowsPerPageOptions.map((option) => ( {option} ))} {count > 0 ? `${from}-${to} of ${count}` : '0-0 of 0′} ◀ = totalPages – 1} aria-label=”Next page” > ▶ ); }; TablePagination.displayName = 'TablePagination’; End File# packages/react/src/components/Dialog/Dialog.tsx import React, { useEffect, useRef } from 'react’; import { createPortal } from 'react-dom’; import clsx from 'clsx’; import { useId } from ’../../hooks/useId’; import ’./Dialog.scss’; export interface DialogProps { /** * Whether the dialog is open */ open: boolean; /** * Title of the dialog */ title?: React.ReactNode; /** * Content of the dialog */ children: React.ReactNode; /** * Function called when the dialog should close */ onClose: () => void; /** * Additional content for the dialog’s footer */ footer?: React.ReactNode; /** * CSS class for the dialog container */ className?: string; /** * CSS class for the dialog content */ contentClassName?: string; /** * CSS class for the dialog backdrop */ backdropClassName?: string; /** * Whether to show a close button in the top-right corner * @default true */ showCloseButton?: boolean; /** * The size of the dialog * @default 'medium’ */ size?: 'small’ | 'medium’ | 'large’ | 'fullscreen’; /** * Whether to close the dialog when the backdrop is clicked * @default true */ closeOnBackdropClick?: boolean; /** * Whether to close the dialog when Escape key is pressed * @default true */ closeOnEsc?: boolean; /** * The maximum height of the dialog content */ maxContentHeight?: string; /** * The ID of the dialog */ id?: string; /** * Whether the dialog is in a loading state * @default false */ loading?: boolean; /** * Whether to prevent scrolling of the body when the dialog is open * @default true */ preventBodyScroll?: boolean; } export const Dialog: React.FC = ({ open, title, children, onClose, footer, className, contentClassName, backdropClassName, showCloseButton = true, size = 'medium’, closeOnBackdropClick = true, closeOnEsc = true, maxContentHeight, id, loading = false, preventBodyScroll = true, }) => { const dialogRef = useRef(null); const generatedId = useId(); const dialogId = id || `dialog-${generatedId}`; const dialogTitleId = `${dialogId}-title`; useEffect(() => { // Focus the dialog when it opens if (open && dialogRef.current) { // Set focus after a small delay to ensure the dialog is fully rendered const timeoutId = setTimeout(() => { if (dialogRef.current) { dialogRef.current.focus(); } }, 50); return () => clearTimeout(timeoutId); } }, [open]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape’ && open && closeOnEsc) { onClose(); } }; document.addEventListener(’keydown’, handleKeyDown); return () => { document.removeEventListener(’keydown’, handleKeyDown); }; }, [open, closeOnEsc, onClose]); useEffect(() => { if (preventBodyScroll) { if (open) { // Store the original overflow style const originalOverflow = document.body.style.overflow; // Prevent scrolling on the body document.body.style.overflow = 'hidden’; return () => { // Restore original overflow style document.body.style.overflow = originalOverflow; }; } } }, [open, preventBodyScroll]); const handleBackdropClick = (event: React.MouseEvent) => { if ( closeOnBackdropClick && event.target === event.currentTarget ) { onClose(); } }; if (!open) return null; const dialogContent = ( {title && ( {title} {showCloseButton && ( × )} )} {children} {footer && {footer} } ); // Use createPortal to render the dialog at the end of the document body return createPortal(dialogContent, document.body); }; End File# myrta-ds/myrta import React, { useState, useRef, useEffect } from 'react’; import clsx from 'clsx’; import { useId } from ’../../hooks/useId’; import ’./TextField.scss’; export interface TextFieldProps extends Omit, 'size’> { /** * Label for the text field */ label?: string; /** * Helper text to be displayed below the text field */ helperText?: string; /** * Error message to be displayed below the text field */ error?: string; /** * Size of the text field * @default 'medium’ */ size?: 'small’ | 'medium’ | 'large’; /** * Whether the text field should take the full width of its container * @default false */ fullWidth?: boolean; /** * Icon to display at the start of the text field */ startIcon?: React.ReactNode; /** * Icon to display at the end of the text field */ endIcon?: React.ReactNode; /** * Additional CSS class for the text field container */ className?: string; /** * Additional CSS class for the input element */ inputClassName?: string; /** * Additional CSS class for the label */ labelClassName?: string; /** * Whether the text field is loading * @default false */ loading?: boolean; /** * Type of the input * @default 'text’ */ type?: string; /** * Maximum length of text input */ maxLength?: number; /** * Whether to show character count * @default false */ showCharacterCount?: boolean; } export const TextField = React.forwardRef( ( { label, helperText, error, size = 'medium’, fullWidth = false, startIcon, endIcon, className, inputClassName, labelClassName, disabled = false, loading = false, type = 'text’, maxLength, showCharacterCount = false, id, value, defaultValue, onChange, required, …props }, ref ) => { const [inputValue, setInputValue] = useState( value !== undefined ? String(value) : defaultValue !== undefined ? String(defaultValue) : ” ); const inputRef = useRef(null); const generatedId = useId(); const inputId = id || `text-field-${generatedId}`; const helperTextId = `helper-text-${generatedId}`; const errorId = `error-${generatedId}`; // Update internal state when controlled value changes useEffect(() => { if (value !== undefined) { setInputValue(String(value)); } }, [value]); const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; // Only update internal state if it’s an uncontrolled component if (value === undefined) { setInputValue(newValue); } if (onChange) { onChange(e); } }; return ( 0, }, className )} > {label && ( {label} {required && *} )} {startIcon && ( {startIcon} )} { // Handle both the ref prop and the ref object if (ref) { if (typeof ref === 'function’) { ref(node); } else { ref.current = node; } } inputRef.current = node; }} id={inputId} type={type} value={value !== undefined ? value : inputValue} onChange={handleChange} disabled={disabled || loading} className={clsx(’mds-text-field__input’, inputClassName)} aria-invalid={Boolean(error)} aria-describedby={ error ? errorId : helperText ? helperTextId : undefined } maxLength={maxLength} required={required} {…props} /> {endIcon && ( {endIcon} )} {loading && ( )} {error ? ( {error} ) : helperText ? ( {helperText} ) : null} {(maxLength && showCharacterCount) && ( {inputValue.length}/{maxLength} )} ); } ); TextField.displayName = 'TextField’; End File# myrta-ds/myrta import React, { useState, useRef, useEffect } from 'react’; import clsx from 'clsx’; import { useId } from ’../../hooks/useId’; import ’./Tabs.scss’; export interface TabProps extends React.ButtonHTMLAttributes { /** * Value of the tab, used for selection */ value: string; /** * Whether the tab is disabled * @default false */ disabled?: boolean; /** * Icon to display before the label */ icon?: React.ReactNode; /** * Badge or notification indicator */ badge?: React.ReactNode; } export const Tab = React.forwardRef( ( { children, className, value, disabled = false, icon, badge, …rest }, ref ) => { // This component is not rendered directly // It serves as a configuration component for TabsRoot return null; } ); Tab.displayName = 'Tab’; export interface TabPanelProps extends React.HTMLAttributes { /** * Value of the tab this panel is associated with */ value: string; /** * Additional CSS class */ className?: string; } export const TabPanel = React.forwardRef( ({ children, className, value, …rest }, ref) => { // This component is not rendered directly // It serves as a configuration component for TabsRoot return null; } ); TabPanel.displayName = 'TabPanel’; export interface TabsProps extends React.HTMLAttributes { /** * Current active tab value */ value: string; /** * Callback when tab changes */ onChange: (value: string) => void; /** * Orientation of the tabs * @default 'horizontal’ */ orientation?: 'horizontal’ | 'vertical’; /** * Variant of the tabs * @default 'default’ */ variant?: 'default’ | 'enclosed’ | 'pills’; /** * Size of the tabs * @default 'medium’ */ size?: 'small’ | 'medium’ | 'large’; /** * Whether tabs should fill the container width * @default false */ fullWidth?: boolean; /** * Whether to center the tabs * @default false */ centered?: boolean; /** * Custom CSS class for the tabs container */ className?: string; /** * Custom CSS class for the tab list */ tabListClassName?: string; /** * Custom CSS class for the tab panels container */ tabPanelsClassName?: string; /** * Children should be Tab and TabPanel components */ children: React.ReactNode; } /** * A component that displays a set of tabs */ export const Tabs = React.forwardRef( ( { children, value, onChange, orientation = 'horizontal’, variant = 'default’, size = 'medium’, fullWidth = false, centered = false, className, tabListClassName, tabPanelsClassName, …rest }, ref ) => { const [tabs, setPanels] = useState([]); const [panels, setTabs] = useState([]); const [indicatorStyle, setIndicatorStyle] = useState({}); const tabRefs = useRef>(new Map()); const tabListRef = useRef(null); const generatedId = useId(); // Separate Tab and TabPanel components useEffect(() => { const tabsArray: React.ReactElement[] = []; const panelsArray: React.ReactElement[] = []; React.Children.forEach(children, (child) => { if (!React.isValidElement(child)) return; if (child.type === Tab) { tabsArray.push(child); } else if (child.type === TabPanel) { panelsArray.push(child); } }); setPanels(tabsArray); setTabs(panelsArray); }, [children]); // Update indicator position when value changes useEffect(() => { const updateIndicator = () => { const activeTab = tabRefs.current.get(value); if (!activeTab || !tabListRef.current) return; const tabRect = activeTab.getBoundingClientRect(); const tabListRect = tabListRef.current.getBoundingClientRect(); if (orientation === 'horizontal’) { setIndicatorStyle({ left: `${activeTab.offsetLeft}px`, width: `${tabRect.width}px`, bottom: '0′, }); } else { setIndicatorStyle({ top: `${activeTab.offsetTop}px`, height: `${tabRect.height}px`, right: '0′, }); } }; updateIndicator(); window.addEventListener(’resize’, updateIndicator); return () => { window.removeEventListener(’resize’, updateIndicator); }; }, [value, orientation, tabs]); return ( {tabs.map((tab, index) => { const { value: tabValue, disabled, icon, badge, children: tabChildren, className: tabClassName, …tabProps } = tab.props; const isSelected = value === tabValue; const tabId = `tab-${generatedId}-${tabValue}`; const panelId = `panel-${generatedId}-${tabValue}`; return ( { if (node) { tabRefs.current.set(tabValue, node); } else { tabRefs.current.delete(tabValue); } }} role=”tab” id={tabId} aria-controls={panelId} aria-selected={isSelected} tabIndex={isSelected ? 0 : -1} className={clsx( 'mds-tabs__tab’, { 'mds-tabs__tab–selected’: isSelected, 'mds-tabs__tab–disabled’: disabled, 'mds-tabs__tab–with-icon’: Boolean(icon), }, tabClassName )} onClick={() => !disabled && onChange(tabValue)} disabled={disabled} {…tabProps} > {icon && {icon}} {tabChildren} {badge && {badge}} ); })} {panels.map((panel) => { const { value: panelValue, children: panelChildren, className: panelClassName, …panelProps } = panel.props; const isSelected = value === panelValue; const tabId = `tab-${generatedId}-${panelValue}`; const panelId = `panel-${generatedId}-${panelValue}`; if (!isSelected) return null; return ( {panelChildren} ); })} ); } ); Tabs.displayName = 'Tabs’; End File# myrta-ds/myrta # packages/react/src/components/Slider/Slider.tsx import React, { useRef, useState, useEffect } from 'react’; import clsx from 'clsx’; import { useId } from ’../../hooks/useId’; import ’./Slider.scss’; export interface SliderProps { /** * The current value of the slider */ value?: number; /** * Default value for uncontrolled component * @default 0 */ defaultValue?: number; /** * Minimum allowed value * @default 0 */ min?: number; /** * Maximum allowed value * @default 100 */ max?: number; /** * Step size * @default 1 */ step?: number; /** * Callback when value changes */ onChange?: (value: number) => void; /** * Whether the slider is disabled * @default false */ disabled?: boolean; /** * Size of the slider * @default 'medium’ */ size?: 'small’ | 'medium’ | 'large’; /** * Label for the slider */ label?: string; /** * Whether to show the current value * @default false */ showValue?: boolean; /** * Format function for the displayed value */ formatValue?: (value: number) => string; /** * Mark specific values on the track */ marks?: { value: number; label?: string }[]; /** * Custom CSS class */ className?: string; /** * Custom CSS class for the track */ trackClassName?: string; /** * Custom CSS class for the thumb */ thumbClassName?: string; /** * ID attribute for the slider */ id?: string; /** * Callback when dragging starts */ onDragStart?: () => void; /** * Callback when dragging ends */ onDragEnd?: () => void; } export const Slider = React.forwardRef( ( { value: valueProp, defaultValue = 0, min = 0, max = 100, step = 1, onChange, disabled = false, size = 'medium’, label, showValue = false, formatValue = (value) => value.toString(), marks, className, trackClassName, thumbClassName, id, onDragStart, onDragEnd, …props }, ref ) => { const [value, setValue] = useState( valueProp !== undefined ? valueProp : defaultValue ); const [isDragging, setIsDragging] = useState(false); const trackRef = useRef(null); const thumbRef = useRef(null); const generatedId = useId(); const sliderId = id || `slider-${generatedId}`; const labelId = `${sliderId}-label`; const valueId = `${sliderId}-value`; // Handle controlled component updates useEffect(() => { if (valueProp !== undefined) { setValue(valueProp); } }, [valueProp]); // Calculate percentage for positioning const percentage = ((value – min) / (max – min)) * 100; const handleTrackClick = (e: React.MouseEvent) => { if (disabled || !trackRef.current) return; const rect = trackRef.current.getBoundingClientRect(); const clickPosition = e.clientX – rect.left; const percentageClicked = (clickPosition / rect.width) * 100; const rawValue = min + (percentageClicked / 100) * (max – min); const snappedValue = Math.round(rawValue / step) * step; const clampedValue = Math.max(min, Math.min(max, snappedValue)); setValue(clampedValue); onChange?.(clampedValue); }; const startDragging = (e: React.MouseEvent | React.TouchEvent) => { if (disabled) return; setIsDragging(true); onDragStart?.(); // Capture initial position const clientX = 'touches’ in e ? e.touches[0].clientX : e.clientX; updateValueFromClientX(clientX); // Prevent text selection during drag document.body.style.userSelect = 'none’; }; const stopDragging = () => { if (isDragging) { setIsDragging(false); onDragEnd?.(); document.body.style.userSelect = ”; } }; const updateValueFromClientX = (clientX: number) => { if (!trackRef.current || !isDragging) return; const rect = trackRef.current.getBoundingClientRect(); const clickPosition = Math.max(0, Math.min(rect.width, clientX – rect.left)); const percentageClicked = (clickPosition / rect.width) * 100; const rawValue = min + (percentageClicked / 100) * (max – min); const snappedValue = Math.round(rawValue / step) * step; const clampedValue = Math.max(min, Math.min(max, snappedValue)); setValue(clampedValue); onChange?.(clampedValue); }; const handleMouseMove = (e: MouseEvent) => { if (isDragging) { updateValueFromClientX(e.clientX); } }; const handleTouchMove = (e: TouchEvent) => { if (isDragging && e.touches.length > 0) { updateValueFromClientX(e.touches[0].clientX); } }; // Set up event listeners for drag useEffect(() => { if (isDragging) { document.addEventListener(’mousemove’, handleMouseMove); document.addEventListener(’touchmove’, handleTouchMove); document.addEventListener(’mouseup’, stopDragging); document.addEventListener(’touchend’, stopDragging); } return () => { document.removeEventListener(’mousemove’, handleMouseMove); document.removeEventListener(’touchmove’, handleTouchMove); document.removeEventListener(’mouseup’, stopDragging); document.removeEventListener(’touchend’, stopDragging); }; }, [isDragging]); // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (disabled) return; let newValue = value; switch (e.key) { case 'ArrowRight’: case 'ArrowUp’: newValue = Math.min(max, value + step); break; case 'ArrowLeft’: case 'ArrowDown’: newValue = Math.max(min, value – step); break; case 'Home’: newValue = min; break; case 'End’: newValue = max; break; default: return; } if (newValue !== value) { e.preventDefault(); setValue(newValue); onChange?.(newValue); } }; return ( {(label || showValue) && ( {label && ( {label} )} {showValue && ( {formatValue(value)} )} )} {/* Render marks if provided */} {marks && marks.length > 0 && ( {marks.map((mark) => { const markPercentage = ((mark.value – min) / (max – min)) * 100; return ( = mark.value, })} style={{ left: `${markPercentage}%` }} > {mark.label && ( {mark.label} )} ); })} )} ); } ); Slider.displayName = 'Slider’; End Fileimport React, { useState, useRef, useEffect } from 'react’; import clsx from 'clsx’; import { useId } from ’../../hooks/useId’; import ’./Checkbox.scss’; export interface CheckboxProps extends Omit, 'size’> { /** * Label for the checkbox */ label?: React.ReactNode; /** * Additional helper text */ helperText?: React.ReactNode; /** * Error message */ error?: React.ReactNode; /** * Whether the checkbox is checked */ checked?: boolean; /** * Default checked state for uncontrolled component * @default false */ defaultChecked?: boolean; /** * Whether the checkbox is in an indeterminate state * @default false */ indeterminate?: boolean; /** * Whether the checkbox is disabled * @default false */ disabled?: boolean; /** * Callback when the checked state changes */ onChange?: (checked: boolean, event: React.ChangeEvent) => void; /** * Size of the checkbox * @default 'medium’ */ size?: 'small’ | 'medium’ | 'large’; /** * Additional CSS class for the checkbox container */ className?: string; /** * Additional CSS class for the checkbox input element */ inputClassName?: string; /** * Additional CSS class for the checkbox label */ labelClassName?: string; /** * Name attribute for the input element */ name?: string; /** * ID attribute for the input element */ id?: string; /** * Whether the checkbox is in a loading state * @default false */ loading?: boolean; } export const Checkbox = React.forwardRef( ( { label, helperText, error, checked, defaultChecked = false, indeterminate = false, disabled = false, onChange, size = 'medium’, className, inputClassName, labelClassName, name, id, loading = false, …props }, ref ) => { const [isChecked, setIsChecked] = useState( checked !== undefined ? checked : defaultChecked ); const inputRef = useRef(null); const generatedId = useId(); const checkboxId = id || `checkbox-${generatedId}`; const helperTextId = `helper-text-${generatedId}`; const errorId = `error-${generatedId}`; // Update internal state when controlled value changes useEffect(() => { if (checked !== undefined) { setIsChecked(checked); } }, [checked]); // Set the indeterminate prop useEffect(() => { if (inputRef.current) { inputRef.current.indeterminate = indeterminate; } }, [indeterminate]); const handleChange = (event: React.ChangeEvent) => { if (disabled || loading) return; const newChecked = event.target.checked; // Only update internal state for uncontrolled component if (checked === undefined) { setIsChecked(newChecked); } if (onChange) { onChange(newChecked, event); } }; return ( { // Handle both the ref prop and the ref object if (ref) { if (typeof ref === 'function’) { ref(node); } else { ref.current = node; } } inputRef.current = node; }} type=”checkbox” id={checkboxId} name={name} checked={isChecked} onChange={handleChange} disabled={disabled || loading} className={clsx(’mds-checkbox__input’, inputClassName)} aria-invalid={Boolean(error)} aria-describedby={ error ? errorId : helperText ? helperTextId : undefined } {…props} /> {loading && } {label && ( {label} )} {error ? ( {error} ) : helperText ? ( {helperText} ) : null} ); } ); Checkbox.displayName = 'Checkbox’; End File# myrta-ds/myrta # packages/react/src/components/Menu/Menu.tsx import React, { useState, useRef, useEffect } from 'react’; import { createPortal } from 'react-dom’; import clsx from 'clsx’; import { useId } from ’../../hooks/useId’; import { useClickOutside } from ’../../hooks/useClickOutside’; import ’./Menu.scss’; export interface MenuProps { /** * The trigger element */ trigger: React.ReactElement; /** * The content of the menu */ children: React.ReactNode; /** * Whether the menu is open */ open?: boolean; /** * Callback when the menu should open or close */ onOpenChange?: (open: boolean) => void; /** * Position of the menu relative to the trigger * @default 'bottom-start’ */ placement?: 'top’ | 'top-start’ | 'top-end’ | 'bottom’ | 'bottom-start’ | 'bottom-end’ | 'left’ | 'right’; /** * Custom CSS class for the menu */ className?: string; /** * Width of the menu */ width?: number | string; /** * Maximum height of the menu before scrolling */ maxHeight?: number | string; /** * Whether to close the menu when an item is clicked * @default true */ closeOnItemClick?: boolean; /** * CSS z-index for the menu * @default 1000 */ zIndex?: number; /** * Whether to match the width of the menu with the trigger * @default false */ matchWidth?: boolean; /** * Distance between the menu and the trigger * @default 4 */ offset?: number; } export interface MenuItemProps extends React.HTMLAttributes { /** * Whether the item is disabled * @default false */ disabled?: boolean; /** * Icon to display before item text */ icon?: React.ReactNode; /** * Custom CSS class for the menu item */ className?: string; } export const MenuItem: React.FC = ({ children, disabled = false, icon, className, onClick, …rest }) => { const menuContext = useMenuContext(); const handleClick = (e: React.MouseEvent) => { if (disabled) { e.preventDefault(); return; } // Call the original onClick handler onClick?.(e); // Close the menu if closeOnItemClick is true if (menuContext?.closeOnItemClick) { menuContext.onClose(); } }; return ( {icon && {icon}} {children} ); }; MenuItem.displayName = 'MenuItem’; export interface MenuDividerProps extends React.HTMLAttributes { /** * Custom CSS class for the divider */ className?: string; } export const MenuDivider: React.FC = ({ className, …rest }) => ( ); MenuDivider.displayName = 'MenuDivider’; export interface MenuGroupProps extends React.HTMLAttributes { /** * Group title */ title?: React.ReactNode; /** * Custom CSS class for the group */ className?: string; } export const MenuGroup: React.FC = ({ title, children, className, …rest }) => ( {title && {title} } {children} ); MenuGroup.displayName = 'MenuGroup’; // Create a context for the menu interface MenuContextValue { closeOnItemClick: boolean; onClose: () => void; } const MenuContext = React.createContext(null); const useMenuContext = () => React.useContext(MenuContext); export const Menu: React.FC = ({ trigger, children, open: openProp, onOpenChange, placement = 'bottom-start’, className, width, maxHeight, closeOnItemClick = true, zIndex = 1000, matchWidth = false, offset = 4, }) => { const [isOpen, setIsOpen] = useState(Boolean(openProp)); const [menuStyle, setMenuStyle] = useState({}); const triggerRef = useRef(null); const menuRef = useRef(null); const generatedId = useId(); const menuId = `menu-${generatedId}`; // Set up click outside handling useClickOutside(menuRef, { active: isOpen, onClickOutside: () => { setIsOpen(false); onOpenChange?.(false); }, }); // Handle controlled component useEffect(() => { if (openProp !== undefined) { setIsOpen(openProp); } }, [openProp]); // Calculate and set menu position const updateMenuPosition = () => { if (!triggerRef.current || !menuRef.current) return; const triggerRect = triggerRef.current.getBoundingClientRect(); const menuRect = menuRef.current.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let top, left; // Calculate position based on placement switch (placement) { case 'top’: top = triggerRect.top – menuRect.height – offset; left = triggerRect.left + triggerRect.width / 2 – menuRect.width / 2; break; case 'top-start’: top = triggerRect.top – menuRect.height – offset; left = triggerRect.left; break; case 'top-end’: top = triggerRect.top – menuRect.height – offset; left = triggerRect.right – menuRect.width; break; case 'bottom’: top = triggerRect.bottom + offset; left = triggerRect.left + triggerRect.width / 2 – menuRect.width / 2; break; case 'bottom-start’: top = triggerRect.bottom + offset; left = triggerRect.left; break; case 'bottom-end’: top = triggerRect.bottom + offset; left = triggerRect.right – menuRect.width; break; case 'left’: top = triggerRect.top + triggerRect.height / 2 – menuRect.height / 2; left = triggerRect.left – menuRect.width – offset; break; case 'right’: top = triggerRect.top + triggerRect.height / 2 – menuRect.height / 2; left = triggerRect.right + offset; break; default: top = triggerRect.bottom + offset; left = triggerRect.left; } // Adjust for viewport boundaries if (left viewportWidth) left = viewportWidth – menuRect.width; if (top viewportHeight) top = viewportHeight – menuRect.height; const style: React.CSSProperties = { position: 'fixed’, top: `${top}px`, left: `${left}px`, zIndex, }; if (width) { style.width = typeof width === 'number’ ? `${width}px` : width; } else if (matchWidth) { style.width = `${triggerRect.width}px`; } if (maxHeight) { style.maxHeight = typeof maxHeight === 'number’ ? `${maxHeight}px` : maxHeight; } setMenuStyle(style); }; // Update position when menu opens useEffect(() => { if (isOpen) { updateMenuPosition(); // Update position on resize/scroll window.addEventListener(’resize’, updateMenuPosition); window.addEventListener(’scroll’, updateMenuPosition); } return () => { window.removeEventListener(’resize’, updateMenuPosition); window.removeEventListener(’scroll’, updateMenuPosition); }; }, [isOpen]); // Toggle menu const toggleMenu = () => { const newState = !isOpen; setIsOpen(newState); onOpenChange?.(newState); }; // Close menu const closeMenu = () => { setIsOpen(false); onOpenChange?.(false); }; // Clone trigger with ref and event handlers const triggerElement = React.cloneElement(trigger, { ref: (node: HTMLElement | null) => { triggerRef.current = node; // Forward ref if the child has one const { ref } = trigger as any; if (typeof ref === 'function’) ref(node); else if (ref) ref.current = node; }, onClick: (e: React.MouseEvent) => { toggleMenu(); // Call the original onClick if it exists if (trigger.props.onClick) trigger.props.onClick(e); }, 'aria-haspopup’: 'menu’, 'aria-expanded’: isOpen, 'aria-controls’: isOpen ? menuId : undefined, }); return ( {triggerElement} {isOpen && createPortal( {children} , document.body )} ); }; Menu.displayName = 'Menu’; End File# packages/react/src/components/Avatar/Avatar.tsx import React, { useState } from 'react’; import clsx from 'clsx’; import ’./Avatar.scss’; // Status types for avatar type AvatarStatus = 'online’ | 'offline’ | 'away’ | 'busy’ | 'invisible’; // Size variants for avatar type AvatarSize = 'xs’ | 'small’ | 'medium’ | 'large’ | 'xl’; // Shape variants for avatar type AvatarShape = 'circle’ | 'square’ | ’rounded’; export interface AvatarProps extends React.HTMLAttributes { /** * Image source for the avatar */ src?: string; /** * Alt text for the avatar image */ alt?: string; /** * Text to display when no image is available */ name?: string; /** * Size of the avatar * @default 'medium’ */ size?: AvatarSize; /** * Shape of the avatar * @default 'circle’ */ shape?: AvatarShape; /** * Status indicator for the avatar */ status?: AvatarStatus; /** * Whether to show a border around the avatar * @default false */ bordered?: boolean; /** * Background color for the avatar when displaying initials */ backgroundColor?: string; /** * Text color for the avatar when displaying initials */ textColor?: string; /** * Custom CSS class */ className?: string; /** * Optional icon to display instead of initials when no image is available */ icon?: React.ReactNode; /** * Callback when avatar fails to load */ onError?: (e: React.SyntheticEvent) => void; /** * Callback when avatar is clicked */ onClick?: (e: React.MouseEvent) => void; } export const Avatar = React.forwardRef( ( { src, alt, name, size = 'medium’, shape = 'circle’, status, bordered = false, backgroundColor, textColor, className, icon, onError, onClick, style, …rest }, ref ) => { const [imgError, setImgError] = useState(false); // Generate initials from name const getInitials = () => { if (!name) return ”; return name .split(’ ’) .map(n => n[0]) .join(”) .toUpperCase() .substring(0, 2); }; // Handle image load error const handleError = (e: React.SyntheticEvent) => { setImgError(true); onError?.(e); }; // Combine styles const avatarStyle = { …style, …(backgroundColor && !src && { backgroundColor }), …(textColor && !src && { color: textColor }), }; // Determine if we should render the image const shouldRenderImage = src && !imgError; return ( {shouldRenderImage ? ( ) : icon ? ( {icon} ) : ( {getInitials()} )} {status && ( )} ); } ); Avatar.displayName = 'Avatar’; End File# packages/react/src/components/Card/Card.tsx import React from 'react’; import clsx from 'clsx’; import ’./Card.scss’; export interface CardProps extends React.HTMLAttributes { /** * Variant of the card * @default 'outlined’ */ variant?: 'outlined’ | 'elevated’ | 'filled’; /** * Whether the card has a hover effect * @default false */ hoverable?: boolean; /** * Whether the card is clickable * @default false */ clickable?: boolean; /** * Whether the card is disabled * @default false */ disabled?: boolean; /** * Additional CSS class for the card */ className?: string; } export const Card = React.forwardRef( ( { children, variant = 'outlined’, hoverable = false, clickable = false, disabled = false, className, …rest }, ref ) => ( {children} ) ); Card.displayName = 'Card’; export interface CardHeaderProps extends React.HTMLAttributes { /** * Title of the card */ title?: React.ReactNode; /** * Subtitle of the card */ subtitle?: React.ReactNode; /** * Action element to display in the header */ action?: React.ReactNode; /** * Avatar or icon to display in the header */ avatar?: React.ReactNode; /** * Additional CSS class for the header */ className?: string; } export const CardHeader = React.forwardRef( ( { title, subtitle, action, avatar, className, children, …rest }, ref ) => ( {avatar && {avatar} } {title && {title} } {subtitle && {subtitle} } {children} {action && {action} } ) ); CardHeader.displayName = 'CardHeader’; export interface CardContentProps extends React.HTMLAttributes { /** * Additional CSS class for the content */ className?: string; } export const CardContent = React.forwardRef( ({ className, children, …rest }, ref) => ( {children} ) ); CardContent.displayName = 'CardContent’; export interface CardFooterProps extends React.HTMLAttributes { /** * Additional CSS class for the footer */ className?: string; /** * Align the footer content * @default 'left’ */ align?: 'left’ | 'center’ | 'right’ | 'space-between’; } export const CardFooter = React.forwardRef( ( { className, align = 'left’, children, …rest }, ref ) => ( {children} ) ); CardFooter.displayName = 'CardFooter’; export interface CardMediaProps extends React.HTMLAttributes { /** * Image source */ image?: string; /** * Alt text for the image */ alt?: string; /** * Height of the media area */ height?: number | string; /** * Position of the background image * @default 'center’ */ position?: string; /** * Title overlay on the media */ title?: React.ReactNode; /** * Additional CSS class for the media */ className?: string; } export const CardMedia = React.forwardRef( ( { image, alt, height = 200, position = 'center’, title, className, children, style, …rest }, ref ) => { const mediaStyle = { …style, height: typeof height === 'number’ ? `${height}px` : height, backgroundImage: image ? `url(${image})` : undefined, backgroundPosition: position, }; return ( {children} {title && {title} } ); } ); CardMedia.displayName = 'CardMedia’; export interface CardDividerProps extends React.HTMLAttributes { /** * Whether the divider is vertical * @default false */ vertical?: boolean; /** * Additional CSS class for the divider */ className?: string; } export const CardDivider = React.forwardRef( ({ vertical = false, className, …rest }, ref) => ( ) ); CardDivider.displayName = 'CardDivider’; End Fileimport { Component, ElementType, HTMLAttributes, MouseEvent, ReactNode } from 'react’; import clsx from 'clsx’; interface SpinnerProps extends HTMLAttributes { /** * Custom spinner size */ size?: 'small’ | 'medium’ | 'large’; /** * Change the color of the spinner */ color?: string; /** * Display a text message along the spinner */ message?: string; /** * Custom classes to apply to the spinner */ className?: string; /** * Display the spinner as an inline element */ inline?: boolean; /** * Whether to center the spinner */ centered?: boolean; /** * Spinner overlay mode with backdrop */ overlay?: boolean; /** * Custom icon for the spinner */ customIcon?: ReactNode; /** * Icon to display instead of spinner */ icon?: string; /** * Whether the spinner should take up the entire height of the parent */ fullHeight?: boolean; /** * Aria label for accessibility */ 'aria-label’?: string; /** * Component to render as */ as?: ElementType; /** * Whether the spinner should spin faster */ speedUp?: boolean; /** * Called when spinner is clicked */ onClick?: (event: MouseEvent) => void; } class Spinner extends Component { render() { const { size = 'medium’, color, message, className, inline = false, centered = false, overlay = false, customIcon, icon, fullHeight = false, 'aria-label’: ariaLabel = 'Loading’, as: Component = 'div’, speedUp = false, onClick, …rest } = this.props; const spinnerClasses = clsx( 'mds-spinner’, `mds-spinner–${size}`, { 'mds-spinner–inline’: inline, 'mds-spinner–centered’: centered, 'mds-spinner–overlay’: overlay, 'mds-spinner–with-message’: !!message, 'mds-spinner–full-height’: fullHeight, 'mds-spinner–speed-up’: speedUp, }, className ); const spinnerStyle = color ? { ’–spinner-color’: color } as React.CSSProperties : undefined; return ( {customIcon ? ( {customIcon} ) : icon ? ( {icon} ) : ( )} {message && {message} } {ariaLabel} ); } } export { Spinner, SpinnerProps }; End File# myrta-ds/myrta # packages/react/src/components/Radio/Radio.tsx import React, { useState, useRef, useEffect } from 'react’; import clsx from 'clsx’; import { useId } from ’../../hooks/useId’; import ’./Radio.scss’; export interface RadioProps extends Omit, 'size’> { /** * Label for the radio */ label?: React.ReactNode; /** * Helper text to display */ helperText?: React.ReactNode; /** * Error message to display */ error?: React.ReactNode; /** * Whether the radio is checked */ checked?: boolean; /** * Default checked state for uncontrolled component * @default false */ defaultChecked?: boolean; /** * Whether the radio is disabled * @default false */ disabled?: boolean; /** * Callback when checked state changes */ onChange?: (checked: boolean, event: React.ChangeEvent) => void; /** * Size of the radio * @default 'medium’ */ size?: 'small’ | 'medium’ | 'large’; /** * CSS class for the radio container */ className?: string; /** * CSS class for the radio input */ inputClassName?: string; /** * CSS class for the radio label */ labelClassName?: string; /** * Name for the radio input */ name?: string; /** * ID for the radio input */ id?: string; /** * Whether the radio is in a loading state * @default false */ loading?: boolean; /** * Value of the radio */ value?: string | number; } export const Radio = React.forwardRef( ( { label, helperText, error, checked, defaultChecked = false, disabled = false, onChange, size = 'medium’, className, inputClassName, labelClassName, name, id, loading = false, value, …props }, ref ) => { const [isChecked, setIsChecked] = useState( checked !== undefined ? checked : defaultChecked ); const inputRef = useRef(null); const generatedId = useId(); const radioId = id || `radio-${generatedId}`; const helperTextId = `helper-text-${generatedId}`; const errorId = `error-${generatedId}`; // Update internal state when controlled value changes useEffect(() => { if (checked !== undefined) { setIsChecked(checked); } }, [checked]); const handleChange = (event: React.ChangeEvent) => { if (disabled || loading) return; const newChecked = event.target.checked; // Only update internal state for uncontrolled component if (checked === undefined) { setIsChecked(newChecked); } if (onChange) { onChange(newChecked, event); } }; return ( { // Handle both the ref prop and the ref object if (ref) { if (typeof ref === 'function’) { ref(node); } else { ref.current = node; } } inputRef.current = node; }} type=”radio” id={radioId} name={name} checked={isChecked} onChange={handleChange} disabled={disabled || loading} className={clsx(’mds-radio__input’, inputClassName)} aria-invalid={Boolean(error)} aria-describedby={ error ? errorId : helperText ? helperTextId : undefined } value={value} {…props} /> {loading && } {label && ( {label} )} {error ? ( {error} ) : helperText ? ( {helperText} ) : null} ); } ); Radio.displayName = 'Radio’; export interface RadioGroupProps extends Omit, 'onChange’> { /** * Name attribute to be applied to all radios */ name: string; /** * Value of the selected radio */ value?: string | number; /** * Default value for uncontrolled component */ defaultValue?: string | number; /** * Called when a radio is selected */ onChange?: (value: string | number, event: React.ChangeEvent) => void; /** * CSS class for the radio group */ className?: string; /** * Orientation of the radio group * @default 'vertical’ */ orientation?: 'horizontal’ | 'vertical’; /** * Whether the radio group is disabled * @default false */ disabled?: boolean; /** * Error message to display */ error?: React.ReactNode; /** * Label for the radio group */ label?: React.ReactNode; /** * CSS class for the label */ labelClassName?: string; /** * Size for all radios in the group * @default 'medium’ */ size?: 'small’ | 'medium’ | 'large’; /** * Whether the radio group is required * @default false */ required?: boolean; } export const RadioGroup = React.forwardRef( ( { children, name, value, defaultValue, onChange, className, orientation = 'vertical’, disabled = false, error, label, labelClassName, size = 'medium’, required = false, …rest }, ref ) => { const [selectedValue, setSelectedValue] = useState( value !== undefined ? value : defaultValue ); const generatedId = useId(); const groupId = `radio-group-${generatedId}`; const errorId = `radio-group-error-${generatedId}`; // Update internal state when controlled value changes useEffect(() => { if (value !== undefined) { setSelectedValue(value); } }, [value]); const handleChange = (checked: boolean, event: React.ChangeEvent) => { if (checked) { const newValue = event.target.value; // Only update internal state for uncontrolled component if (value === undefined) { setSelectedValue(newValue); } if (onChange) { onChange(newValue, event); } } }; // Clone children to pass props const radioButtons = React.Children.map(children, (child) => { if (!React.isValidElement(child)) return child; // Only modify Radio components if (child.type === Radio) { return React.cloneElement(child, { name, size, disabled: disabled || child.props.disabled, checked: child.props.value === selectedValue, onChange: handleChange, }); } return child; }); return ( {label && ( {label} {required && *} )} {radioButtons} {error && ( {error} )} ); } ); RadioGroup.displayName = 'RadioGroup’; End Fileimport React from 'react’; import clsx from 'clsx’; import ’./Badge.scss’; export interface BadgeProps extends React.HTMLAttributes { /** * Content to be displayed inside the badge */ content?: React.ReactNode; /** * Maximum count to show (e.g., 99+) * @default 99 */ max?: number; /** * Badge color variant * @default 'primary’ */ variant?: 'primary’ | 'secondary’ | 'success’ | 'warning’ | 'error’ | 'info’; /** * Badge size * @default 'medium’ */ size?: 'small’ | 'medium’ | 'large’; /** * Whether the badge is a dot style (without content) * @default false */ dot?: boolean; /** * Position of the badge relative to its children * @default 'top-right’ */ position?: 'top-right’ | 'top-left’ | 'bottom-right’ | 'bottom-left’; /** * Whether to show the badge even when content is zero * @default false */ showZero?: boolean; /** * Whether the badge should be visible * @default true */ visible?: boolean; /** * Horizontal offset of the badge * @default 0 */ offsetX?: number; /** * Vertical offset of the badge * @default 0 */ offsetY?: number; /** * Whether the badge is standalone (not positioned relative to children) * @default false */ standalone?: boolean; /** * Elements that the badge wraps */ children?: React.ReactNode; } export const Badge = React.forwardRef( ( { content, max = 99, variant = 'primary’, size = 'medium’, dot = false, position = 'top-right’, showZero = false, visible = true, offsetX = 0, offsetY = 0, standalone = false, children, className, style, …rest }, ref ) => { // Determine if the badge should be shown const shouldShowBadge = () => { if (!visible) return false; if (dot) return true; if (content === 0 || content === '0′) return showZero; return Boolean(content); }; // Format the content for the badge const formattedContent = () => { if (dot) return null; if (typeof content === 'number’ && content > max) { return `${max}+`; } return content; }; // Create style with offsets const badgeStyle = { …style, …(offsetX && { ’–badge-offset-x’: `${offsetX}px` }), …(offsetY && { ’–badge-offset-y’: `${offsetY}px` }), }; // For standalone badge, just render the badge itself if (standalone) { return ( {formattedContent()} ); } // For badge with children, render the badge positioned relative to children return ( {children} {shouldShowBadge() && ( {formattedContent()} )} ); } ); Badge.displayName = 'Badge’; End Fileimport React, { useId } from 'react’; import clsx from 'clsx’; // Icon type definition export type IconName = | 'alert-circle’ | 'alert-triangle’ | 'arrow-down’ | 'arrow-left’ | 'arrow-right’ | 'arrow-up’ | 'bell’ | 'calendar’ | 'check’ | 'check-circle’ | 'chevron-down’ | 'chevron-left’ | 'chevron-right’ | 'chevron-up’ | 'clock’ | 'close’ | 'download’ | 'edit’ | 'eye’ | 'eye-off’ | 'file’ | 'folder’ | 'globe’ | 'heart’ | 'home’ | 'info’ | 'link’ | 'lock’ | 'mail’ | 'menu’ | 'message’ | 'more-horizontal’ | 'more-vertical’ | 'phone’ | 'plus’ | 'search’ | 'settings’ | 'share’ | 'star’ | 'trash’ | 'unlock’ | 'upload’ | 'user’ | 'users’ | 'x’; // Icon size definition export type IconSize = 'small’ | 'medium’ | 'large’ | number; // Icon props interface export interface IconProps { /** * Name of the icon */ name: IconName; /** * Size of the icon * @default 'medium’ */ size?: IconSize; /** * Color of the icon * @default 'currentColor’ */ color?: string; /** * Additional CSS class */ className?: string; /** * ARIA label for accessibility */ 'aria-label’?: string; /** * ARIA hidden attribute * @default false when aria-label is provided, otherwise true */ 'aria-hidden’?: boolean; /** * Viewbox for the SVG * @default '0 0 24 24′ */ viewBox?: string; /** * Custom stroke width * @default 2 */ strokeWidth?: number; /** * HTML title for the icon */ title?: string; /** * Whether the icon should spin * @default false */ spin?: boolean; /** * Whether the icon should pulse * @default false */ pulse?: boolean; /** * Custom CSS style */ style?: React.CSSProperties; /** * Custom SVG path */ customPath?: string; /** * Custom SVG content */ customSvg?: React.ReactNode; /** * onClick event handler */ onClick?: (event: React.MouseEvent) => void; } // Icon component export const Icon: React.FC = ({ name, size = 'medium’, color = 'currentColor’, className, 'aria-label’: ariaLabel, 'aria-hidden’: ariaHiddenProp, viewBox = '0 0 24 24′, strokeWidth = 2, title, spin = false, pulse = false, style, customPath, customSvg, onClick, …props }) => { // Generate unique IDs for accessibility const titleId = useId(); // Determine if the icon should be hidden from screen readers const ariaHidden = ariaHiddenProp ?? (ariaLabel ? false : true); // Convert size to pixel value if needed const getSizeInPixels = () => { if (typeof size === 'number’) return size; switch (size) { case 'small’: return 16; case 'large’: return 32; case 'medium’: default: return 24; } }; // Get the icon path based on name const getIconPath = (): string => { if (customPath) return customPath; // Return path data based on icon name switch (name) { case 'alert-circle’: return 'M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM11 7h2v7h-2V7zm0 8h2v2h-2v-2z’; case 'alert-triangle’: return 'M12.866 3l9.526 16.5a1 1 0 0 1-.866 1.5H2.474a1 1 0 0 1-.866-1.5L11.134 3a1 1 0 0 1 1.732 0zM11 16v2h2v-2h-2zm0-7v5h2V9h-2z’; case 'arrow-down’: return 'M12 4v12.25L17.25 11l1.42 1.41L12 19.08l-6.67-6.67L6.75 11 12 16.25V4h2z’; case 'arrow-left’: return 'M7.75 12L13 6.75 11.59 5.34 4.92 12l6.67 6.66L13 17.25 7.75 12z’; case 'arrow-right’: return 'M16.25 12L11 17.25l1.41 1.41L19.08 12l-6.67-6.66L11 6.75 16.25 12z’; case 'arrow-up’: return 'M12 20V7.75L6.75 13 5.34 11.59 12 4.92l6.66 6.67L17.25 13 12 7.75V20h-2z’; case 'bell’: return 'M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z’; case 'calendar’: return 'M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2zM7 12h5v5H7v-5z’; case 'check’: return 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z’; case 'check-circle’: return 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z’; case 'chevron-down’: return 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z’; case 'chevron-left’: return 'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z’; case 'chevron-right’: return 'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z’; case 'chevron-up’: return 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z’; case 'clock’: return 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z’; case 'close’: return 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z’; case 'download’: return 'M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z’; case 'edit’: return 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z’; case 'eye’: return 'M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z’; case 'eye-off’: return 'M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z’; case 'file’: return 'M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z’; case 'folder’: return 'M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z’; case 'globe’: return 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z’; case 'heart’: return 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z’; case 'home’: return 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z’; case 'info’: return 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z’; case 'link’: return 'M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z’; case 'lock’: return 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z’; case 'mail’: return 'M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z’; case 'menu’: return 'M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z’; case 'message’: return 'M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z’; case 'more-horizontal’: return 'M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z’; case 'more-vertical’: return 'M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z’; case 'phone’: return 'M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z’; case 'plus’: return 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z’; case 'search’: return 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z’; case 'settings’: return 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z’; case 'share’: return 'M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z’; case 'star’: return 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z’; case 'trash’: return 'M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z’; case 'unlock’: return 'M12 17c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm6-9h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h1.9c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z’; case 'upload’: return 'M5 20h14v-2H5v2zm0-10h4v7h6v-7h4l-7-7-7 7z’; case 'user’: return 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z’; case 'users’: return 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z’; case 'x’: return 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z’; default: return ”; } }; // Style object with size and animation properties const sizeInPixels = getSizeInPixels(); const iconStyle: React.CSSProperties = { …style, width: sizeInPixels, height: sizeInPixels, color, }; // Default SVG attributes const svgProps = { xmlns: 'http://www.w3.org/2000/svg’, viewBox, width: sizeInPixels, height: sizeInPixels, fill: 'currentColor’, stroke: 'none’, className: clsx( 'mds-icon’, { 'mds-icon–spin’: spin, 'mds-icon–pulse’: pulse, }, className ), style: iconStyle, 'aria-hidden’: ariaHidden, 'aria-labelledby’: title ? titleId : undefined, onClick, …props, }; // Render the icon return ( {title && {title}} {customSvg || } ); }; End File# packages/react/rollup.config.mjs import resolve from '@rollup/plugin-node-resolve’; import commonjs from '@rollup/plugin-commonjs’; import typescript from '@rollup/plugin-typescript’; import terser from '@rollup/plugin-terser’; import external from 'rollup-plugin-peer-deps-external’; import dts from 'rollup-plugin-dts’; import sass from 'rollup-plugin-sass’; import autoprefixer from 'autoprefixer’; import postcss from 'postcss’; import url from '@rollup/plugin-url’; import { readFileSync } from 'fs’; // Parse package.json const packageJson = JSON.parse(readFileSync(’./package.json’, 'utf8′)); // Define output directory const outputDir = 'dist’; const createPath = path => `${outputDir}/${path}`; // Configure SASS processing const sassOptions = { // Process and insert CSS directly insert: true, // Process SASS with PostCSS for autoprefixing processor: css => postcss([autoprefixer]) .process(css, { from: undefined }) .then(result => result.css) }; // Main Rollup configuration export default [ // ESM and CJS bundles (code) { input: 'src/index.ts’, output: [ { file: createPath(packageJson.module), format: 'esm’, sourcemap: true }, { file: createPath(packageJson.main), format: 'cjs’, sourcemap: true } ], plugins: [ // Handle external dependencies external(), // Resolve node_modules resolve(), // Convert CommonJS modules to ES6 commonjs(), // Process TypeScript typescript({ tsconfig: ’./tsconfig.json’, exclude: [’**/*.test.tsx’, '**/*.test.ts’, '**/*.stories.tsx’] }), // Process SASS files sass(sassOptions), // Handle assets (limit: 8KB, otherwise use file) url({ limit: 8 * 1024, // 8KB include: [’**/*.svg’, '**/*.png’, '**/*.jpg’, '**/*.gif’], fileName: '[dirname][name][extname]’ }), // Minify for production terser() ], // Explicitly mark React as external external: [’react’, 'react-dom’] }, // TypeScript declaration files { input: 'src/index.ts’, output: [ { file: createPath(packageJson.types), format: 'esm’ } ], plugins: [ dts({ tsconfig: ’./tsconfig.json’, compilerOptions: { emitDeclarationOnly: true, } }) ], external: [/.scss$/] } ]; End File# myrta-ds/myrta „use client”; import { Card, CardContent, Button, CardFooter, CardHeader, CardTitle, } from „@/components/ui/card”; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from „@/components/ui/table”; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from „@/components/ui/select”; import { Tabs, TabsList, TabsTrigger, TabsContent } from „@/components/ui/tabs”; import { useCallback, useEffect, useState } from „react”; import { Input } from „@/components/ui/input”; import { fetchLogData, filterLogData } from „@/lib/data”; import { LogData } from „@/lib/definitions”; import { CopyIcon, LoaderIcon, SearchIcon } from „lucide-react”; import { Label } from „@/components/ui/label”; import { Pagination, PaginationContent, PaginationItem, PaginationPrev, PaginationLink, PaginationEllipsis, PaginationNext, } from „@/components/ui/pagination”; import { useDebounce } from „@/lib/hooks”; import { truncateFirstLine, formatLogDate, getSeverityClass, formatStackTrace, } from „@/lib/utils”; import { Badge } from „@/components/ui/badge”; import copyToClipboard from „@/lib/clipboard”; import { ScrollArea } from „@/components/ui/scroll-area”; import LogMessageCard from „@/components/logs/log-message-card”; const DEBOUNCE_MS = 300; const LOGS_PER_PAGE = 10; export default function LogsPage() { // State for logs data and filters const [logs, setLogs] = useState([]); const [filteredLogs, setFilteredLogs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [selectedLog, setSelectedLog] = useState(null); const [tableView, setTableView] = useState(true); // Filters state const [searchTerm, setSearchTerm] = useState(„”); const [severity, setSeverity] = useState(„all”); const [timeRange, setTimeRange] = useState(„24h”); const [source, setSource] = useState(„all”); // Pagination state const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); // Debounced search term const debouncedSearchTerm = useDebounce(searchTerm, DEBOUNCE_MS); // Load logs data const loadLogs = useCallback(async () => { setIsLoading(true); try { const data = await fetchLogData(); setLogs(data); setIsLoading(false); } catch (error) { console.error(„Failed to fetch log data:”, error); setIsLoading(false); } }, []); // Initial data load useEffect(() => { loadLogs(); }, [loadLogs]); // Apply filters whenever filter criteria change useEffect(() => { const applyFilters = () => { const filtered = filterLogData( logs, debouncedSearchTerm, severity, timeRange, source ); setFilteredLogs(filtered); setTotalPages(Math.ceil(filtered.length / LOGS_PER_PAGE)); setCurrentPage(1); // Reset to first page when filters change // Clear selected log when filters change setSelectedLog(null); }; applyFilters(); }, [logs, debouncedSearchTerm, severity, timeRange, source]); // Handle pagination const getPaginatedLogs = () => { const startIndex = (currentPage – 1) * LOGS_PER_PAGE; const endIndex = startIndex + LOGS_PER_PAGE; return filteredLogs.slice(startIndex, endIndex); }; const handleCopySelected = () => { if (selectedLog) { copyToClipboard( `${selectedLog.timestamp} [${selectedLog.severity}] ${selectedLog.message}n${selectedLog.stackTrace}` ); } }; // Render loading state if (isLoading) { return ( Loading logs… ); } const renderPagination = () => { if (totalPages { const pages = []; const showEllipsisStart = currentPage > 3; const showEllipsisEnd = currentPage 3) { pages.push( setCurrentPage(1)}>1 ); } // Show ellipsis if needed if (showEllipsisStart) { pages.push( ); } // Show current page and adjacent pages for ( let i = Math.max(2, currentPage – 1); i {i} ); } // Show ellipsis if needed if (showEllipsisEnd) { pages.push( ); } // Always show last page if (currentPage 3) { pages.push( setCurrentPage(totalPages)}> {totalPages} ); } return pages; }; return ( setCurrentPage((prev) => Math.max(prev – 1, 1))} isDisabled={currentPage === 1} /> {renderPageNumbers()} setCurrentPage((prev) => Math.min(prev + 1, totalPages)) } isDisabled={currentPage === totalPages} /> ); }; const paginatedLogs = getPaginatedLogs(); const renderTableView = () => ( Timestamp Severity Source Message {paginatedLogs.length === 0 ? ( No logs found matching the current filters. ) : ( paginatedLogs.map((log) => ( setSelectedLog(log)} style={{ cursor: 'pointer’ }} > {formatLogDate(log.timestamp)} {log.severity} {log.source} {truncateFirstLine(log.message)} )) )} Przewiń tabelę w poziomie ←→ {renderPagination()} ); const renderCardView = () => ( {paginatedLogs.length === 0 ? ( No logs found matching the current filters. ) : ( paginatedLogs.map((log) => ( setSelectedLog(log)} /> )) )} {renderPagination()} ); return ( System Logs {/* Filters panel */} setSearchTerm(e.target.value)} className=”pl-10″ /> All Severities Error Warning Info Debug Last hour Last 24 hours Last 7 days Last 30 days All time All Sources API Frontend Backend Database { setSearchTerm(„”); setSeverity(„all”); setTimeRange(„24h”); setSource(„all”); }} > Reset Filters {/* Main content area */} Log Events setTableView(v === „table”)} > Table Cards {tableView ? renderTableView() : renderCardView()} Showing {Math.min(filteredLogs.length, LOGS_PER_PAGE)} of{” „} {filteredLogs.length} logs {/* Log details panel */} Log Details {selectedLog && ( Copy )} {selectedLog ? ( Timestamp {formatLogDate(selectedLog.timestamp, true)} Severity {selectedLog.severity} Source {selectedLog.source} Message {selectedLog.message} {selectedLog.stackTrace && ( Stack Trace {formatStackTrace(selectedLog.stackTrace)} )} {selectedLog.metadata && ( Metadata {JSON.stringify(selectedLog.metadata, null, 2)} )} ) : ( Select a log entry to view details )} ); } End File# apps/dashboard-demo/app/analytics/performance/page.tsx „use client”; import { Card, CardContent, CardHeader, CardTitle } from „@/components/ui/card”; import { ArrowDownIcon, ArrowUpIcon, LoaderIcon, MoreHorizontalIcon, } from „lucide-react”; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from „@/components/ui/select”; import { useState, useEffect } from „react”; import { Button } from „@/components/ui/button”; import { Tabs, TabsList, TabsTrigger } from „@/components/ui/tabs”; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from „@/components/ui/table”; import { Progress } from „@/components/ui/progress”; import { TimeSeriesChart } from „@/components/charts/time-series-chart”; import { BarChart } from „@/components/charts/bar-chart”; import { DonutChart } from „@/components/charts/donut-chart”; import { fetchPerformanceData, fetchPerformanceRouteData, fetchPerformanceResources, } from „@/lib/data”; import { PerformanceData, PerformanceRouteData, PerformanceResource, } from „@/lib/definitions”; import { formatBytes, formatNumber } from „@/lib/utils”; import ResourcesTable from „@/components/performance/resources-table”; // Page component export default function PerformancePage() { const [timeRange, setTimeRange] = useState(„7d”); const [isLoading, setIsLoading] = useState(true); const [performanceData, setPerformanceData] = useState( null ); const [routeData, setRouteData] = useState([]); const [resources, setResources] = useState([]); useEffect(() => { async function loadData() { setIsLoading(true); try { const perfData = await fetchPerformanceData(timeRange); const routesData = await fetchPerformanceRouteData(timeRange); const resourcesData = await fetchPerformanceResources(); setPerformanceData(perfData); setRouteData(routesData); setResources(resourcesData); setIsLoading(false); } catch (error) { console.error(„Error loading performance data:”, error); setIsLoading(false); } } loadData(); }, [timeRange]); if (isLoading) { return ( Loading performance data… ); } if (!performanceData) { return ( No performance data available. ); } return ( Performance Dashboard Last 24 hours Last 7 days Last 30 days Last 90 days Export {/* Key Metrics Cards */} 0 ? „slower” : „faster” } isGoodWhenNegative={true} /> 0 ? „slower” : „faster” } isGoodWhenNegative={true} /> 0 ? „slower” : „faster” } isGoodWhenNegative={true} /> 0 ? „slower” : „faster” } isGoodWhenNegative={true} /> {/* Time Series Charts */} Page Load Time (seconds) First Contentful Paint (seconds) {/* Routes Performance */} Routes Performance Slowest All Routes Route Avg. Load Time Min Max Page Views Performance {routeData.map((route) => ( {route.route} {route.avgLoadTime.toFixed(2)}s {route.minLoadTime.toFixed(2)}s {route.maxLoadTime.toFixed(2)}s {formatNumber(route.pageViews)} 0.7 ? „bg-green-500” : route.performanceScore > 0.4 ? „bg-amber-500” : „bg-red-500” } /> 0.7 ? „text-green-500” : route.performanceScore > 0.4 ? „text-amber-500” : „text-red-500” } > {(route.performanceScore * 100).toFixed(0)}% ))} Przewiń tabelę w poziomie ←→ {/* Charts and Resources */} Page Sizes by Type ({ name: item.type, value: item.size, }))} height={300} valueFormatter={(value) => formatBytes(value)} /> Loading Times by Device Type {/* Resources Table */} Resource Performance ); } // Metric Card Component function MetricCard({ title, value, change, changeLabel, isGoodWhenNegative = false, }: { title: string; value: string; change: number; changeLabel: string; isGoodWhenNegative?: boolean; }) { // Determine if the change is positive or negative from a business perspective const isPositiveChange = isGoodWhenNegative ? change 0; return ( {title} {value} {change >= 0 ? ( ) : ( )} {Math.abs(change).toFixed(1)}% {changeLabel} vs. previous period ); } End File# apps/dashboard-demo/app/analytics/engagement/page.tsx „use client”; import { Card, CardContent, CardHeader, CardTitle } from „@/components/ui/card”; import { ArrowDownIcon, ArrowUpIcon, LoaderIcon, MoreHorizontalIcon, } from „lucide-react”; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from „@/components/ui/select”; import { useState, useEffect } from „react”; import { Button } from „@/components/ui/button”; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from „@/components/ui/table”; import { TimeSeriesChart } from „@/components/charts/time-series-chart”; import { BarChart } from „@/components/charts/bar-chart”; import { DonutChart } from „@/components/charts/donut-chart”; import { fetchEngagementData, fetchReferrers, fetchPopularContent, } from „@/lib/data”; import { EngagementData, Referrer, ContentItem } from „@/lib/definitions”; import { formatNumber, formatTime } from „@/lib/utils”; // Page component export default function EngagementPage() { const [timeRange, setTimeRange] = useState(„30d”); const [isLoading, setIsLoading] = useState(true); const [engagementData, setEngagementData] = useState( null ); const [referrers, setReferrers] = useState([]); const [popularContent, setPopularContent] = useState([]); useEffect(() => { async function loadData() { setIsLoading(true); try { const data = await fetchEngagementData(timeRange); const referrerData = await fetchReferrers(timeRange); const contentData = await fetchPopularContent(timeRange); setEngagementData(data); setReferrers(referrerData); setPopularContent(contentData); setIsLoading(false); } catch (error) { console.error(„Error loading engagement data:”, error); setIsLoading(false); } } loadData(); }, [timeRange]); if (isLoading) { return ( Loading engagement data… ); } if (!engagementData) { return ( No engagement data available. ); } return ( User Engagement Last 7 days Last 30 days Last 90 days Last year Export {/* Key Metrics Cards */} {/* Time Series Charts */} Average Session Duration formatTime(value * 60)} /> Bounce Rate Over Time {/* Charts and Referrers */} Traffic by Device `${value}%`} /> {engagementData.trafficByDevice.map((item) => ( {item.name} {item.value}% ))} User Engagement by Age Group formatTime(value * 60)} /> {/* Popular Content & Referrers */} Most Popular Content Title Views Avg. Time Engagement {popularContent.map((item) => ( {item.title} {formatNumber(item.views)} {formatTime(item.avgTimeOnPage)} 75 ? „text-green-500 font-medium” : item.engagementScore > 50 ? „text-amber-500 font-medium” : „text-muted-foreground font-medium” } > {item.engagementScore}% ))} Przewiń tabelę w poziomie ←→ Top Referrers Source Sessions Bounce Rate Conversion {referrers.map((referrer) => ( {referrer.source} {formatNumber(referrer.sessions)} 70 ? „text-red-500 font-medium” : referrer.bounceRate > 50 ? „text-amber-500 font-medium” : „text-green-500 font-medium” } > {referrer.bounceRate.toFixed(1)}% {referrer.conversionRate.toFixed(1)}% ))} Przewiń tabelę w poziomie ←→ ); } // Metric Card Component function MetricCard({ title, value, change, isGoodWhenPositive = true, }: { title: string; value: string; change: number; isGoodWhenPositive?: boolean; }) { // Determine if the change is positive or negative from a business perspective const isPositiveChange = isGoodWhenPositive ? change > 0 : change = 0 ? ( ) : ( )} {Math.abs(change).toFixed(1)}% vs. previous period ); } End File# apps/dashboard-demo/lib/data.ts import { addDays, addHours, format, subDays, subHours, subMonths } from „date-fns”; import { LogData, PerformanceData, PerformanceRouteData, PerformanceResource, EngagementData, Referrer, ContentItem } from „./definitions”; // Mock log data export async function fetchLogData(): Promise { // Simulate API delay await new Promise(resolve => setTimeout(resolve, 800)); const now = new Date(); const logs: LogData[] = []; // Generate random logs for (let i = 0; i 0.7 ? { userId: `user_${Math.floor(Math.random() * 10000)}`, requestId: `req_${Math.random().toString(36).substring(2, 12)}`, path: `/api/${[’users’, 'products’, 'orders’][Math.floor(Math.random() * 3)]}/${Math.floor(Math.random() * 1000)}`, browser: [’Chrome’, 'Firefox’, 'Safari’, 'Edge’][Math.floor(Math.random() * 4)], os: [’Windows’, 'MacOS’, 'Linux’, 'iOS’, 'Android’][Math.floor(Math.random() * 5)] } : undefined; logs.push({ id: `log_${i}`, timestamp, severity, source, message, stackTrace, metadata }); } // Sort by most recent first return logs.sort((a, b) => new Date(b.timestamp).getTime() – new Date(a.timestamp).getTime()); } // Filter log data based on search term and filters export function filterLogData( logs: LogData[], searchTerm: string = ”, severity: string = 'all’, timeRange: string = ’24h’, source: string = 'all’ ): LogData[] { const now = new Date(); let filteredLogs = […logs]; // Filter by time range if (timeRange !== 'all’) { let cutoffDate; switch (timeRange) { case '1h’: cutoffDate = subHours(now, 1); break; case ’24h’: cutoffDate = subDays(now, 1); break; case '7d’: cutoffDate = subDays(now, 7); break; case ’30d’: cutoffDate = subDays(now, 30); break; default: cutoffDate = subDays(now, 1); // Default to 24h } filteredLogs = filteredLogs.filter(log => new Date(log.timestamp) >= cutoffDate ); } // Filter by severity if (severity !== 'all’) { filteredLogs = filteredLogs.filter(log => log.severity.toLowerCase() === severity.toLowerCase() ); } // Filter by source if (source !== 'all’) { filteredLogs = filteredLogs.filter(log => log.source.toLowerCase() === source.toLowerCase() ); } // Filter by search term if (searchTerm.trim() !== ”) { const term = searchTerm.toLowerCase(); filteredLogs = filteredLogs.filter(log => log.message.toLowerCase().includes(term) || log.source.toLowerCase().includes(term) || log.severity.toLowerCase().includes(term) || (log.stackTrace && log.stackTrace.toLowerCase().includes(term)) ); } return filteredLogs; } // Helper functions for generating random log messages function getRandomErrorMessage(): string { const errorMessages = [ „Failed to connect to database: Connection timeout after 30 seconds”, „Uncaught TypeError: Cannot read property 'data’ of undefined”, „Authentication failed: Invalid token signature”, „Failed to process payment: Invalid card number”, „500 Internal Server Error: Unexpected condition encountered”, „OutOfMemoryError: Java heap space”, „Unhandled exception in API request: Invalid JSON payload”, „Database query failed: Syntax error in SQL statement”, „Cache miss for critical resource, fallback failed”, „Rate limit exceeded for API endpoint /api/users”, „Failed to read file: Permission denied”, „Network error: Unable to reach external service” ]; return errorMessages[Math.floor(Math.random() * errorMessages.length)]; } function getRandomWarningMessage(): string { const warningMessages = [ „High CPU usage detected: 85% for the last 5 minutes”, „Memory usage approaching threshold: 78% of available memory”, „Slow database query detected: Query took 4.2s to complete”, „API rate limit at 80% – consider throttling requests”, „Deprecated API endpoint still in use: /v1/legacy/users”, „Cache hit ratio below acceptable threshold (65%)”, „Session timeout shorter than recommended (10 minutes)”, „Disk space running low: 85% used on /data volume”, „Configuration using default values – recommended to set explicitly”, „CORS policy may be too permissive, consider restricting origins” ]; return warningMessages[Math.floor(Math.random() * warningMessages.length)]; } function getRandomInfoMessage(): string { const infoMessages = [ „User successfully authenticated: user_12345”, „Payment processed successfully for order #34567”, „New user registered: [email protected]”, „Email notification sent to [email protected]”, „Database backup completed successfully, size: 1.2GB”, „API request completed, response time: 235ms”, „Background job completed: Report generation”, „User session started: session_id_123456”, „Config reloaded with new environment variables”, „Cache purged for content region: product-catalog” ]; return infoMessages[Math.floor(Math.random() * infoMessages.length)]; } function getRandomDebugMessage(): string { const debugMessages = [ „Request parameters: {„userId”: 12345, „limit”: 50, „offset”: 0}”, „Database query executed: SELECT * FROM users WHERE status = 'active’ LIMIT 100”, „Cache lookup for key: user:profile:12345”, „Processing item 45 of 230 in batch job”, „Authentication flow starting for user: [email protected]”, „API response payload size: 24.5KB”, „Function execution time: method=getUserData, duration=125ms”, „Initializing service dependencies: [UserService, AuthService, CacheManager]”, „Route matched: GET /api/products/:id -> ProductController.getProductById”, „Object state before transform: {„status”: „pending”, „attempts”: 2}” ]; return debugMessages[Math.floor(Math.random() * debugMessages.length)]; } function getRandomStackTrace(): string { const stackTraces = [ `Error: Cannot read property 'id’ of undefined at UserService.getUserDetails (/src/services/UserService.js:42:23) at async APIController.getUserProfile (/src/controllers/APIController.js:57:12) at async processRequest (/src/middleware/requestHandler.js:28:7)`, `TypeError: undefined is not a function at Object.parseResponse [as parse] (/src/utils/responseParser.js:28:10) at APIClient.handleResponse (/src/clients/APIClient.js:112:45) at async fetchData (/src/hooks/useFetch.js:23:19) at async ComponentDidMount (/src/components/DataView.js:31:5)`, `ReferenceError: fetch is not defined at fetchData (/src/utils/api.js:15:3) at loadUserData (/src/components/UserProfile.js:47:19) at Component.componentDidMount (/src/components/UserProfile.js:32:10)`, `SyntaxError: Unexpected token } in JSON at position 42 at JSON.parse () at parseResponse (/src/utils/http.js:23:19) at async fetchData (/src/services/dataService.js:45:23)`, `Error: Failed to connect to database at connectToDatabase (/src/db/connection.js:28:11) at initializeServices (/src/app.js:42:8) at startServer (/src/server.js:15:3) at main (/src/index.js:10:1)`, `RangeError: Maximum call stack size exceeded at Object.processNode (/src/utils/treeProcessor.js:45:12) at Object.processNode (/src/utils/treeProcessor.js:47:14) at Object.processNode (/src/utils/treeProcessor.js:47:14)`, `DatabaseError: relation „users” does not exist at Connection.parseE (/node_modules/pg/lib/connection.js:614:13) at Connection.parseMessage (/node_modules/pg/lib/connection.js:413:19) at Socket. (/node_modules/pg/lib/connection.js:129:22)`, `AuthenticationError: Invalid credentials at verifyToken (/src/auth/tokenVerifier.js:35:11) at authenticateRequest (/src/middleware/auth.js:28:15) at async processRequest (/src/middleware/requestHandler.js:24:5)` ]; return stackTraces[Math.floor(Math.random() * stackTraces.length)]; } // Mock performance data export async function fetchPerformanceData(timeRange: string): Promise { // Simulate API delay await new Promise(resolve => setTimeout(resolve, 1000)); const now = new Date(); const days = timeRange === ’24h’ ? 1 : timeRange === '7d’ ? 7 : timeRange === ’30d’ ? 30 : 90; // Generate history data const pageLoadTimeHistory = []; const firstContentfulPaintHistory = []; for (let i = days; i >= 0; i–) { const date = subDays(now, i); // Add some randomness but maintain a trend const basePageLoad = 2.2 + (Math.sin(i/10) * 0.3); const baseFCP = 1.4 + (Math.cos(i/8) * 0.2); pageLoadTimeHistory.push({ date: format(date, 'yyyy-MM-dd’), value: Number((basePageLoad + (Math.random() * 0.5 – 0.25)).toFixed(2)) }); firstContentfulPaintHistory.push({ date: format(date, 'yyyy-MM-dd’), value: Number((baseFCP + (Math.random() * 0.4 – 0.2)).toFixed(2)) }); } return { pageLoadTime: 2.4, pageLoadTimeChange: -5.2, firstContentfulPaint: 1.2, firstContentfulPaintChange: -8.5, timeToInteractive: 3.1, timeToInteractiveChange: -3.8, serverResponseTime: 0.42, serverResponseTimeChange: 12.5, pageLoadTimeHistory, firstContentfulPaintHistory, resourceSizesByType: [ { type: 'JavaScript’, size: 845000 }, { type: 'CSS’, size: 124000 }, { type: 'Images’, size: 1250000 }, { type: 'Fonts’, size: 298000 }, { type: 'HTML’, size: 45000 }, { type: 'Other’, size: 67000 }, ], loadTimesByDevice: [ { device: 'Desktop’, loadTime: 2.1 }, { device: 'Mobile’, loadTime: 3.4 }, { device: 'Tablet’, loadTime: 2.8 }, ] }; } // Mock performance route data export async function fetchPerformanceRouteData(timeRange: string): Promise { // Simulate API delay await new Promise(resolve => setTimeout(resolve, 800)); return [ { route: '/dashboard’, avgLoadTime: 2.1, minLoadTime: 1.4, maxLoadTime: 3.8, pageViews: 15420, performanceScore: 0.82 }, { route: '/products’, avgLoadTime: 2.8, minLoadTime: 1.9, maxLoadTime: 5.2, pageViews: 8760, performanceScore: 0.75 }, { route: '/checkout’, avgLoadTime: 3.2, minLoadTime: 2.1, maxLoadTime: 6.5, pageViews: 4350, performanceScore: 0.65 }, { route: '/user/profile’, avgLoadTime: 1.9, minLoadTime: 1.2, maxLoadTime: 3.6, pageViews: 3280, performanceScore: 0.88 }, { route: '/blog’, avgLoadTime: 2.4, minLoadTime: 1.6, maxLoadTime: 4.2, pageViews: 7520, performanceScore: 0.79 }, { route: '/api/data’, avgLoadTime: 0.8, minLoadTime: 0.3, maxLoadTime: 3.2, pageViews: 142500, performanceScore: 0.92 }, { route: '/search’, avgLoadTime: 4.2, minLoadTime: 2.5, maxLoadTime: 8.7, pageViews: 6240, performanceScore: 0.42 }, { route: '/cart’, avgLoadTime: 2.6, minLoadTime: 1.5, maxLoadTime: 5.4, pageViews: 5870, performanceScore: 0.70 } ]; } // Mock resources performance data export async function fetchPerformanceResources(): Promise { // Simulate API delay await new Promise(resolve => setTimeout(resolve, 700)); return [ { name: 'main.js’, type: 'script’, size: 287500, transferSize: 76400, loadTime: 420, cacheable: true, optimized: false }, { name: 'styles.css’, type: 'stylesheet’, size: 84200, transferSize: 23500, loadTime: 180, cacheable: true, optimized: true }, { name: 'vendor.js’, type: 'script’, size: 543000, transferSize: 168400, loadTime: 680, cacheable: true, optimized: false }, { name: 'hero-image.jpg’, type: 'image’, size: 485000, transferSize: 482000, loadTime: 540, cacheable: true, optimized: false }, { name: 'font-awesome.woff2′, type: 'font’, size: 98000, transferSize: 97400, loadTime: 220, cacheable: true, optimized: true }, { name: 'api/user-data’, type: 'fetch’, size: 12400, transferSize: 5200, loadTime: 350, cacheable: false, optimized: true }, { name: 'analytics.js’, type: 'script’, size: 67800, transferSize: 22400, loadTime: 310, cacheable: true, optimized: false }, { name: 'product-thumbnails.png’, type: 'image’, size: 265000, transferSize: 264300, loadTime: 480, cacheable: true, optimized: false }, { name: 'roboto.woff2′, type: 'font’, size: 142000, transferSize: 141600, loadTime: 290, cacheable: true, optimized: true }, { name: 'polyfill.js’, type: 'script’, size: 48600, transferSize: 12800, loadTime: 160, cacheable: true, optimized: true } ]; } // Mock user engagement data export async function fetchEngagementData(timeRange: string): Promise { // Simulate API delay await new Promise(resolve => setTimeout(resolve, 900)); const now = new Date(); const days = timeRange === '7d’ ? 7 : timeRange === ’30d’ ? 30 : timeRange === ’90d’ ? 90 : 365; // Generate history data const sessionDurationHistory = []; const bounceRateHistory = []; for (let i = days; i >= 0; i–) { const date = subDays(now, i); // Add some randomness but maintain a trend const baseDuration = 3.2 + (Math.sin(i/15) * 0.4); const baseBounce = 48 + (Math.cos(i/20) * 5); sessionDurationHistory.push({ date: format(date, 'yyyy-MM-dd’), value: Number((baseDuration + (Math.random() * 0.6 – 0.3)).toFixed(2)) }); bounceRateHistory.push({ date: format(date, 'yyyy-MM-dd’), value: Number((baseBounce + (Math.random() * 3 – 1.5)).toFixed(1)) }); } return { avgSessionDuration: 186, // in seconds avgSessionDurationChange: 4.8, pagesPerSession: 3.4, pagesPerSessionChange: 2.1, bounceRate: 46.5, bounceRateChange: -3.2, returningUsers: 32.7, returningUsersChange: 8.4, sessionDurationHistory, bounceRateHistory, trafficByDevice: [ { name: 'Desktop’, value: 48 }, { name: 'Mobile’, value: 42 }, { name: 'Tablet’, value: 10 }, ], engagementByAge: [ { age: ’18-24′, sessionDuration: 2.8 }, { age: ’25-34′, sessionDuration: 3.5 }, { age: ’35-44′, sessionDuration: 4.2 }, { age: ’45-54′, sessionDuration: 3.7 }, { age: ’55-64′, sessionDuration: 3.1 }, { age: ’65+’, sessionDuration: 2.5 }, ] }; } // Mock referrers data export async function fetchReferrers(timeRange: string): Promise { // Simulate API delay await new Promise(resolve => setTimeout(resolve, 700)); return [ { source: 'Google’, sessions: 42650, bounceRate: 42.3, conversionRate: 3.8 }, { source: 'Direct’, sessions: 31470, bounceRate: 38.7, conversionRate: 5.2 }, { source: 'Facebook’, sessions: 18920, bounceRate: 53.1, conversionRate: 2.7 }, { source: 'Instagram’, sessions: 14580, bounceRate: 64.5, conversionRate: 3.1 }, { source: 'Twitter’, sessions: 8240, bounceRate: 59.8, conversionRate: 1.8 }, { source: 'Email’, sessions: 7830, bounceRate: 31.4, conversionRate: 6.4 }, { source: 'LinkedIn’, sessions: 4920, bounceRate: 44.2, conversionRate: 4.3 }, { source: 'Reddit’, sessions: 3750, bounceRate: 66.7, conversionRate: 1.5 } ]; } // Mock popular content data export async function fetchPopularContent(timeRange: string): Promise { // Simulate API delay await new Promise(resolve => setTimeout(resolve, 800)); return [ { id: 'page_1′, title: 'Getting Started Guide’, views: 28450, avgTimeOnPage: 285, engagementScore: 87 }, { id: 'page_2′, title: 'Product Features Overview’, views: 24180, avgTimeOnPage: 192, engagementScore: 76 }, { id: 'page_3′, title: 'Pricing Plans’, views: 19740, avgTimeOnPage: 147, engagementScore: 62 }, { id: 'page_4′, title: 'Latest Product Updates’, views: 17920, avgTimeOnPage: 240, engagementScore: 81 }, { id: 'page_5′, title: 'API Documentation’, views: 15840, avgTimeOnPage: 328, engagementScore: 93 }, { id: 'page_6′, title: 'Case Studies’, views: 14250, avgTimeOnPage: 265, engagementScore: 84 }, { id: 'page_7′, title: 'Contact Support’, views: 11380, avgTimeOnPage: 118, engagementScore: 54 }, { id: 'page_8′, title: 'Frequently Asked Questions’, views: 9740, avgTimeOnPage: 208, engagementScore: 72 } ]; } End File# myrta-ds/myrta // Types for dashboard data structures // Log data export interface LogData { id: string; timestamp: string; severity: 'info’ | 'warning’ | 'error’ | 'debug’; source: string; message: string; stackTrace?: string; metadata?: Record; } // Performance data export interface TimeSeriesDataPoint { date: string; value: number; } export interface ResourceSizeData { type: string; size: number; } export interface DeviceLoadTimeData { device: string; loadTime: number; } export interface PerformanceData { pageLoadTime: number; pageLoadTimeChange: number; firstContentfulPaint: number; firstContentfulPaintChange: number; timeToInteractive: number; timeToInteractiveChange: number; serverResponseTime: number; serverResponseTimeChange: number; pageLoadTimeHistory: TimeSeriesDataPoint[]; firstContentfulPaintHistory: TimeSeriesDataPoint[]; resourceSizesByType: ResourceSizeData[]; loadTimesByDevice: DeviceLoadTimeData[]; } export interface PerformanceRouteData { route: string; avgLoadTime: number; minLoadTime: number; maxLoadTime: number; pageViews: number; performanceScore: number; } export interface PerformanceResource { name: string; type: string; size: number; transferSize: number; loadTime: number; cacheable: boolean; optimized: boolean; } // Engagement data export interface DeviceTrafficData { name: string; value: number; } export interface AgeEngagementData { age: string; sessionDuration: number; } export interface EngagementData { avgSessionDuration: number; // in seconds avgSessionDurationChange: number; pagesPerSession: number; pagesPerSessionChange: number; bounceRate: number; bounceRateChange: number; returningUsers: number; returningUsersChange: number; sessionDurationHistory: TimeSeriesDataPoint[]; bounceRateHistory: TimeSeriesDataPoint[]; trafficByDevice: DeviceTrafficData[]; engagementByAge: AgeEngagementData[]; } export interface Referrer { source: string; sessions: number; bounceRate: number; conversionRate: number; } export interface ContentItem { id: string; title: string; views: number; avgTimeOnPage: number; // in seconds engagementScore: number; // 0-100 } End Fileimport { format, formatDistance } from 'date-fns’; /** * Format a date object or string to a readable date * @param date Date object or string * @param includeTime Include time in the formatted string * @returns Formatted date string */ export function formatDate(date: Date | string, includeTime: boolean = false): string { const dateObj = typeof date === 'string’ ? new Date(date) : date; return format(dateObj, includeTime ? 'PPp’ : 'PP’); } /** * Format a date with a relative time (e.g., '3 days ago’) * @param date Date object or string * @returns Relative time string */ export function formatRelativeTime(date: Date | string): string { const dateObj = typeof date === 'string’ ? new Date(date) : date; return formatDistance(dateObj, new Date(), { addSuffix: true }); } /** * Format a timestamp from a log entry * @param timestamp ISO date string * @param includeSeconds Include seconds in the output * @returns Formatted date/time string */ export function formatLogDate(timestamp: string, includeSeconds: boolean = false): string { const date = new Date(timestamp); if (includeSeconds) { return format(date, 'MMM d, yyyy HH:mm:ss’); } return format(date, 'MMM d, yyyy HH:mm’); } /** * Truncate the first line of a multi-line string * @param text Text to truncate * @param maxLength Maximum length before truncation * @returns Truncated first line */ export function truncateFirstLine(text: string, maxLength: number = 100): string { // Get only the first line const firstLine = text.split(’n’)[0]; // Truncate if necessary if (firstLine.length <= maxLength) { return firstLine; } return firstLine.substring(0, maxLength) + '…'; } /** * Get CSS class based on log severity * @param severity Log severity * @returns CSS class name */ export function getSeverityClass(severity: string): 'default' | 'destructive' | 'warning' | 'secondary' { switch (severity.toLowerCase()) { case 'error': return 'destructive'; case 'warning': return 'warning'; case 'info': return 'secondary'; case 'debug': default: return 'default'; } } /** * Format a stack trace for better readability * @param stackTrace Raw stack trace * @returns Formatted stack trace */ export function formatStackTrace(stackTrace: string): string { if (!stackTrace) return ''; // Already has line breaks if (stackTrace.includes('n')) { return stackTrace; } // Add line breaks at common delimiters return stackTrace .replace(/at /g, 'n at ') .replace(/^n/, ''); // Remove leading newline } /** * Format a number with thousands separators * @param num Number to format * @returns Formatted number string */ export function formatNumber(num: number): string { return new Intl.NumberFormat().format(num); } /** * Format time in seconds to a readable format * @param seconds Time in seconds * @returns Formatted time string */ export function formatTime(seconds: number): string { if (seconds string; /** * Text prefix for values */ valuePrefix?: string; /** * Text suffix for values */ valueSuffix?: string; } /** * Bar chart component using Recharts */ export function BarChart({ data, xAxisKey, yAxisKey, height = 300, width = „100%”, barColor = „hsl(var(–primary))”, showGrid = true, valueFormatter, valuePrefix = „”, valueSuffix = „”, }: BarChartProps) { // Default value formatter const defaultFormatter = (value: number) => { return `${valuePrefix}${value}${valueSuffix}`; }; // Use the provided formatter or default const formatValue = valueFormatter || defaultFormatter; return ( {showGrid && ( )} { if (active && payload && payload.length) { return ( {payload[0].payload[xAxisKey]} {formatValue(payload[0].value as number)} ); } return null; }} /> ); } End File# myrta-ds/myrta import * as React from „react”; import { DonutChart as RechartsDonutChart, PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from „recharts”; import { Card } from „@/components/ui/card”; /** * Data item for donut charts */ interface DonutChartDataItem { name: string; value: number; } interface DonutChartProps { /** * Data for the donut chart */ data: DonutChartDataItem[]; /** * Height of the chart in pixels * @default 300 */ height?: number; /** * Width of the chart (can be percentage or pixels) * @default '100%’ */ width?: number | string; /** * Inner radius as a percentage * @default 70 */ innerRadius?: number; /** * Outer radius as a percentage * @default 90 */ outerRadius?: number; /** * Colors for the segments * @default Automatically generated based on theme */ colors?: string[]; /** * Show a tooltip on hover * @default true */ showTooltip?: boolean; /** * Show a legend for the chart * @default false */ showLegend?: boolean; /** * Format value for display */ valueFormatter?: (value: number) => string; } /** * Donut chart component using Recharts */ export function DonutChart({ data, height = 300, width = „100%”, innerRadius = 70, outerRadius = 90, colors, showTooltip = true, showLegend = false, valueFormatter, }: DonutChartProps) { // Default colors based on theme const defaultColors = [ „hsl(var(–primary))”, „hsl(var(–secondary))”, „hsl(var(–accent))”, „hsl(var(–muted))”, „hsl(var(–primary) / 0.8)”, „hsl(var(–secondary) / 0.8)”, „hsl(var(–accent) / 0.8)”, „hsl(var(–muted) / 0.8)”, „hsl(var(–primary) / 0.6)”, „hsl(var(–secondary) / 0.6)”, ]; // Use the provided colors or default colors const chartColors = colors || defaultColors; // Default value formatter const defaultFormatter = (value: number) => { return value.toString(); }; // Use the provided formatter or default const formatValue = valueFormatter || defaultFormatter; return ( {data.map((entry, index) => ( ))} {showTooltip && ( { if (active && payload && payload.length) { return ( {payload[0].name} {formatValue(payload[0].value as number)} ); } return null; }} /> )} {showLegend && ( )} ); } End Fileimport * as React from „react”; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area } from „recharts”; import { Card } from „@/components/ui/card”; /** * Data point for time series charts */ interface TimeSeriesPoint { date: string; value: number; } interface TimeSeriesChartProps { /** * Data for the time series chart */ data: TimeSeriesPoint[]; /** * Height of the chart in pixels * @default 300 */ height?: number; /** * Width of the chart (can be percentage or pixels) * @default '100%’ */ width?: number | string; /** * Color of the line * @default 'hsl(var(–primary))’ */ lineColor?: string; /** * Color of the area under the line * @default 'hsl(var(–primary) / 0.2)’ */ areaColor?: string; /** * Whether to show the grid * @default true */ showGrid?: boolean; /** * Whether to show the tooltip * @default true */ showTooltip?: boolean; /** * Key to use for the x-axis * @default 'date’ */ xAxisKey?: string; /** * Key to use for the y-axis * @default 'value’ */ yAxisKey?: string; /** * Text prefix for values * @default ” */ valuePrefix?: string; /** * Text suffix for values * @default ” */ valueSuffix?: string; /** * Value formatter */ valueFormatter?: (value: number) => string; } /** * Time series chart component using Recharts */ export function TimeSeriesChart({ data, height = 300, width = „100%”, lineColor = „hsl(var(–primary))”, areaColor = „hsl(var(–primary) / 0.2)”, showGrid = true, showTooltip = true, xAxisKey = „date”, yAxisKey = „value”, valuePrefix = „”, valueSuffix = „”, valueFormatter, }: TimeSeriesChartProps) { // Format the value for display const formatValue = (value: number) => { if (valueFormatter) { return valueFormatter(value); } return `${valuePrefix}${value}${valueSuffix}`; }; return ( {showGrid && ( )} {showTooltip && ( { if (active && payload && payload.length) { return ( {payload[0].payload[xAxisKey]} {formatValue(payload[0].value as number)} ); } return null; }} /> )} ); } End File# myrta-ds/myrta import { Badge } from „@/components/ui/badge”; import { Button } from „@/components/ui/button”; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from „@/components/ui/table”; import { PerformanceResource } from „@/lib/definitions”; import { formatBytes } from „@/lib/utils”; import { ArrowDownIcon, ArrowUpIcon, SortIcon } from „lucide-react”; import { useState } from „react”; type SortDirection = „asc” | „desc” | null; interface ResourcesTableProps { resources: PerformanceResource[]; } export default function ResourcesTable({ resources }: ResourcesTableProps) { const [sortField, setSortField] = useState(„loadTime”); const [sortDirection, setSortDirection] = useState(„desc”); const handleSort = (field: string) => { if (sortField === field) { if (sortDirection === „asc”) { setSortDirection(„desc”); } else if (sortDirection === „desc”) { setSortDirection(null); setSortField(null); } else { setSortDirection(„asc”); } } else { setSortField(field); setSortDirection(„asc”); } }; const getSortIcon = (field: string) => { if (sortField !== field) { return ; } if (sortDirection === „asc”) { return ; } if (sortDirection === „desc”) { return ; } return ; }; // Sort resources based on current sort settings const sortedResources = […resources].sort((a, b) => { if (!sortField || !sortDirection) return 0; let valueA, valueB; switch (sortField) { case „name”: valueA = a.name.toLowerCase(); valueB = b.name.toLowerCase(); break; case „type”: valueA = a.type.toLowerCase(); valueB = b.type.toLowerCase(); break; case „size”: valueA = a.size; valueB = b.size; break; case „transferSize”: valueA = a.transferSize; valueB = b.transferSize; break; case „loadTime”: valueA = a.loadTime; valueB = b.loadTime; break; default: return 0; } if (sortDirection === „asc”) { return valueA > valueB ? 1 : -1; } else { return valueA { switch (type.toLowerCase()) { case „script”: return Script; case „stylesheet”: return CSS; case „image”: return Image; case „font”: return Font; case „fetch”: return API; default: return {type}; } }; return ( handleSort(„name”)} className=”px-0 font-medium” > Resource Name {getSortIcon(„name”)} handleSort(„type”)} className=”px-0 font-medium” > Type {getSortIcon(„type”)} handleSort(„size”)} className=”px-0 font-medium” > Size {getSortIcon(„size”)} handleSort(„transferSize”)} className=”px-0 font-medium” > Transfer {getSortIcon(„transferSize”)} handleSort(„loadTime”)} className=”px-0 font-medium” > Load Time {getSortIcon(„loadTime”)} Status {sortedResources.map((resource) => ( {resource.name} {getResourceTypeBadge(resource.type)} {formatBytes(resource.size)} {formatBytes(resource.transferSize)} 500 ? „text-red-500” : resource.loadTime > 300 ? „text-amber-500” : „text-green-500” } > {resource.loadTime}ms {!resource.optimized ? ( Needs Optimization ) : ( Optimized )} ))} Przewiń tabelę w poziomie ←→ ); } End File# myrta-ds/myrta import React from 'react’; import { Card, CardBody, CardHeader, CardTitle } from '@/components/ui/card’; import { Badge } from '@/components/ui/badge’; import { LogData } from '@/lib/definitions’; import { getSeverityClass, formatLogDate } from '@/lib/utils’; import { Button } from '@/components/ui/button’; import { CopyIcon } from 'lucide-react’; import copyToClipboard from '@/lib/clipboard’; interface LogMessageCardProps { log: LogData; isSelected?: boolean; onClick?: () => void; } const LogMessageCard: React.FC = ({ log, isSelected = false, onClick }) => { const handleCopy = (e: React.MouseEvent) => { e.stopPropagation(); copyToClipboard(`${log.timestamp} [${log.severity}] ${log.message}`); }; return ( {formatLogDate(log.timestamp)} • {log.source} {log.severity} {log.message.split(’n’)[0]} {log.message} {log.stackTrace && ( {log.stackTrace.split(’n’)[0]} {log.stackTrace.split(’n’).length > 1 && '…’} )} ); }; export default LogMessageCard; End File’use client’ import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable, SortingState, ColumnFiltersState, getFilteredRowModel, getSortedRowModel, } from „@tanstack/react-table” import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from „@/components/ui/table” import { Button } from „@/components/ui/button” import { useState } from 'react’ import { Input } from '@/components/ui/input’ interface DataTableProps { /** * Column definitions for the table */ columns: ColumnDef[] /** * Data to display in the table */ data: TData[] /** * Whether to show search functionality * @default true */ showSearch?: boolean /** * The column key to search on */ searchKey?: string /** * Placeholder text for the search input * @default „Search…” */ searchPlaceholder?: string /** * Whether to show pagination controls * @default true */ showPagination?: boolean /** * The number of rows per page * @default 10 */ pageSize?: number } /** * A reusable data table component with sorting, filtering, and pagination */ export function DataTable({ columns, data, showSearch = true, searchKey, searchPlaceholder = „Search…”, showPagination = true, pageSize = 10, }: DataTableProps) { const [sorting, setSorting] = useState([]) const [columnFilters, setColumnFilters] = useState([]) const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), state: { sorting, columnFilters, }, initialState: { pagination: { pageSize, }, }, }) return ( {/* Search input */} {showSearch && searchKey && ( table.getColumn(searchKey)?.setFilterValue(event.target.value) } className=”max-w-sm” /> )} {/* Table */} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} ) })} ))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} )) ) : ( No results. )} Przewiń tabelę w poziomie ←→ {/* Pagination */} {showPagination && ( table.previousPage()} disabled={!table.getCanPreviousPage()} > Previous table.nextPage()} disabled={!table.getCanNextPage()} > Next )} ) } End File# myrta-ds/myrta import * as THREE from 'three’; import { useRef, useState, useEffect } from 'react’; import { Canvas, useFrame } from '@react-three/fiber’; import { useGLTF, useTexture, Environment, Float, MeshReflectorMaterial } from '@react-three/drei’; import { EffectComposer, Bloom } from '@react-three/postprocessing’; import { easing } from 'maath’; export function MeshStage({ children, color, …props }) { // Reflective floor setup const [hovered, setHovered] = useState(false); return ( (e.stopPropagation(), setHovered(true))} onPointerOut={() => setHovered(false)}> {children} ); } export function RoundedBox({ position = [0, 0, 0], args = [1, 1, 1], radius = 0.1, …props }) { const mesh = useRef(); const [hovered, setHovered] = useState(false); useFrame((state, delta) => { easing.dampC(mesh.current.material.color, hovered ? '#40a0ff’ : '#ff6080′, 0.1, delta); mesh.current.rotation.x = mesh.current.rotation.y += delta / 4; }); return ( setHovered(true)} onPointerOut={() => setHovered(false)} castShadow receiveShadow {…props} > ); } export function Shoe({ position = [0, 0, 0], rotation = [0, 0, 0], …props }) { const mesh = useRef(); const { nodes, materials } = useGLTF(’/models/shoe.glb’); useFrame((state, delta) => { const t = state.clock.elapsedTime; mesh.current.rotation.set( Math.cos(t / 4) / 8, Math.sin(t / 3) / 4, Math.sin(t / 2) / 8 ); mesh.current.position.y = (1 + Math.sin(t / 2)) / 3; }); return ( ); } export function WatchModel({ position = [0, 0, 0], rotation = [0, 0, 0], …props }) { const mesh = useRef(); const [hovered, setHovered] = useState(false); const { nodes, materials } = useGLTF(’/models/watch.glb’); useFrame((state, delta) => { const t = state.clock.elapsedTime; mesh.current.rotation.x = Math.cos(t / 2) / 8; mesh.current.rotation.y = Math.sin(t / 4) * 0.5; easing.dampC(materials.glass.color, hovered ? '#40a0ff’ : '#74b9ff’, 0.2, delta); easing.dampC(materials.body.color, hovered ? '#303030′ : '#202020′, 0.2, delta); }); return ( setHovered(true)} onPointerOut={() => setHovered(false)} {…props} dispose={null} > ); } export function PhoneModel({ position = [0, 0, 0], rotation = [0, 0, 0], …props }) { const mesh = useRef(); const [hovered, setHovered] = useState(false); const texture = useTexture(’/textures/screen.jpg’); useEffect(() => { texture.flipY = false; }, [texture]); useFrame((state, delta) => { const t = state.clock.elapsedTime; mesh.current.rotation.x = Math.cos(t / 3) / 10; mesh.current.rotation.y = Math.sin(t / 2) * 0.3; }); return ( setHovered(true)} onPointerOut={() => setHovered(false)} {…props} > {/* Phone Body */} {/* Screen */} {/* Camera Module */} ); } export function LaptopModel({ position = [0, 0, 0], rotation = [0, 0, 0], …props }) { const mesh = useRef(); const [hovered, setHovered] = useState(false); const screenTexture = useTexture(’/textures/laptop-screen.jpg’); useEffect(() => { screenTexture.flipY = false; }, [screenTexture]); useFrame((state, delta) => { const t = state.clock.elapsedTime; mesh.current.rotation.x = Math.cos(t / 4) / 10; mesh.current.rotation.y = Math.sin(t / 3) * 0.2; }); return ( setHovered(true)} onPointerOut={() => setHovered(false)} {…props} > {/* Base */} {/* Keyboard Area */} {/* Trackpad */} {/* Screen Housing */} {/* Screen */} {/* Logo on back */} ); } useGLTF.preload(’/models/shoe.glb’); useGLTF.preload(’/models/watch.glb’); End File# myrta-ds/myrta # apps/docs/app/components/design-system/color-palette.tsx import * as React from 'react’; import { Label } from '@/components/ui/label’; import { getContrast } from 'polished’; interface ColorSwatch { colorName: string; colorValue: string; isDark: boolean; } interface ColorPaletteProps { /** * The colors to display * Format: { colorName: colorValue } */ colors: Record; } export function ColorPalette({ colors }: ColorPaletteProps) { const swatches = React.useMemo(() => { return Object.entries(colors).map(([colorName, colorValue]) => { // Calculate if text should be white or black const contrastWithWhite = getContrast(colorValue, '#FFFFFF’); const isDark = contrastWithWhite >= 3; // WCAG AA for normal text return { colorName, colorValue, isDark, }; }); }, [colors]); const sortedSwatches = React.useMemo(() => { return […swatches].sort((a, b) => { // Extract numbers like „50”, „100”, „200” from colorName const valueA = parseInt((a.colorName.match(/d+/) || [0])[0], 10); const valueB = parseInt((b.colorName.match(/d+/) || [0])[0], 10); return valueA – valueB; }); }, [swatches]); return ( {sortedSwatches.map((swatch) => ( ))} ); } /** * Individual color swatch component */ function ColorSwatch({ colorName, colorValue, isDark }: ColorSwatch) { return ( {colorValue} {colorName} ); } /** * Color grid to display a set of colors */ export function ColorGrid({ children, className, }: { children: React.ReactNode; className?: string; }) { return ( {children} ); } /** * Color grid item */ export function ColorGridItem({ color, name, className, }: { color: string; name: string; className?: string; }) { // Calculate if text should be white or black const contrastWithWhite = getContrast(color, '#FFFFFF’); const isDark = contrastWithWhite >= 3; // WCAG AA for normal text return ( {color} {name} ); } End File”use client”; import React, { useState } from „react”; import { Tabs, TabsContent, TabsList, TabsTrigger } from „@/components/ui/tabs”; import { Copy } from „lucide-react”; import { Button } from „@/components/ui/button”; import { cn } from „@/lib/utils”; interface CodeBlockProps { /** * The code to display */ code: string; /** * The language of the code (for syntax highlighting) * @default „tsx” */ language?: string; /** * The file name to display */ fileName?: string; /** * Disable syntax highlighting * @default false */ disableSyntaxHighlighting?: boolean; /** * Show line numbers * @default false */ showLineNumbers?: boolean; /** * Hide copy button * @default false */ hideCopyButton?: boolean; /** * Additional HTML attributes for the code element */ codeProps?: React.HTMLAttributes; /** * Alternative code examples in different languages */ examples?: { language: string; code: string; fileName?: string; }[]; } /** * Code block component for displaying code snippets with syntax highlighting */ export function CodeBlock({ code, language = „tsx”, fileName, disableSyntaxHighlighting = false, showLineNumbers = false, hideCopyButton = false, codeProps, examples = [], }: CodeBlockProps) { const [copied, setCopied] = useState(false); const allExamples = [ { language, code, fileName, }, …examples, ]; const handleCopy = async (codeToСopy: string) => { await navigator.clipboard.writeText(codeToСopy); setCopied(true); setTimeout(() => setCopied(false), 2000); }; return ( {/* File name header, if provided */} {allExamples.length === 1 && fileName && ( {fileName} {!hideCopyButton && ( handleCopy(code)} > Copy code )} )} {/* Multiple examples tabs */} {allExamples.length > 1 ? ( {allExamples.map((example, i) => ( {example.language.toUpperCase()} ))} {!hideCopyButton && ( handleCopy(code)} > Copy code )} {allExamples.map((example, i) => ( {example.code} ))} ) : ( {/* Single code block */} {code} {/* Copy button at the top right if no filename */} {!fileName && !hideCopyButton && ( handleCopy(code)} > Copy code )} )} ); } End File# myrta-ds/myrta # apps/docs/app/components/docs/installation-steps.tsx „use client”; import { Tabs, TabsContent, TabsList, TabsTrigger } from „@/components/ui/tabs”; import { CodeBlock } from „./code-block”; // Package manager tab content const packageManagers = [ { name: „npm”, installCmd: „npm install @myrta-ds/core @myrta-ds/react”, peerCmd: „npm install react react-dom”, }, { name: „yarn”, installCmd: „yarn add @myrta-ds/core @myrta-ds/react”, peerCmd: „yarn add react react-dom”, }, { name: „pnpm”, installCmd: „pnpm add @myrta-ds/core @myrta-ds/react”, peerCmd: „pnpm add react react-dom”, }, ]; // Component import examples const importExamples = { typescript: `import { Button } from '@myrta-ds/react’; export default function App() { return ( Click me ); }`, javascript: `import { Button } from '@myrta-ds/react’; export default function App() { return ( Click me ); }`, }; // CSS setup examples const cssSetupExamples = { typescript: `// In your entry file (e.g., main.tsx, index.tsx, or app.tsx) import '@myrta-ds/core/styles.css’; import { App } from ’./App’; // Rest of your application setup…`, javascript: `// In your entry file (e.g., main.js, index.js, or app.js) import '@myrta-ds/core/styles.css’; import { App } from ’./App’; // Rest of your application setup…`, }; export default function InstallationSteps() { return ( 1. Install the packages {packageManagers.map((pm) => ( {pm.name} ))} {packageManagers.map((pm) => ( Myrta DS has peer dependencies on React and React DOM: ))} 2. Import the CSS Import the CSS file in your main entry file to apply global styles: TypeScript JavaScript 3. Use components Now you can import and use components in your application: TypeScript JavaScript ); } End File”use client”; import * as React from „react”; import Link from „next/link”; import { usePathname } from „next/navigation”; import { cn } from „@/lib/utils”; import { ChevronRight } from „lucide-react”; export interface SidebarNavProps extends React.HTMLAttributes { items: { title: string; href?: string; disabled?: boolean; links?: { title: string; href: string; disabled?: boolean; }[]; }[]; } /** * SidebarNav component for documentation sidebar */ export function SidebarNav({ className, items, …props }: SidebarNavProps) { const pathname = usePathname(); // Track expanded sections const [expandedSections, setExpandedSections] = React.useState([]); // Check if section should be initially expanded const initializeExpandedSections = React.useCallback(() => { const initialExpanded: string[] = []; items.forEach((item) => { if ( item.links?.some((link) => pathname.startsWith(link.href)) || pathname === item.href ) { initialExpanded.push(item.title); } }); setExpandedSections(initialExpanded); }, [items, pathname]); // Initialize expanded sections on mount and pathname change React.useEffect(() => { initializeExpandedSections(); }, [initializeExpandedSections, pathname]); // Toggle a section’s expanded state const toggleSection = (title: string) => { setExpandedSections((prev) => prev.includes(title) ? prev.filter((item) => item !== title) : […prev, title] ); }; return ( {items.map((item) => { const isExpanded = expandedSections.includes(item.title); const hasLinks = item.links && item.links.length > 0; return ( {/* Section title/link */} {hasLinks ? ( toggleSection(item.title)} className={cn( „w-full flex items-center justify-between px-3 py-2 text-sm font-medium rounded-md”, item.disabled && „cursor-not-allowed opacity-60”, pathname === item.href ? „bg-muted text-foreground” : „hover:bg-muted/50 hover:text-foreground” )} disabled={item.disabled} > {item.title} ) : ( {item.title} )} {/* Section links (if expanded) */} {hasLinks && isExpanded && ( {item.links?.map((link) => ( {link.title} ))} )} ); })} ); } End File# apps/docs/app/components/docs/api-tbl.tsx /** * API table component for documenting props and other APIs */ import React from 'react’; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table’; import { Badge } from '@/components/ui/badge’; interface ApiTableProps { /** * The data to display in the table */ data: ApiItem[]; /** * Optional table caption/title */ caption?: string; /** * The name of the interface being documented */ interfaceName?: string; /** * Whether to display default values * @default true */ showDefaults?: boolean; } export interface ApiItem { /** * Property name */ name: string; /** * Property type (e.g. string, number, boolean) */ type: string; /** * Default value (if any) */ default?: string; /** * Property description */ description: string; /** * Whether the property is required * @default false */ required?: boolean; } export function ApiTable({ data, caption, interfaceName, showDefaults = true, }: ApiTableProps) { return ( {caption && {caption}} Prop Type {showDefaults && Default} Description {data.map((item) => ( {item.name} {item.required && Required} {formatType(item.type)} {showDefaults && ( {item.default !== undefined ? formatDefaultValue(item.default) : ’-’} )} {item.description} ))} Przewiń tabelę w poziomie ←→ ); } /** * Format a type string for display */ function formatType(type: string): React.ReactNode { // Highlight certain types if ([’string’, 'number’, 'boolean’, 'any’, 'void’].includes(type)) { return {type}; } // Format union types if (type.includes(’|’)) { return type.split(’|’).map((t, i) => ( {i > 0 && ’ | ’} {t.trim()} )); } return type; } /** * Format a default value for display */ function formatDefaultValue(value: string): React.ReactNode { // Boolean values if (value === 'true’ || value === 'false’) { return {value}; } // Numbers if (!isNaN(Number(value))) { return {value}; } // Strings (quoted) if ((value.startsWith(’”’) && value.endsWith(’”’)) || (value.startsWith(„’”) && value.endsWith(„’”))) { return {value}; } return value; } End File# myrta-ds/myrta import { MetadataRoute } from 'next’; interface SitemapEntry { url: string; lastModified?: string | Date; changeFrequency?: 'always’ | 'hourly’ | 'daily’ | 'weekly’ | 'monthly’ | 'yearly’ | 'never’; priority?: number; } export default function sitemap(): MetadataRoute.Sitemap { const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://myrta-ds.vercel.app’; const pages: SitemapEntry[] = [ { url: baseUrl, lastModified: new Date(), changeFrequency: 'monthly’, priority: 1, }, ]; // Documentation pages const docsPaths = [ // Getting started '/docs/introduction’, '/docs/installation’, '/docs/theming’, '/docs/roadmap’, // Foundation '/docs/foundation/colors’, '/docs/foundation/typography’, '/docs/foundation/spacing’, '/docs/foundation/breakpoints’, // Components '/docs/components/accordion’, '/docs/components/avatar’, '/docs/components/badge’, '/docs/components/button’, '/docs/components/card’, '/docs/components/checkbox’, '/docs/components/dialog’, '/docs/components/icon’, '/docs/components/menu’, '/docs/components/radio’, '/docs/components/select’, '/docs/components/slider’, '/docs/components/spinner’, '/docs/components/table’, '/docs/components/tabs’, '/docs/components/textfield’, '/docs/components/toggle’, '/docs/components/tooltip’, // Examples '/examples/dashboard’, '/examples/landing’, ]; const docsEntries = docsPaths.map(path => ({ url: `${baseUrl}${path}`, lastModified: new Date(), changeFrequency: 'weekly’ as const, priority: 0.8, })); // Legal and auxiliary pages const auxiliaryPaths = [ '/examples’, '/docs’, '/changelog’, ]; const auxiliaryEntries = auxiliaryPaths.map(path => ({ url: `${baseUrl}${path}`, lastModified: new Date(), changeFrequency: 'monthly’ as const, priority: 0.5, })); // Combine all entries return [ …pages, …docsEntries, …auxiliaryEntries, ]; } End Fileimport { Metadata } from 'next’; import { ScrollArea } from '@/components/ui/scroll-area’; import { Products } from '@/app/examples/landing/components/Products’; import { Hero } from '@/app/examples/landing/components/Hero’; import { Features } from '@/app/examples/landing/components/Features’; import { Brands } from '@/app/examples/landing/components/Brands’; import { Faq } from '@/app/examples/landing/components/Faq’; import { Testimonials } from '@/app/examples/landing/components/Testimonials’; import { Pricing } from '@/app/examples/landing/components/Pricing’; import { Statistics } from '@/app/examples/landing/components/Statistics’; import { Footer } from '@/app/examples/landing/components/Footer’; import { Header } from '@/app/examples/landing/components/Header’; export const metadata: Metadata = { title: 'Landing Page Example | Myrta Design System’, description: 'Example landing page built with Myrta Design System components’, }; export default function LandingPage() { return ( ); } End File# myrta-ds/myrta # apps/docs/app/examples/landing/components/Hero.tsx 'use client’; import { Button } from '@/components/ui/button’; import Link from 'next/link’; import { ArrowRight, Play } from 'lucide-react’; import { motion } from 'framer-motion’; import { MeshStage, RoundedBox } from '@/components/3d/mesh-stage’; import Image from 'next/image’; export function Hero() { return ( {/* Left column – Text content */} Next Generation Platform Kolejne rozdziały .ticss-13062101 { margin-top:30px; } Zapraszamy do dalszego czytania naszego leksykonu. Wybierz kolejny rozdział z menu poniżej, aby otworzyć nową podstronę kompedium wiedzy i uzyskać szczegółowe informację o leku, substancji lub chorobie. .ticss-b75d3ce7 { text-indent:0 !important; } Dawkowanie i sposób podawaniaDziałania niepożądaneInterakcje lekuProfil bezpieczeństwa lekuPrzeciwwskazaniaPrzedawkowaniePrzedkliniczne dane o bezpieczeństwieSkład i postać lekuSpecjalne ostrzeżeniaWłaściwości farmakodynamiczneWłaściwości farmakokinetyczneWpływ na płodność, ciążę i laktacjęWpływ na zdolność prowadzenia pojazdów i obsługiwania maszynWskazania do stosowania .ticss-a64d1df6 { /*break-inside: avoid;*/ } .ticss-88b9ebd1 { margin-top:30px; }
- {title} {icon || }
- 1. Install the packages
- 2. Import the CSS
- 3. Use components
Specjalne ostrzeżenia i środki ostrożności dotyczące stosowania
Stosowanie produktu leczniczego Lactulosum Polfarmex wymaga szczególnej uwagi w określonych sytuacjach klinicznych. Poniżej przedstawiono szczegółowe informacje dotyczące środków ostrożności oraz potencjalnych zagrożeń związanych z terapią tym lekiem.1
Monitorowanie elektrolitów podczas długotrwałej terapii
W przypadku przedłużonego stosowania laktulozy, szczególnie u pacjentów geriatrycznych, należy regularnie kontrolować stężenie elektrolitów w osoczu, ze szczególnym uwzględnieniem potasu i chlorków. Monitorowanie to pozwala na wczesne wykrycie potencjalnych zaburzeń elektrolitowych wynikających z długotrwałego działania przeczyszczającego.2
Substancje pomocnicze o znanym działaniu
Produkt leczniczy Lactulosum Polfarmex zawiera szereg substancji pomocniczych, które mogą wywierać istotny wpływ kliniczny u określonych grup pacjentów. Substancje te obejmują: etanol (składnik aromatu), fruktozę, laktozę, galaktozę, siarczyny oraz butylohydroksyanizol.3
Zawartość etanolu
W skład produktu wchodzi aromat mangora, płynny, zawierający etanol (alkohol). W każdych 15 ml syropu znajduje się 0,00963 g alkoholu etylowego.4
Zawartość etanolu w poszczególnych dawkach terapeutycznych przedstawia się następująco:
| Grupa pacjentów | Wskazanie | Dawka | Zawartość etanolu | Ekwiwalent alkoholu |
|---|---|---|---|---|
| Dorośli | Działanie przeczyszczające | 45 ml syropu | 29,07 mg | mniej niż 1 ml piwa lub 1 ml wina |
| 15 ml syropu | 9,63 mg | mniej niż 1 ml piwa lub 1 ml wina | ||
| Niewydolność wątroby | 180 ml syropu | 116,28 mg | mniej niż 3 ml piwa i 2 ml wina | |
| Niemowlęta | Działanie przeczyszczające | 2,5 ml syropu | 1,615 mg | mniej niż 1 ml piwa lub 1 ml wina |
| Dzieci do 3 lat | 5 ml syropu | 3,23 mg | mniej niż 1 ml piwa lub 1 ml wina | |
| Dzieci powyżej 3 lat | 15 ml syropu | 9,69 mg | mniej niż 1 ml piwa lub 1 ml wina |
Należy podkreślić, że ilość alkoholu obecna w preparacie jest tak niewielka, że nie powoduje klinicznie istotnych efektów farmakologicznych.5
Zawartość fruktozy
Produkt zawiera 0,075 g fruktozy w każdych 15 ml syropu. W maksymalnej dawce dobowej zawartość fruktozy sięga 0,9 g. Należy mieć na uwadze, że fruktoza zawarta w syropie może wywierać niekorzystny wpływ na zęby, szczególnie przy długotrwałym stosowaniu.6
Zawartość laktozy
Każde 15 ml syropu zawiera 0,75 g laktozy, co daje 9 g laktozy w maksymalnej dawce dobowej. Ta informacja jest szczególnie istotna dla pacjentów z cukrzycą, u których należy uwzględnić zawartość laktozy w bilansie węglowodanów.7
Produkt jest przeciwwskazany u pacjentów z rzadko występującą dziedziczną nietolerancją galaktozy, brakiem laktazy lub zespołem złego wchłaniania glukozy-galaktozy ze względu na zawartość laktozy.8
Zawartość galaktozy
Produkt leczniczy zawiera 1,125 g galaktozy w każdych 15 ml syropu, co przekłada się na 13,5 g galaktozy w maksymalnej dawce dobowej. Z tego powodu produkt nie powinien być stosowany u pacjentów z rzadkimi dziedzicznymi zaburzeniami związanymi z nietolerancją galaktozy, takimi jak galaktozemia czy zespół złego wchłaniania glukozy-galaktozy.9
Zawartość siarczyn # kevinkirbyuk/Opta-F24-Data-Parser
# OptaParser.py
import json
import os
import time
import re
import pandas as pd
from time import sleep
def preprocess_text_to_valid_json(text):
„””
Ensure the text is valid JSON by handling common syntax errors.
Parameters:
– text (str): The JSON text to be preprocessed
Returns:
– str: Preprocessed JSON text that should be valid
„””
# Common preprocessing steps
# Remove trailing commas in arrays and objects
text = re.sub(r’,s*([}]])’, r’1′, text)
# Ensure properties are in double quotes
text = re.sub(r'([{,])s*([a-zA-Z0-9_]+):’, r’1″2″:’, text)
# Replace single quotes with double quotes for string values
text = re.sub(r”'([^’]*)’”, r’”1″’, text)
return text
def read_opta_files(filenames):
„””
Read multiple Opta F24 XML files and store them in a list.
Parameters:
– filenames (List[str]): List of Opta F24 filenames
Returns:
– List: List of parsed XML file contents
„””
opta_files = []
for filename in filenames:
# Skip if file doesn’t exist or is directory
if not os.path.isfile(filename):
print(f”Skipping {filename}: file not found.”)
continue
# Read the file
with open(filename, 'r’, encoding=’utf-8′) as file:
opta_files.append(file.read())
return opta_files
def parse_opta_f24_to_json(xml_string):
„””
Parse Opta F24 XML data into JSON format.
Parameters:
– xml_string (str): Opta F24 XML data as string
Returns:
– dict: Parsed data in JSON format
„””
# Extract necessary components
match_pattern = r’
match_time = re.search(match_pattern, xml_string)
timestamp = match_time.group(1) if match_time else „”
game_pattern = r’
match = re.search(game_pattern, xml_string)
if not match:
print(„Failed to extract game information.”)
return None # Or handle the error in a way that makes sense for your application
game_id = match.group(1)
away_team_id = match.group(2)
away_team_name = match.group(3)
competition_id = match.group(4)
competition_name = match.group(5)
game_date = match.group(6)
home_team_id = match.group(7)
home_team_name = match.group(8)
matchday = match.group(9)
period_1_start = match.group(10)
period_2_start = match.group(11)
season_id = match.group(12)
season_name = match.group(13)
# Extract all event data
pattern = r’
r’.*?’
events = []
for match in re.finditer(pattern, xml_string):
event_id = match.group(1)
type_id = match.group(3)
period_id = match.group(4)
minute = match.group(5)
second = match.group(6)
team_id = match.group(7)
outcome = match.group(8)
x_pos = match.group(9)
y_pos = match.group(10)
event_timestamp = match.group(11)
# Extract qualifiers for this event
qualifier_pattern = r’’
qualifiers = []
for q_match in re.finditer(qualifier_pattern, match.group(0)):
q_id = q_match.group(1)
qualifier_id = q_match.group(2)
value = q_match.group(3)
qualifiers.append({
„id”: q_id,
„qualifier_id”: qualifier_id,
„value”: value
})
# Create event object
event = {
„id”: event_id,
„type_id”: type_id,
„period_id”: period_id,
„minute”: minute,
„second”: second,
„team_id”: team_id,
„outcome”: outcome,
„x”: x_pos,
„y”: y_pos,
„timestamp”: event_timestamp,
„qualifiers”: qualifiers
}
events.append(event)
# Create the final JSON structure
json_data = {
„OptaFeed”: {
„timestamp”: timestamp,
„Game”: {
„id”: game_id,
„away_team_id”: away_team_id,
„away_team_name”: away_team_name,
„competition_id”: competition_id,
„competition_name”: competition_name,
„game_date”: game_date,
„home_team_id”: home_team_id,
„home_team_name”: home_team_name,
„matchday”: matchday,
„period_1_start”: period_1_start,
„period_2_start”: period_2_start,
„season_id”: season_id,
„season_name”: season_name,
„Events”: events
}
}
}
return json_data
def save_to_json(data, filename):
„””
Save data as JSON to a file.
Parameters:
– data (dict): The data to save
– filename (str): The output filename
Returns:
– bool: True if successful, False otherwise
„””
try:
with open(filename, 'w’, encoding=’utf-8′) as file:
json.dump(data, file, indent=4)
return True
except Exception as e:
print(f”Error saving JSON to {filename}: {e}”)
return False
def extract_match_info(json_data):
„””
Extract match information from Opta F24 JSON data.
Parameters:
– json_data (dict): Parsed Opta F24 data
Returns:
– dict: Extracted match information
„””
game = json_data[„OptaFeed”][„Game”]
return {
„match_id”: game[„id”],
„home_team_id”: game[„home_team_id”],
„home_team”: game[„home_team_name”],
„away_team_id”: game[„away_team_id”],
„away_team”: game[„away_team_name”],
„competition”: game[„competition_name”],
„season”: game[„season_name”],
„date”: game[„game_date”]
}
def events_to_dataframe(json_data):
„””
Convert Opta F24 events from JSON to a pandas DataFrame.
Parameters:
– json_data (dict): Parsed Opta F24 data
Returns:
– DataFrame: Events data in tabular format
„””
events = json_data[„OptaFeed”][„Game”][„Events”]
match_info = extract_match_info(json_data)
# Initialize an empty list to store flattened events
flattened_events = []
for event in events:
# Start with basic event information
flat_event = {
„event_id”: event[„id”],
„type_id”: event[„type_id”],
„period_id”: event[„period_id”],
„minute”: event[„minute”],
„second”: event[„second”],
„team_id”: event[„team_id”],
„outcome”: event[„outcome”],
„x”: event[„x”],
„y”: event[„y”],
„timestamp”: event[„timestamp”],
„match_id”: match_info[„match_id”],
„home_team”: match_info[„home_team”],
„away_team”: match_info[„away_team”],
„competition”: match_info[„competition”],
„season”: match_info[„season”],
„date”: match_info[„date”]
}
# Add team information
if event[„team_id”] == match_info[„home_team_id”]:
flat_event[„team”] = match_info[„home_team”]
elif event[„team_id”] == match_info[„away_team_id”]:
flat_event[„team”] = match_info[„away_team”]
# Add qualifiers as separate columns
for qualifier in event[„qualifiers”]:
qualifier_key = f”q{qualifier[’qualifier_id’]}”
flat_event[qualifier_key] = qualifier[„value”]
flattened_events.append(flat_event)
# Convert to DataFrame
df = pd.DataFrame(flattened_events)
return df
def main(filenames, output_format=”json”):
„””
Main function to process Opta F24 files.
Parameters:
– filenames (List[str]): List of Opta F24 filenames to process
– output_format (str): Output format (json or csv)
Returns:
– None: Results are saved to files
„””
# Read in all the specified files
opta_files = read_opta_files(filenames)
# Process each file
for i, file_content in enumerate(opta_files):
try:
# Parse XML to JSON
json_data = parse_opta_f24_to_json(file_content)
if json_data:
# Extract match info for filename
match_info = extract_match_info(json_data)
base_filename = f”{match_info[’home_team’]}_{match_info[’away_team’]}_{match_info[’date’]}”
base_filename = base_filename.replace(” „, „_”).replace(„/”, „-„)
# Save in requested format(s)
if output_format.lower() == „json” or output_format.lower() == „both”:
json_filename = f”{base_filename}.json”
save_to_json(json_data, json_filename)
print(f”Saved {json_filename}”)
if output_format.lower() == „csv” or output_format.lower() == „both”:
# Convert to DataFrame and save as CSV
df = events_to_dataframe(json_data)
csv_filename = f”{base_filename}.csv”
df.to_csv(csv_filename, index=False)
print(f”Saved {csv_filename}”)
else:
print(f”Failed to parse file {filenames[i]}”)
except Exception as e:
print(f”Error processing {filenames[i]}: {e}”)
print(„Processing complete.”)
if __name__ == „__main__”:
# Example usage:
filenames = [„f24-20-2019-2245250-eventdetails.xml”]
main(filenames, output_format=”both”) # Save as both JSON and CSV
# serbogdanov/project
service = $service;
$this->repository = $repository;
$this->contactRepository = $contactRepository;
$this->appointmentRepository = $appointmentRepository;
}
/**
* @param Request $request
* @return IlluminateContractsViewFactory|IlluminateViewView
*/
public function index(Request $request)
{
$sort_by = $request->get(’sort_by’, 'created_at’);
$sort = $request->get(’sort’, 'desc’);
$search = trim($request->get(’search’));
$user = Auth::user();
$per_page = config(’project.per_page’);
$hasPermission = auth()->user()->hasPermission(’patient’);
$patients = $this->repository->getUserWithContactByRole(’patient’, $sort_by, $sort, $search, $per_page, false);
return view(’users::patient.index’, compact(’patients’, 'user’, 'hasPermission’));
}
/**
* @return IlluminateContractsViewFactory|IlluminateViewView
*/
public function create()
{
$isCreate = true;
$doctors = $this->repository->getUsersByRole(’doctor’);
$treatments = $this->service->getPreDefinedData(’treatment’);
$bloodGroups = $this->service->getPreDefinedData(’blood_group’);
$statuses = $this->service->getPreDefinedData(’status’);
return view(’users::patient.create_edit’, compact(’doctors’, 'isCreate’, 'bloodGroups’, 'treatments’, 'statuses’));
}
/**
* @param Request $request
* @return IlluminateHttpRedirectResponse
*/
public function store(CreateUserRequest $request)
{
try {
$data = $request->all();
$this->service->createPatientContact($data);
return redirect()->route(’patients.index’)
->with(’success’, trans(’users::messages.record_was_successfully_created’));
} catch (Exception $e) {
return back()
->with(’error’, „Cannot save patient: „.$e->getMessage());
}
}
/**
* @param $id
* @return IlluminateContractsViewFactory|IlluminateViewView
*/
public function show($id)
{
$per_page = config(’project.per_page’);
$user = $this->service->findPatientById($id);
$doctors = $this->repository->getUsersByRole(’doctor’);
return view(’users::patient.view’, compact(’user’, 'doctors’));
}
/**
* @param $id
* @return IlluminateContractsViewFactory|IlluminateViewView
*/
public function edit($id)
{
$isCreate = false;
$user = $this->service->findPatientById($id);
$doctors = $this->repository->getUsersByRole(’doctor’);
$bloodGroups = $this->service->getPreDefinedData(’blood_group’);
$treatments = $this->service->getPreDefinedData(’treatment’);
$statuses = $this->service->getPreDefinedData(’status’);
return view(’users::patient.create_edit’, compact(’user’, 'doctors’, 'isCreate’, 'bloodGroups’, 'treatments’, 'statuses’));
}
/**
* @param Request $request
* @param $id
* @return IlluminateHttpRedirectResponse
*/
public function update(UpdateUserRequest $request, $id)
{
try {
$data = $request->all();
$this->service->updatePatientContact($id, $data);
return redirect()->route(’patients.index’)
->with(’success’, trans(’users::messages.record_was_successfully_updated’));
} catch (Exception $e) {
return back()
->with(’error’, „Cannot update patient: „.$e->getMessage());
}
}
/**
* @param $id
* @return IlluminateHttpRedirectResponse
*/
public function destroy($id)
{
try {
$this->repository->delete($id);
return back()
->with(’success’, trans(’users::messages.record_was_successfully_deleted’));
} catch (Exception $e) {
return back()
->with(’error’, trans(’users::messages.record_was_not_deleted_due_to_system_error’).$e->getMessage());
}
}
/**
* @param Request $request
* @param $id
* @return string
*/
public function events(Request $request, $id) {
$data = $this->appointmentRepository->getAppointmentByContactId($id);
return json_encode($data);
}
/**
* @param $id
* @return IlluminateHttpJsonResponse
*/
public function getPatient($id)
{
try {
$data = $this->contactRepository->findById($id, true);
return response()->json([
'status’ => 'success’,
'data’ => $data
]);
} catch(Exception $e) {
return response()->json([
'status’ => 'error’,
'error’ => $e->getMessage()
]);
}
}
/**
* Copy patient data
*
* @param IlluminateHttpRequest $request
* @return IlluminateHttpJsonResponse
*/
public function copyPatientData(Request $request)
{
try {
$data = $request->only(’patient_ids’);
$patientIds = !empty($data[’patient_ids’]) ? explode(’,’, $data[’patient_ids’]) : [];
if (empty($patientIds)) {
throw new Exception(’Please choose patient(s) for copy.’);
}
$patients = [];
foreach ($patientIds as $patientId) {
$patients[] = $this->service->findPatientById($patientId);
}
$data = [
'datas’ => $patients
];
$view = view(’users::patient.includes.patient_data_mail_table’, $data)->render();
return response()->json([
'status’ => 'success’,
'view’ => $view
]);
} catch(Exception $e) {
return response()->json([
'status’ => 'error’,
'error’ => $e->getMessage()
]);
}
}
public function patientStatus($id)
{
try {
$this->service->updatePatientStatus($id);
return back()
->with(’success’, trans(’users::messages.patient_status_successfully_updated’));
} catch (Exception $e) {
return back()
->with(’error’, 'Cannot update patient: ’.$e->getMessage());
}
}
}
End FileuserModel = $userModel;
$this->roleUserModel = $roleUserModel;
$this->roleModel = $roleModel;
$this->contactModel = $contactModel;
$this->fileEntityModel = $fileEntityModel;
}
/**
* get All User Except Current User
* @return IlluminateDatabaseEloquentCollection|static[]
*/
public function getAllUsers()
{
return $this->userModel->where(’id’, ’<>’, Auth::user()->id)->get();
}
/**
* get users by role
* @param string $roleName
* @return mixed
*/
public function getUsersByRole($roleName = 'user’)
{
$role = $this->roleModel->select(’id’)->where(’name’, $roleName)->first();
$userIds = $this->roleUserModel->select(’user_id’)->where(’role_id’, $role->id)->get()->pluck(’user_id’)->toArray();
return $this->userModel->whereIn(’id’, $userIds)->get();
}
/**
* @param String $search
* @return Collection
*/
public function searchUserName($search)
{
$users = DB::table(’users’)
->whereRaw(’CONCAT(first_name, ” „, last_name) like ?’, [„%{$search}%”])
->orderBy(’first_name’)
->orderBy(’last_name’)
->get();
$role = $this->roleModel->select(’id’)->where(’name’, 'patient’)->first();
return $users;
}
/**
* @param $userId
* @param bool $isDoctorView
* @return Contact|Contact[]|IlluminateDatabaseEloquentCollection|IlluminateDatabaseEloquentModel|null
*/
public function getUserContactProfileByUserId($userId, $isDoctorView = false) {
$role = $this->roleModel->select(’id’)->where(’name’, 'patient’)->first();
$userIds = $this->roleUserModel->select(’user_id’)->where(’role_id’, $role->id)->get()->pluck(’user_id’)->toArray();
if ($isDoctorView) {
return $this->contactModel
->whereIn(’user_id’, $userIds)
->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’)
->where(’assigned_dr_id’, $userId)
->get();
} else {
return $this->contactModel
->whereIn(’user_id’, $userIds)
->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’)
->get();
}
}
/**
* @param $userId
* @return mixed
*/
public function getUserById($userId)
{
return $this->userModel->where(’id’, $userId)->with(’profile’)->first();
}
/**
* @param $roleName
* @param string $sort_by
* @param string $sort
* @param string $search
* @param int $per_page
* @param bool $isDoctorAssigned
* @return IlluminateContractsPaginationLengthAwarePaginator
*/
public function getUserWithContactByRole($roleName, $sort_by = 'created_at’,
$sort = 'desc’, $search = ”, $per_page = 10, $isDoctorAssigned = false)
{
$role = $this->roleModel->select(’id’)->where(’name’, $roleName)->first();
$userIds = $this->roleUserModel->select(’user_id’)->where(’role_id’, $role->id)->get()->pluck(’user_id’)->toArray();
$query = $this->contactModel->whereIn(’user_id’, $userIds)
->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’);
if ($isDoctorAssigned === true) {
$query = $query->where(’assigned_dr_id’, Auth::user()->id);
}
if (!is_null($search) && !empty($search)) {
$query = $query->where(function ($q) use ($search) {
$q->where(’first_name’, 'like’, '%’.$search.’%’)
->orWhere(’last_name’, 'like’, '%’.$search.’%’)
->orWhere(’surname’, 'like’, '%’.$search.’%’)
->orWhere(’email’, 'like’, '%’.$search.’%’)
;
});
}
return $query->orderBy($sort_by, $sort)->paginate($per_page);
}
/**
* @param $id
* @return bool
*/
public function delete($id)
{
$user = $this->userModel->where(’id’, $id)->first();
return $user->delete();
}
/**
* @param $userId
* @param $entityId
* @param $category
* @return bool
*/
public function saveUserAvatar($userId, $entityId, $category)
{
$object = $this->fileEntityModel
->where(’entity_id’, $userId)
->where(’category’, $category)
->first();
if (is_null($object)) {
$this->fileEntityModel->entity_id = $userId;
$this->fileEntityModel->file_id = $entityId;
$this->fileEntityModel->category = $category;
return $this->fileEntityModel->save();
} else {
return $object
->where(’entity_id’, $userId)
->where(’category’, $category)
->update([’file_id’ => $entityId]);
}
}
/**
* @param $userId
* @param $category
* @return FileEntity
*/
public function getUserAvatar($userId, $category)
{
return $this->fileEntityModel
->where(’entity_id’, $userId)
->where(’category’, $category)
->first();
}
/**
* @param $roleId
* @return IlluminateDatabaseEloquentCollection|static[]
*/
public function findAllUsersByRoleId($roleId)
{
return $this->userModel
->join(’role_user’, 'role_user.user_id’, '=’, 'users.id’)
->select(’users.*’, 'role_user.created_at as user_role_created_at’)
->where(’role_user.role_id’, $roleId)
->get();
}
/**
* @return IlluminateDatabaseEloquentCollection|static[]
*/
public function getAdminUser()
{
return $this->userModel
->join(’role_user’, 'role_user.user_id’, '=’, 'users.id’)
->join(’roles’, 'roles.id’, '=’, 'role_user.role_id’)
->where(’role_user.role_id’, env(’ROLE_ADMIN_USER_ID’))
->select(’users.*’)
->get();
}
/**
* @param $userId
* @return Contact[]|IlluminateDatabaseEloquentCollection|IlluminateDatabaseEloquentModel|mixed|null
*/
public function getContactData($userId)
{
$contact = $this->contactModel
->where(’assigned_dr_id’, $userId)
->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’)
->get();
return $contact;
}
/**
* @param $doctor_ids
* @return Contact[]|IlluminateDatabaseEloquentCollection
*/
public function getReferredDoctorPatients($doctor_ids)
{
return $this->contactModel
->whereIn(’assigned_dr_id’, $doctor_ids)
->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’)
->get();
}
/**
* @param $user_ids
* @return mixed
*/
public function getContactsByUserIds($user_ids)
{
return $this->contactModel
->whereIn(’user_id’, $user_ids)
->with(’avatar’, 'user’, 'patient_doctor’, 'treatment’, 'appointments’)
->get();
}
}End FileuserRepository = $userRepository;
$this->roleRepository = $roleRepository;
$this->contactService = $contactService;
$this->preDefinedService = $preDefinedService;
}
/**
* @param $data
* @param $is_creating
* @return User
*/
public function createOrUpdate($data, $is_creating)
{
$user = new User();
if (!$is_creating) {
$user = User::find($data[’id’]);
}
if ($is_creating) {
$user->email = $data[’email’];
$user->first_name = $data[’first_name’];
$user->last_name = $data[’last_name’];
$user->password = Hash::make($data[’password’]);
$user->status = true;
} else {
if (isset($data[’password’]) && !empty($data[’password’])) {
$user->password = Hash::make($data[’password’]);
}
}
$user->save();
return $user;
}
/**
* @param $user
* @param $data
* @param $entityId
* @param $type
* @param bool $is_creating
* @return mixed
*/
public function saveRole($user, $data, $entityId, $type, $is_creating = true)
{
if (!empty($data[’roles’])) {
$roles = [];
foreach ($data[’roles’] as $roleId => $value) {
$roles[] = $roleId;
}
$user->syncRoles($roles);
}
return $user;
}
/**
* @param array $data
* @return mixed
*/
public function createUserProfile(array $data)
{
try {
DB::beginTransaction();
$data[’password’] = str_random(8);
$data[’password_confirmation’] = $data[’password’];
$data[’email’] = (!empty($data[’email’])) ? $data[’email’] : 'john.’.$data[’last_name’].’@example.com’;
$user = $this->createOrUpdate($data, true);
$data[’roles’][$data[’role’]] = 1;
$this->saveRole($user, $data, $user->id, 'user’);
$contact = $this->contactService->setOwner($user->id);
$this->contactService->storeContact($data);
DB::commit();
return $contact;
} catch (Exception $e) {
DB::rollBack();
return $e->getMessage();
}
}
/**
* @param array $data
* @return mixed
*/
public function createPatientContact(array $data)
{
try {
DB::beginTransaction();
$data[’password’] = str_random(8);
$data[’password_confirmation’] = $data[’password’];
$user = $this->createOrUpdate($data, true);
$data[’roles’][config(’project.roles.patient’)] = 1;
$this->saveRole($user, $data, $user->id, 'user’);
$contact = $this->contactService->setOwner($user->id);
$this->contactService->storeContact($data);
$this->updateContactAssignedDoctor($contact->id, isset($data[’doctor’]) ? $data[’doctor’] : ”);
DB::commit();
return true;
} catch (Exception $e) {
DB::rollBack();
throw $e;
}
}
/**
* find patient information and details by id
* @param $id
* @return mixed
*/
public function findPatientById($id) {
$user = $this->contactService->findContactByUserId($id);
return $user;
}
/**
* update patient contact by id
* @param $id
* @param $data
* @return bool
* @throws Exception
*/
public function updatePatientContact($id, $data) {
try {
DB::beginTransaction();
$contact = $this->contactService->findById($id, true);
if (!empty($data[’password’])) {
$user = User::find($contact->user_id);
$user->password = Hash::make($data[’password’]);
$user->email = $data[’email’];
$user->save();
}
$this->contactService->updateContact($contact, $data);
$this->updateContactAssignedDoctor($id, isset($data[’doctor’]) ? $data[’doctor’] : ”);
DB::commit();
return true;
} catch (Exception $e) {
DB::rollBack();
throw $e;
}
}
/**
* @param $category
* @return mixed
*/
public function getPreDefinedData($category)
{
return $this->preDefinedService->getDataByCategory($category);
}
/**
* @param $contactId
* @param $doctorID
* @return mixed
*/
public function updateContactAssignedDoctor($contactId, $doctorID = ”)
{
return $this->contactService->updateContactAssignedDoctor($contactId, $doctorID);
}
/**
* @param $userId
* @return mixed
*/
public function updatePatientStatus($userId)
{
$contact = $this->contactService->findContactByUserId($userId);
return $this->contactService->updatePatientStatus($contact->id);
}
}
End File# serbogdanov/project
# app/Modules/Appointments/Repositories/AppointmentRepository.php
appointmentModel = $appointmentModel;
$this->userRepository = $userRepository;
}
/**
* @param array $data
* @return Appointment|IlluminateDatabaseEloquentModel
*/
public function create(array $data)
{
$start = Carbon::createFromFormat(’Y-m-d H:i’, $data[’date’].’ ’.$data[’start’]);
$end = Carbon::createFromFormat(’Y-m-d H:i’, $data[’date’].’ ’.$data[’end’]);
$query = $this->appointmentModel->create([
'contact_id’ => $data[’contact_id’],
'title’ => $data[’title’],
'dentist_id’ => $data[’dentist_id’],
'type_id’ => $data[’type_id’],
'duration’ => $data[’duration’],
'date’ => $data[’date’],
'treatment_id’ => $data[’treatment_id’],
'start_time’ => $data[’start’],
'end_time’ => $data[’end’],
'all_day’ => 0,
'notes’ => isset($data[’notes’]) ? $data[’notes’] : ”,
'color’ => isset($data[’color’]) ? $data[’color’] : ”,
'is_block’ => isset($data[’is_block’]) ? $data[’is_block’] : 0,
'is_initial’ => isset($data[’is_initial’]) ? $data[’is_initial’] : 0,
'status_id’ => $data[’status_id’],
'start’ => $start,
'end’ => $end,
]);
return $query;
}
/**
* Update appointment
* @param int $id
* @param array $data
* @return Appointment
* @throws Exception
*/
public function update($id, array $data)
{
$appointment = $this->findById($id);
if (empty($appointment)) {
throw new Exception(’No appointment found.’);
}
$start = Carbon::createFromFormat(’Y-m-d H:i’, $data[’date’].’ ’.$data[’start’]);
$end = Carbon::createFromFormat(’Y-m-d H:i’, $data[’date’].’ ’.$data[’end’]);
if (array_key_exists(’contact_id’, $data)) {
$appointment->contact_id = $data[’contact_id’];
}
if (array_key_exists(’title’, $data)) {
$appointment->title = $data[’title’];
}
if (array_key_exists(’dentist_id’, $data)) {
$appointment->dentist_id = $data[’dentist_id’];
}
if (array_key_exists(’type_id’, $data)) {
$appointment->type_id = $data[’type_id’];
}
if (array_key_exists(’duration’, $data)) {
$appointment->duration = $data[’duration’];
}
if (array_key_exists(’date’, $data)) {
$appointment->date = $data[’date’];
}
if (array_key_exists(’treatment_id’, $data)) {
$appointment->treatment_id = $data[’treatment_id’];
}
if (array_key_exists(’start’, $data)) {
$appointment->start_time = $data[’start’];
}
if (array_key_exists(’end’, $data)) {
$appointment->end_time = $data[’end’];
}
if (array_key_exists(’all_day’, $data)) {
$appointment->all_day = $data[’all_day’];
}
if (array_key_exists(’notes’, $data)) {
$appointment->notes = $data[’notes’];
}
if (array_key_exists(’color’, $data)) {
$appointment->color = $data[’color’];
}
if (array_key_exists(’is_block’, $data)) {
$appointment->is_block = $data[’is_block’];
}
if (array_key_exists(’is_initial’, $data)) {
$appointment->is_initial = $data[’is_initial’];
}
if (array_key_exists(’status_id’, $data)) {
$appointment->status_id = $data[’status_id’];
}
$appointment->start = $start;
$appointment->end = $end;
$appointment->save();
return $appointment;
}
/**
* Delete appointment
* @param $id
* @return bool
* @throws Exception
*/
public function delete($id)
{
$appointment = $this->findById($id);
if (empty($appointment)) {
throw new Exception(’No appointment found.’);
}
return $appointment->delete();
}
/**
* @param $id
* @return Appointment|IlluminateDatabaseEloquentModel|null|object
*/
public function findById($id)
{
return $this->appointmentModel->where(’id’, $id)->with([’contact’])->first();
}
/**
* @param $id
* @return Appointment|IlluminateDatabaseEloquentModel|null|object
*/
public function findDetailById($id)
{
return $this->appointmentModel->where(’id’, $id)
->with([’contact’,’dentist’,’type’, 'status’, 'treatment’])
->first();
}
/**
* @param null $doctorId
* @param null $startStr
* @param null $endStr
* @return mixed
*/
public function getDentistAppointment($doctorId = null, $startStr = null, $endStr = null)
{
$query = $this->appointmentModel->where(’dentist_id’, $doctorId);
if (!is_null($startStr) && !is_null($endStr)) {
$start = Carbon::parse($startStr)->format(’Y-m-d’);
$end = Carbon::parse($endStr)->format(’Y-m-d’);
// Use date based conditions directly on date column for more accurate search
$query = $query->whereRaw(’date(date) >= ?’, [$start])
->whereRaw(’date(date) <= ?', [$end]);
}
// Include related models for a richer response
$query = $query->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]);
return $query->get();
}
/**
* @param $contactId
* @return mixed
*/
public function getAppointmentByContactId($contactId)
{
$query = $this->appointmentModel
->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’])
->where(’contact_id’, $contactId);
return $query->get();
}
/**
* @param $startDate
* @param $endDate
* @return mixed
*/
public function getAppointmentByRange($startDate, $endDate)
{
$doctorIds = [];
$contactIds = [];
$user = Auth::user();
if ($user->hasRole(’doctor’)) {
$doctorIds = [$user->id];
} else {
$result = $this->userRepository->getUsersByRole(’doctor’);
foreach($result as $doctor) {
$doctorIds[] = $doctor->id;
}
}
if ($user->hasRole(’doctor’)) {
$contacts = $this->userRepository->getContactData($user->id);
foreach($contacts as $contact) {
$contactIds[] = $contact->id;
}
} else {
$query = $this->appointmentModel
->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’])
->where(’date’, '>=’, $startDate)
->where(’date’, ’<=', $endDate)
->whereIn(’dentist_id’, $doctorIds);
}
return $query->get();
}
/**
* @param string $sort_by
* @param string $sort
* @param string $search
* @param int $per_page
* @return IlluminateContractsPaginationLengthAwarePaginator
*/
public function getAllAppointments($sort_by = 'date’, $sort = 'desc’, $search = ”, $per_page = 10)
{
$query = $this->appointmentModel->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’]);
if (!is_null($search) && !empty($search)) {
$query = $query->where(function ($q) use ($search) {
$q->where(’title’, 'like’, '%’.$search.’%’)
->orWhere(’notes’, 'like’, '%’.$search.’%’)
->orWhereHas(’contact’, function ($query) use ($search) {
$query->where(’first_name’, 'like’, '%’.$search.’%’)
->orWhere(’last_name’, 'like’, '%’.$search.’%’);
})
->orWhereHas(’dentist’, function ($query) use ($search) {
$query->where(’first_name’, 'like’, '%’.$search.’%’)
->orWhere(’last_name’, 'like’, '%’.$search.’%’);
});
});
}
return $query->orderBy($sort_by, $sort)->paginate($per_page);
}
/**
* @param $user_id
* @param string $sort_by
* @param string $sort
* @param string $search
* @param int $per_page
* @return IlluminateContractsPaginationLengthAwarePaginator
*/
public function getAppointmentsByDentist($user_id, $sort_by = 'date’, $sort = 'desc’, $search = ”, $per_page = 10)
{
$query = $this->appointmentModel->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’])
->where(’dentist_id’, $user_id);
if (!is_null($search) && !empty($search)) {
$query = $query->where(function ($q) use ($search) {
$q->where(’title’, 'like’, '%’.$search.’%’)
->orWhere(’notes’, 'like’, '%’.$search.’%’)
->orWhereHas(’contact’, function ($query) use ($search) {
$query->where(’first_name’, 'like’, '%’.$search.’%’)
->orWhere(’last_name’, 'like’, '%’.$search.’%’);
});
});
}
return $query->orderBy($sort_by, $sort)->paginate($per_page);
}
/**
* @param $contact_id
* @param string $sort_by
* @param string $sort
* @param string $search
* @param int $per_page
* @return IlluminateContractsPaginationLengthAwarePaginator
*/
public function getAppointmentsByContact($contact_id, $sort_by = 'date’, $sort = 'desc’, $search = ”, $per_page = 10)
{
$query = $this->appointmentModel->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’])
->where(’contact_id’, $contact_id);
if (!is_null($search) && !empty($search)) {
$query = $query->where(function ($q) use ($search) {
$q->where(’title’, 'like’, '%’.$search.’%’)
->orWhere(’notes’, 'like’, '%’.$search.’%’)
->orWhereHas(’dentist’, function ($query) use ($search) {
$query->where(’first_name’, 'like’, '%’.$search.’%’)
->orWhere(’last_name’, 'like’, '%’.$search.’%’);
});
});
}
return $query->orderBy($sort_by, $sort)->paginate($per_page);
}
/**
* @param $startDate
* @param $endDate
* @return mixed
*/
public function getWeeklyAppointments($startDate, $endDate)
{
$query = $this->appointmentModel
->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’])
->where(’date’, '>=’, $startDate)
->where(’date’, ’<=', $endDate);
return $query->get();
}
/**
* @param string $status
* @return mixed
*/
public function getAppointmentsByStatus($status = 'booked’)
{
$query = $this->appointmentModel
->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’])
->whereHas(’status’, function ($query) use ($status) {
$query->where(’value’, $status);
})
->where(’date’, '>=’, Carbon::now()->format(’Y-m-d’));
return $query->orderBy(’date’, 'asc’)->orderBy(’start_time’, 'asc’)->take(5)->get();
}
/**
* Get upcoming appointments count
* @return int
*/
public function getUpcomingAppointmentsCount()
{
return $this->appointmentModel
->where(’date’, '>=’, Carbon::now()->format(’Y-m-d’))
->count();
}
/**
* Get today’s appointments count
* @return int
*/
public function getTodayAppointmentsCount()
{
return $this->appointmentModel
->where(’date’, Carbon::now()->format(’Y-m-d’))
->count();
}
/**
* Get weekly appointments statistics
* @return array
*/
public function getWeeklyAppointmentStats()
{
$startOfWeek = Carbon::now()->startOfWeek()->format(’Y-m-d’);
$endOfWeek = Carbon::now()->endOfWeek()->format(’Y-m-d’);
$results = DB::table(’appointments’)
->select(DB::raw(’DATE(date) as day, COUNT(*) as count’))
->whereBetween(’date’, [$startOfWeek, $endOfWeek])
->groupBy(’day’)
->orderBy(’day’)
->get();
// Initialize stats array with zeros for each day
$stats = [];
$current = Carbon::parse($startOfWeek);
while ($current->format(’Y-m-d’) <= $endOfWeek) {
$stats[$current->format(’Y-m-d’)] = 0;
$current->addDay();
}
// Fill in actual counts
foreach ($results as $result) {
$stats[$result->day] = $result->count;
}
return $stats;
}
/**
* Get dentist specific weekly appointment stats
* @param int $dentistId
* @return array
*/
public function getDentistWeeklyAppointmentStats($dentistId)
{
$startOfWeek = Carbon::now()->startOfWeek()->format(’Y-m-d’);
$endOfWeek = Carbon::now()->endOfWeek()->format(’Y-m-d’);
$results = DB::table(’appointments’)
->select(DB::raw(’DATE(date) as day, COUNT(*) as count’))
->where(’dentist_id’, $dentistId)
->whereBetween(’date’, [$startOfWeek, $endOfWeek])
->groupBy(’day’)
->orderBy(’day’)
->get();
// Initialize stats array with zeros for each day
$stats = [];
$current = Carbon::parse($startOfWeek);
while ($current->format(’Y-m-d’) <= $endOfWeek) {
$stats[$current->format(’Y-m-d’)] = 0;
$current->addDay();
}
// Fill in actual counts
foreach ($results as $result) {
$stats[$result->day] = $result->count;
}
return $stats;
}
/**
* Get appointment statistics by treatment type
* @return array
*/
public function getAppointmentStatsByTreatment()
{
$results = DB::table(’appointments’)
->join(’pre_defined_data’, 'appointments.treatment_id’, '=’, 'pre_defined_data.id’)
->select(’pre_defined_data.value as treatment’, DB::raw(’COUNT(*) as count’))
->where(’pre_defined_data.category’, 'treatment’)
->groupBy(’pre_defined_data.value’)
->orderBy(’count’, 'desc’)
->take(5)
->get();
$stats = [];
foreach ($results as $result) {
$stats[$result->treatment] = $result->count;
}
return $stats;
}
/**
* Get upcoming appointments
* @param int $limit
* @return mixed
*/
public function getUpcomingAppointments($limit = 5)
{
return $this->appointmentModel
->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’])
->where(’date’, '>=’, Carbon::now()->format(’Y-m-d’))
->orderBy(’date’, 'asc’)
->orderBy(’start_time’, 'asc’)
->take($limit)
->get();
}
/**
* Get today’s appointments
* @return mixed
*/
public function getTodayAppointments()
{
return $this->appointmentModel
->with([’contact’, 'dentist’, 'type’, 'status’, 'treatment’])
->where(’date’, Carbon::now()->format(’Y-m-d’))
->orderBy(’start_time’, 'asc’)
->get();
}
}
End File# serbogdanov/project
# app/Modules/Appointments/Controllers/AppointmentController.php
repository = $repository;
$this->service = $service;
$this->preDefinedService = $preDefinedService;
$this->userRepository = $userRepository;
}
/**
* @param Request $request
* @return IlluminateContractsViewFactory|IlluminateViewView
*/
public function index(Request $request)
{
$sort_by = $request->get(’sort_by’, 'date’);
$sort = $request->get(’sort’, 'desc’);
$search = trim($request->get(’search’));
$per_page = config(’project.per_page’);
$user = Auth::user();
if ($user->hasRole(’doctor’)) {
$appointments = $this->repository->getAppointmentsByDentist($user->id, $sort_by, $sort, $search, $per_page);
} else {
$appointments = $this->repository->getAllAppointments($sort_by, $sort, $search, $per_page);
}
return view(’appointments::index’, compact(’appointments’, 'user’));
}
/**
* @return IlluminateContractsViewFactory|IlluminateViewView
*/
public function create()
{
$doctors = $this->userRepository->getUsersByRole(’doctor’);
$patients = $this->userRepository->getUsersByRole(’patient’);
$status = $this->preDefinedService->getDataByCategory(’status’);
$treatments = $this->preDefinedService->getDataByCategory(’treatment’);
$types = $this->preDefinedService->getDataByCategory(’appointment_type’);
return view(’appointments::create’, compact(’doctors’, 'patients’, 'status’, 'treatments’, 'types’));
}
/**
* @param CreateAppointmentRequest $request
* @return IlluminateHttpRedirectResponse
*/
public function store(CreateAppointmentRequest $request)
{
try {
$data = $request->all();
$this->service->createAppointment($data);
if ($request->has(’save_and_new’)) {
return redirect()->route(’appointments.create’)
->with(’success’, trans(’appointments::messages.record_was_successfully_created’));
}
return redirect()->route(’appointments.index’)
->with(’success’, trans(’appointments::messages.record_was_successfully_created’));
} catch (Exception $e) {
return back()
->with(’error’, „Cannot save appointment: ” . $e->getMessage());
}
}
/**
* @param $id
* @return IlluminateContractsViewFactory|IlluminateViewView
*/
public function show($id)
{
$appointment = $this->repository->findDetailById($id);
if (empty($appointment)) {
return redirect()->route(’appointments.index’)
->with(’error’, trans(’appointments::messages.record_not_found’));
}
return view(’appointments::show’, compact(’appointment’));
}
/**
* @param $id
* @return IlluminateContractsViewFactory|IlluminateViewView
*/
public function edit($id)
{
$appointment = $this->repository->findDetailById($id);
if (empty($appointment)) {
return redirect()->route(’appointments.index’)
->with(’error’, trans(’appointments::messages.record_not_found’));
}
$doctors = $this->userRepository->getUsersByRole(’doctor’);
$patients = $this->userRepository->getUsersByRole(’patient’);
$status = $this->preDefinedService->getDataByCategory(’status’);
$treatments = $this->preDefinedService->getDataByCategory(’treatment’);
$types = $this->preDefinedService->getDataByCategory(’appointment_type’);
return view(’appointments::edit’, compact(’appointment’, 'doctors’, 'patients’, 'status’, 'treatments’, 'types’));
}
/**
* @param UpdateAppointmentRequest $request
* @param $id
* @return IlluminateHttpRedirectResponse
*/
public function update(UpdateAppointmentRequest $request, $id)
{
try {
$data = $request->all();
$this->service->updateAppointment($id, $data);
return redirect()->route(’appointments.index’)
->with(’success’, trans(’appointments::messages.record_was_successfully_updated’));
} catch (Exception $e) {
return back()
->with(’error’, „Cannot update appointment: ” . $e->getMessage());
}
}
/**
* @param $id
* @return IlluminateHttpRedirectResponse
*/
public function destroy($id)
{
try {
$this->repository->delete($id);
return back()
->with(’success’, trans(’appointments::messages.record_was_successfully_deleted’));
} catch (Exception $e) {
return back()
->with(’error’, trans(’appointments::messages.record_was_not_deleted_due_to_system_error’));
}
}
/**
* @param Request $request
* @return IlluminateHttpJsonResponse
*/
public function events(Request $request)
{
try {
$start = $request->get(’start’);
$end = $request->get(’end’);
$doctorId = $request->get(’doctor_id’);
$data = $this->repository->getDentistAppointment($doctorId, $start, $end);
$formattedData = $this->service->formatAppointmentsForCalendar($data);
return response()->json([
'status’ => 'success’,
'data’ => $formattedData
]);
} catch (Exception $e) {
return response()->json([
'status’ => 'error’,
'message’ => $e->getMessage()
]);
}
}
/**
* @param Request $request
* @return IlluminateHttpJsonResponse
*/
public function quickCreate(Request $request)
{
try {
DB::beginTransaction();
$data = $request->all();
$appointment = $this->service->createAppointment($data);
DB::commit();
return response()->json([
'status’ => 'success’,
'message’ => 'Appointment created successfully’,
'data’ => $appointment
]);
} catch (Exception $e) {
DB::rollBack();
return response()->json([
'status’ => 'error’,
'message’ => $e->getMessage()
]);
}
}
/**
* @param Request $request
* @param $id
* @return IlluminateHttpJsonResponse
*/
public function updateEvent(Request $request, $id)
{
try {
DB::beginTransaction();
$data = $request->all();
$appointment = $this->service->updateEventTiming($id, $data);
DB::commit();
return response()->json([
'status’ => 'success’,
'message’ => 'Appointment updated successfully’,
'data’ => $appointment
]);
} catch (Exception $e) {
DB::rollBack();
return response()->json([
'status’ => 'error’,
'message’ => $e->getMessage()
]);
}
}
/**
* @param Request $request
* @param $id
* @return IlluminateHttpJsonResponse
*/
public function quickUpdate(Request $request, $id)
{
try {
DB::beginTransaction();
$data = $request->all();
$appointment = $this->service->updateAppointment($id, $data);
DB::commit();
return response()->json([
'status’ => 'success’,
'message’ => 'Appointment updated successfully’,
'data’ => $appointment
]);
} catch (Exception $e) {
DB::rollBack();
return response()->json([
'status’ => 'error’,
'message’ => $e->getMessage()
]);
}
}
/**
* @param $id
* @return IlluminateHttpJsonResponse
*/
public function getAppointment($id)
{
try {
$appointment = $this->repository->findDetailById($id);
if (empty($appointment)) {
throw new Exception(’Appointment not found’);
}
return response()->json([
'status’ => 'success’,
'data’ => $appointment
]);
} catch (Exception $e) {
return response()->json([
'status’ => 'error’,
'message’ => $e->getMessage()
]);
}
}
/**
* @return IlluminateContractsViewFactory|IlluminateViewView
*/
public function calendar()
{
$doctors = $this->userRepository->getUsersByRole(’doctor’);
$patients = $this->userRepository->getUsersByRole(’patient’);
$status = $this->preDefinedService->getDataByCategory(’status’);
$treatments = $this->preDefinedService->getDataByCategory(’treatment’);
$types = $this->preDefinedService->getDataByCategory(’appointment_type’);
// Get current user
$user = Auth::user();
$currentDoctorId = $user->hasRole(’doctor’) ? $user->id : null;
return view(’appointments::calendar’, compact(
'doctors’,
'patients’,
'status’,
'treatments’,
'types’,
'currentDoctorId’
));
}
/**
* @param Request $request
* @return IlluminateHttpJsonResponse
*/
public function weeklyView(Request $request)
{
try {
$startDate = $request->get(’start_date’, Carbon::now()->startOfWeek()->format(’Y-m-d’));
$endDate = $request->get(’end_date’, Carbon::now()->endOfWeek()->format(’Y-m-d’));
$appointments = $this->repository->getWeeklyAppointments($startDate, $endDate);
$formattedData = $this->service->formatAppointmentsForWeeklyView($appointments);
return response()->json([
'status’ => 'success’,
'data’ => $formattedData,
'period’ => [
'start’ => $startDate,
'end’ => $endDate
]
]);
} catch (Exception $e) {
return response()->json([
'status’ => 'error’,
'message’ => $e->getMessage()
]);
}
}
/**
* @return IlluminateContractsViewFactory|IlluminateViewView
*/
public function weeklySchedule()
{
$doctors = $this->userRepository->getUsersByRole(’doctor’);
$currentWeek = [
'start’ => Carbon::now()->startOfWeek()->format(’Y-m-d’),
'end’ => Carbon::now()->endOfWeek()->format(’Y-m-d’)
];
return view(’appointments::weekly’, compact(’doctors’, 'currentWeek’));
}
/**
* @param Request $request
* @return IlluminateHttpResponse|SymfonyComponentHttpFoundationBinaryFileResponse
*/
public function exportPdf(Request $request)
{
$data = $request->all();
$format = isset($data[’format’]) ? $data[’format’] : 'weekly’;
if ($format === 'weekly’) {
$startDate = $request->get(’start_date’, Carbon::now()->startOfWeek()->format(’Y-m-d’));
$endDate = $request->get(’end_date’, Carbon::now()->endOfWeek()->format(’Y-m-d’));
$appointments = $this->repository->getWeeklyAppointments($startDate, $endDate);
$formattedData = $this->service->formatAppointmentsForWeeklyView($appointments);
$pdf = PDF::loadView(’appointments::exports.weekly_pdf’, [
'appointments’ => $formattedData,
'startDate’ => Carbon::parse($startDate)->format(’d M Y’),
'endDate’ => Carbon::parse($endDate)->format(’d M Y’)
]);
return $pdf->download(’weekly-schedule-’.Carbon::now()->format(’Y-m-d’).’.pdf’);
} else {
// Handle other formats if needed
return response(’Unsupported format’, 400);
}
}
}
End File# app/Modules/Appointments/Services/AppointmentService.php
repository = $repository;
}
/**
* @param array $data
* @return mixed
* @throws Exception
*/
public function createAppointment(array $data)
{
// Validate appointment overlap if needed
if (isset($data[’check_overlap’]) && $data[’check_overlap’]) {
$this->validateAppointmentOverlap($data);
}
// Format data for creation
$appointmentData = $this->prepareAppointmentData($data);
// Create appointment
return $this->repository->create($appointmentData);
}
/**
* @param int $id
* @param array $data
* @return mixed
* @throws Exception
*/
public function updateAppointment($id, array $data)
{
// Validate appointment overlap if needed
if (isset($data[’check_overlap’]) && $data[’check_overlap’]) {
$this->validateAppointmentOverlap($data, $id);
}
// Format data for update
$appointmentData = $this->prepareAppointmentData($data);
// Update appointment
return $this->repository->update($id, $appointmentData);
}
/**
* @param $id
* @param array $data
* @return mixed
* @throws Exception
*/
public function updateEventTiming($id, array $data)
{
$appointment = $this->repository->findById($id);
if (empty($appointment)) {
throw new Exception(’Appointment not found.’);
}
// Extract date and time from start and end
$startDt = Carbon::parse($data[’start’]);
$endDt = Carbon::parse($data[’end’]);
$updateData = [
'date’ => $startDt->format(’Y-m-d’),
'start’ => $startDt->format(’H:i’),
'end’ => $endDt->format(’H:i’),
'duration’ => $endDt->diffInMinutes($startDt)
];
// Check for all_day flag
if (isset($data[’allDay’])) {
$updateData[’all_day’] = $data[’allDay’] ? 1 : 0;
}
return $this->repository->update($id, $updateData);
}
/**
* @param array $data
* @param int|null $excludeId
* @throws Exception
*/
protected function validateAppointmentOverlap(array $data, $excludeId = null)
{
// Implementation of overlap validation logic
// This should check if the new appointment time conflicts with existing ones
// for the same dentist
// Example implementation left for you to complete based on your requirements
}
/**
* @param array $data
* @return array
*/
protected function prepareAppointmentData(array $data)
{
// Clean and format the data for appointment creation/update
$appointmentData = [];
// Map fields from request to appointment model fields
$fieldMappings = [
'contact_id’, 'title’, 'dentist_id’, 'type_id’, 'duration’,
'date’, 'treatment_id’, 'start’, 'end’, 'all_day’,
'notes’, 'color’, 'is_block’, 'is_initial’, 'status_id’
];
foreach ($fieldMappings as $field) {
if (array_key_exists($field, $data)) {
$appointmentData[$field] = $data[$field];
}
}
// Handle special field transformations if needed
return $appointmentData;
}
/**
* Format appointments for calendar view
* @param $appointments
* @return array
*/
public function formatAppointmentsForCalendar($appointments)
{
$formattedAppointments = [];
foreach ($appointments as $appointment) {
$event = [
'id’ => $appointment->id,
'title’ => $appointment->title,
'start’ => Carbon::parse($appointment->date.’ ’.$appointment->start_time)->toIso8601String(),
'end’ => Carbon::parse($appointment->date.’ ’.$appointment->end_time)->toIso8601String(),
'allDay’ => (bool)$appointment->all_day,
'backgroundColor’ => !empty($appointment->color) ? $appointment->color : '#3788d8′,
'borderColor’ => !empty($appointment->color) ? $appointment->color : '#3788d8′,
'extendedProps’ => [
'contact_id’ => $appointment->contact_id,
'dentist_id’ => $appointment->dentist_id,
'type_id’ => $appointment->type_id,
'treatment_id’ => $appointment->treatment_id,
'status_id’ => $appointment->status_id,
'notes’ => $appointment->notes,
'is_block’ => $appointment->is_block,
'is_initial’ => $appointment->is_initial,
'duration’ => $appointment->duration,
]
];
// Add patient information if available
if ($appointment->contact) {
$event[’extendedProps’][’patient’] = [
'id’ => $appointment->contact->id,
'name’ => $appointment->contact->first_name . ’ ’ . $appointment->contact->last_name,
'phone’ => $appointment->contact->phone
];
}
// Add dentist information if available
if ($appointment->dentist) {
$event[’extendedProps’][’dentist’] = [
'id’ => $appointment->dentist->id,
'name’ => $appointment->dentist->first_name . ’ ’ . $appointment->dentist->last_name
];
}
$formattedAppointments[] = $event;
}
return $formattedAppointments;
}
/**
* Format appointments for weekly view
* @param $appointments
* @return array
*/
public function formatAppointmentsForWeeklyView($appointments)
{
$formattedData = [];
// Group appointments by date
$appointmentsByDate = [];
foreach ($appointments as $appointment) {
$date = $appointment->date;
if (!isset($appointmentsByDate[$date])) {
$appointmentsByDate[$date] = [];
}
$appointmentsByDate[$date][] = $appointment;
}
// Sort appointments by time for each date
foreach ($appointmentsByDate as $date => $dateAppointments) {
usort($dateAppointments, function($a, $b) {
return strtotime($a->start_time) – strtotime($b->start_time);
});
$formattedData[$date] = [];
foreach ($dateAppointments as $appointment) {
$formattedData[$date][] = [
'id’ => $appointment->id,
'title’ => $appointment->title,
'start_time’ => $appointment->start_time,
'end_time’ => $appointment->end_time,
'patient_name’ => $appointment->contact ? ($appointment->contact->first_name . ’ ’ . $appointment->contact->last_name) : 'N/A’,
'dentist_name’ => $appointment->dentist ? ($appointment->dentist->first_name . ’ ’ . $appointment->dentist->last_name) : 'N/A’,
'treatment’ => $appointment->treatment ? $appointment->treatment->value : 'N/A’,
'status’ => $appointment->status ? $appointment->status->value : 'N/A’,
'color’ => !empty($appointment->color) ? $appointment->color : '#3788d8′,
];
}
}
return $formattedData;
}
}
End FilegetClientOriginalExtension());
if (!in_array($fileExtension, $allowedTypes)) {
throw new Exception(„File type not allowed. Allowed types: ” . implode(’, ’, $allowedTypes));
}
}
// Validate the file size if maxSize is specified
if ($maxSize > 0 && $file->getSize() > $maxSize * 1024) {
throw new Exception(„File size exceeds maximum allowed size of {$maxSize}KB”);
}
// Generate a unique filename
$filename = Str::random(20) . ’.’ . $file->getClientOriginalExtension();
// Set storage path
$path = $folder ? $folder . '/’ . $filename : $filename;
// Store the file
Storage::disk($disk)->put($path, file_get_contents($file));
// Return the file path
return $path;
}
/**
* Upload and resize image
*
* @param IlluminateHttpUploadedFile $file
* @param string $folder
* @param bool $isPublic
* @param int $width
* @param int $height
* @param bool $maintainAspectRatio
* @return string|null
*/
public function uploadImage($file, $folder = null, $isPublic = true, $width = null, $height = null, $maintainAspectRatio = true)
{
$allowedTypes = [’jpg’, 'jpeg’, 'png’, 'gif’, 'webp’];
// Check if file is valid image
$fileExtension = strtolower($file->getClientOriginalExtension());
if (!in_array($fileExtension, $allowedTypes)) {
throw new Exception(„File type not allowed. Allowed types: ” . implode(’, ’, $allowedTypes));
}
// Generate a unique filename
$filename = Str::random(20) . ’.’ . $fileExtension;
// Set storage path
$path = $folder ? $folder . '/’ . $filename : $filename;
// Create image instance
$image = Image::make($file);
// Resize if dimensions provided
if ($width || $height) {
if ($maintainAspectRatio) {
$image->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
} else {
$image->resize($width, $height);
}
}
// Define the disk to use
$disk = $isPublic ? 'public’ : 'local’;
// Save the image
Storage::disk($disk)->put($path, (string) $image->encode());
// Return the file path
return $path;
}
/**
* Delete file from storage
*
* @param string $path
* @param bool $isPublic
* @return bool
*/
public function deleteFile($path, $isPublic = true)
{
if (!$path) {
return false;
}
$disk = $isPublic ? 'public’ : 'local’;
if (Storage::disk($disk)->exists($path)) {
Storage::disk($disk)->delete($path);
return true;
}
return false;
}
/**
* Get public URL for a file
*
* @param string $path
* @return string|null
*/
public function getFileUrl($path)
{
if (!$path) {
return null;
}
return Storage::url($path);
}
}
x: number, y: number, width: number, height: number, offset: number, borderRadius: number) {
// 3D的框子
context.save();
// 边框设置
context.strokeStyle = 'rgba(66, 77, 116, 0.7)’;
context.lineWidth = 2;
// 绘制立体框的内壁
const innerTop = y + offset;
const innerLeft = x + offset;
const innerWidth = width – offset * 2;
const innerHeight = height – offset * 2;
// 绘制底部矩形(带圆角)
this.drawRoundedRect(context, x, y, width, height, borderRadius);
context.fillStyle = 'rgba(23, 30, 46, 0.7)’;
context.fill();
context.stroke();
// 绘制顶部矩形(带圆角)
this.drawRoundedRect(context, innerLeft, innerTop, innerWidth, innerHeight, borderRadius – offset);
context.fillStyle = 'rgba(47, 57, 86, 0.7)’;
context.fill();
context.stroke();
// 绘制内壁连接线
context.beginPath();
context.moveTo(x + borderRadius, y);
context.lineTo(innerLeft + borderRadius – offset, innerTop);
context.stroke();
context.beginPath();
context.moveTo(x + width – borderRadius, y);
context.lineTo(innerLeft + innerWidth – borderRadius + offset, innerTop);
context.stroke();
context.beginPath();
context.moveTo(x + width, y + borderRadius);
context.lineTo(innerLeft + innerWidth, innerTop + borderRadius – offset);
context.stroke();
context.beginPath();
context.moveTo(x + width, y + height – borderRadius);
context.lineTo(innerLeft + innerWidth, innerTop + innerHeight – borderRadius + offset);
context.stroke();
context.beginPath();
context.moveTo(x + width – borderRadius, y + height);
context.lineTo(innerLeft + innerWidth – borderRadius + offset, innerTop + innerHeight);
context.stroke();
context.beginPath();
context.moveTo(x + borderRadius, y + height);
context.lineTo(innerLeft + borderRadius – offset, innerTop + innerHeight);
context.stroke();
context.beginPath();
context.moveTo(x, y + height – borderRadius);
context.lineTo(innerLeft, innerTop + innerHeight – borderRadius + offset);
context.stroke();
context.beginPath();
context.moveTo(x, y + borderRadius);
context.lineTo(innerLeft, innerTop + borderRadius – offset);
context.stroke();
context.restore();
}
private drawRoundedRect(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
context.beginPath();
context.moveTo(x + radius, y);
context.lineTo(x + width – radius, y);
context.quadraticCurveTo(x + width, y, x + width, y + radius);
context.lineTo(x + width, y + height – radius);
context.quadraticCurveTo(x + width, y + height, x + width – radius, y + height);
context.lineTo(x + radius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height – radius);
context.lineTo(x, y + radius);
context.quadraticCurveTo(x, y, x + radius, y);
context.closePath();
}
//绘制装饰性箭头
private drawDecorativeArrow(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) {
const centerX = x + width / 2;
const centerY = y + height / 2;
const arrowSize = Math.min(width, height) * 0.3;
context.save();
context.strokeStyle = 'rgba(66, 133, 244, 0.6)’;
context.lineWidth = 2;
context.lineCap = ’round’;
context.lineJoin = ’round’;
// 绘制四个方向的箭头
// 上箭头
context.beginPath();
context.moveTo(centerX, centerY – arrowSize);
context.lineTo(centerX, centerY – arrowSize / 3);
context.moveTo(centerX – arrowSize / 4, centerY – arrowSize * 0.6);
context.lineTo(centerX, centerY – arrowSize);
context.lineTo(centerX + arrowSize / 4, centerY – arrowSize * 0.6);
context.stroke();
// 右箭头
context.beginPath();
context.moveTo(centerX + arrowSize, centerY);
context.lineTo(centerX + arrowSize / 3, centerY);
context.moveTo(centerX + arrowSize * 0.6, centerY – arrowSize / 4);
context.lineTo(centerX + arrowSize, centerY);
context.lineTo(centerX + arrowSize * 0.6, centerY + arrowSize / 4);
context.stroke();
// 下箭头
context.beginPath();
context.moveTo(centerX, centerY + arrowSize);
context.lineTo(centerX, centerY + arrowSize / 3);
context.moveTo(centerX – arrowSize / 4, centerY + arrowSize * 0.6);
context.lineTo(centerX, centerY + arrowSize);
context.lineTo(centerX + arrowSize / 4, centerY + arrowSize * 0.6);
context.stroke();
// 左箭头
context.beginPath();
context.moveTo(centerX – arrowSize, centerY);
context.lineTo(centerX – arrowSize / 3, centerY);
context.moveTo(centerX – arrowSize * 0.6, centerY – arrowSize / 4);
context.lineTo(centerX – arrowSize, centerY);
context.lineTo(centerX – arrowSize * 0.6, centerY + arrowSize / 4);
context.stroke();
context.restore();
}
//绘制内部装饰线
private drawDecorativeLines(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) {
context.save();
context.strokeStyle = 'rgba(66, 133, 244, 0.3)’;
context.lineWidth = 1;
// 绘制网格状的装饰线
const gridSize = Math.min(width, height) / 8;
// 水平线
for (let i = 1; i < height / gridSize; i++) {
context.beginPath();
context.moveTo(x, y + i * gridSize);
context.lineTo(x + width, y + i * gridSize);
context.stroke();
}
// 垂直线
for (let i = 1; i < width / gridSize; i++) {
context.beginPath();
context.moveTo(x + i * gridSize, y);
context.lineTo(x + i * gridSize, y + height);
context.stroke();
}
context.restore();
}
//绘制高科技圆形装饰
private drawTechCircle(context: CanvasRenderingContext2D, x: number, y: number, size: number) {
context.save();
const centerX = x + size / 2;
const centerY = y + size / 2;
const radius = size / 2;
// 绘制外圆环
context.beginPath();
context.arc(centerX, centerY, radius, 0, Math.PI * 2);
context.strokeStyle = 'rgba(66, 133, 244, 0.4)';
context.lineWidth = 2;
context.stroke();
// 绘制内圆环
context.beginPath();
context.arc(centerX, centerY, radius * 0.8, 0, Math.PI * 2);
context.strokeStyle = 'rgba(66, 133, 244, 0.3)';
context.lineWidth = 1;
context.stroke();
// 绘制十字标记
context.beginPath();
context.moveTo(centerX - radius * 0.6, centerY);
context.lineTo(centerX + radius * 0.6, centerY);
context.moveTo(centerX, centerY - radius * 0.6);
context.lineTo(centerX, centerY + radius * 0.6);
context.strokeStyle = 'rgba(66, 133, 244, 0.5)';
context.lineWidth = 1;
context.stroke();
// 绘制四个点
const pointRadius = radius * 0.05;
const pointDistance = radius * 0.7;
context.fillStyle = 'rgba(66, 133, 244, 0.7)';
context.beginPath();
context.arc(centerX + pointDistance, centerY, pointRadius, 0, Math.PI * 2);
context.fill();
context.beginPath();
context.arc(centerX - pointDistance, centerY, pointRadius, 0, Math.PI * 2);
context.fill();
context.beginPath();
context.arc(centerX, centerY + pointDistance, pointRadius, 0, Math.PI * 2);
context.fill();
context.beginPath();
context.arc(centerX, centerY - pointDistance, pointRadius, 0, Math.PI * 2);
context.fill();
// 绘制弧线装饰
for (let i = 0; i < 4; i++) {
const startAngle = i * Math.PI / 2 + Math.PI / 8;
const endAngle = startAngle + Math.PI / 4;
context.beginPath();
context.arc(centerX, centerY, radius * 0.9, startAngle, endAngle);
context.strokeStyle = 'rgba(66, 133, 244, 0.6)';
context.lineWidth = 1.5;
context.stroke();
}
context.restore();
}
/**
* 绘制旋转中的装饰性圆
*/
private drawRotatingCircles(context: CanvasRenderingContext2D, x: number, y: number, size: number, time: number) {
const centerX = x + size / 2;
const centerY = y + size / 2;
context.save();
// 计算旋转角度
const rotation1 = (time / 2000) % (Math.PI * 2);
const rotation2 = (time / 3000) % (Math.PI * 2);
// 绘制外圆
context.beginPath();
context.arc(centerX, centerY, size * 0.45, 0, Math.PI * 2);
context.strokeStyle = 'rgba(66, 133, 244, 0.2)';
context.lineWidth = 1;
context.stroke();
// 绘制旋转的分割线 - 外圈
context.translate(centerX, centerY);
context.rotate(rotation1);
context.translate(-centerX, -centerY);
for (let i = 0; i < 12; i++) {
context.save();
context.translate(centerX, centerY);
context.rotate(i * Math.PI / 6);
context.beginPath();
context.moveTo(size * 0.35, 0);
context.lineTo(size * 0.45, 0);
context.strokeStyle = 'rgba(66, 133, 244, 0.6)';
context.lineWidth = 1;
context.stroke();
context.restore();
}
// 绘制内圆
context.setTransform(1, 0, 0, 1, 0, 0);
context.beginPath();
context.arc(centerX, centerY, size * 0.3, 0, Math.PI * 2);
context.strokeStyle = 'rgba(66, 133, 244, 0.3)';
context.lineWidth = 1;
context.stroke();
// 绘制旋转的分割线 - 内圈
context.translate(centerX, centerY);
context.rotate(rotation2);
context.translate(-centerX, -centerY);
for (let i = 0; i < 8; i++) {
context.save();
context.translate(centerX, centerY);
context.rotate(i * Math.PI / 4);
context.beginPath();
context.moveTo(size * 0.2, 0);
context.lineTo(size * 0.3, 0);
context.strokeStyle = 'rgba(66, 133, 244, 0.5)';
context.lineWidth = 1;
context.stroke();
context.restore();
}
// 绘制中心小圆
context.setTransform(1, 0, 0, 1, 0, 0);
context.beginPath();
context.arc(centerX, centerY, size * 0.05, 0, Math.PI * 2);
context.fillStyle = 'rgba(66, 133, 244, 0.6)';
context.fill();
context.restore();
}
/**
* 绘制左下角的指示标记
*/
private drawCornerMarker(context: CanvasRenderingContext2D, x: number, y: number, size: number) {
const cornerX = x;
const cornerY = y + size;
context.save();
context.strokeStyle = 'rgba(66, 133, 244, 0.7)';
context.fillStyle = 'rgba(66, 133, 244, 0.1)';
context.lineWidth = 1;
// 绘制L形标记
context.beginPath();
context.moveTo(cornerX, cornerY - size * 0.3);
context.lineTo(cornerX, cornerY);
context.lineTo(cornerX + size * 0.3, cornerY);
context.stroke();
// 绘制小方块
context.beginPath();
context.rect(cornerX + size * 0.15 - size * 0.05, cornerY - size * 0.15 - size * 0.05, size * 0.1, size * 0.1);
context.fill();
context.stroke();
context.restore();
}
/**
* 绘制右上角的指示标记
*/
private drawTopRightMarker(context: CanvasRenderingContext2D, x: number, y: number, width: number, size: number) {
const cornerX = x + width;
const cornerY = y;
context.save();
context.strokeStyle = 'rgba(66, 133, 244, 0.7)';
context.fillStyle = 'rgba(66, 133, 244, 0.1)';
context.lineWidth = 1;
// 绘制反L形标记
context.beginPath();
context.moveTo(cornerX, cornerY + size * 0.3);
context.lineTo(cornerX, cornerY);
context.lineTo(cornerX - size * 0.3, cornerY);
context.stroke();
// 绘制小方块
context.beginPath();
context.rect(cornerX - size * 0.15 - size * 0.05, cornerY + size * 0.15 - size * 0.05, size * 0.1, size * 0.1);
context.fill();
context.stroke();
context.restore();
}
/**
* 绘制数据流动的动画效果
*/
private drawDataFlow(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, time: number) {
context.save();
// 数据流的参数
const flowSpeed = 1000; // 流动速度的基准值(毫秒)
const flowCount = 3; // 数据流的数量
const flowLength = Math.min(width, height) * 0.15; // 数据流的长度
const flowWidth = 2; // 数据流的宽度
// 设置数据流样式
context.strokeStyle = 'rgba(66, 133, 244, 0.7)';
context.lineWidth = flowWidth;
context.lineCap = 'round';
// 绘制右边的数据流
for (let i = 0; i < flowCount; i++) {
const offset = (time + i * (flowSpeed / flowCount)) % flowSpeed / flowSpeed;
const startY = y + height * offset;
if (startY < y + height) {
// 计算可见长度
let visibleLength = flowLength;
if (startY + flowLength > y + height) {
visibleLength = y + height – startY;
}
context.beginPath();
context.moveTo(x + width, startY);
context.lineTo(x + width, startY + visibleLength);
context.stroke();
}
}
// 绘制下边的数据流
for (let i = 0; i < flowCount; i++) {
const offset = (time + i * (flowSpeed / flowCount) + flowSpeed / 2) % flowSpeed / flowSpeed;
const startX = x + width * offset;
if (startX < x + width) {
// 计算可见长度
let visibleLength = flowLength;
if (startX + flowLength > x + width) {
visibleLength = x + width – startX;
}
context.beginPath();
context.moveTo(startX, y + height);
context.lineTo(startX + visibleLength, y + height);
context.stroke();
}
}
context.restore();
}
/**
* 绘制高科技左上角标记
*/
private drawTopLeftMarker(context: CanvasRenderingContext2D, x: number, y: number, size: number) {
context.save();
// 设置样式
context.strokeStyle = 'rgba(66, 133, 244, 0.7)’;
context.lineWidth = 1;
// 绘制角标记
context.beginPath();
context.moveTo(x, y + size * 0.3);
context.lineTo(x, y);
context.lineTo(x + size * 0.3, y);
context.stroke();
// 绘制装饰点
context.fillStyle = 'rgba(66, 133, 244, 0.8)’;
context.beginPath();
context.arc(x + size * 0.15, y + size * 0.15, size * 0.03, 0, Math.PI * 2);
context.fill();
context.restore();
}
/**
* 绘制高科技右下角标记
*/
private drawBottomRightMarker(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, size: number) {
const cornerX = x + width;
const cornerY = y + height;
context.save();
// 设置样式
context.strokeStyle = 'rgba(66, 133, 244, 0.7)’;
context.lineWidth = 1;
// 绘制角标记
context.beginPath();
context.moveTo(cornerX, cornerY – size * 0.3);
context.lineTo(cornerX, cornerY);
context.lineTo(cornerX – size * 0.3, cornerY);
context.stroke();
// 绘制装饰点
context.fillStyle = 'rgba(66, 133, 244, 0.8)’;
context.beginPath();
context.arc(cornerX – size * 0.15, cornerY – size * 0.15, size * 0.03, 0, Math.PI * 2);
context.fill();
context.restore();
}
/**
* 绘制高科技装饰性刻度
*/
private drawTechScale(context: CanvasRenderingContext2D, x: number, y: number, width: number, time: number) {
context.save();
// 刻度参数
const scaleHeight = 5;
const scaleCount = 20;
const scaleSpacing = width / scaleCount;
const animOffset = (time / 1000) % 1 * scaleSpacing;
// 设置样式
context.strokeStyle = 'rgba(66, 133, 244, 0.5)’;
context.lineWidth = 1;
// 绘制刻度
for (let i = 0; i < scaleCount + 1; i++) {
const scaleX = x + (i * scaleSpacing - animOffset) % width;
const height = (i % 5 === 0) ? scaleHeight * 1.5 : scaleHeight;
context.beginPath();
context.moveTo(scaleX, y);
context.lineTo(scaleX, y + height);
context.stroke();
}
context.restore();
}
}
End File# src/app/components/list-loading-table/list-loading-controller.service.ts
import { Injectable } from '@angular/core';
import { debounceTime, distinctUntilChanged, filter, map, startWith, Subject, Subscription, switchMap, tap, timer } from 'rxjs';
/**
* 列表加载控制器服务
* 用于管理列表的加载状态、搜索和刷新
*/
@Injectable({
providedIn: 'root'
})
export class ListLoadingController {
private refreshSubject = new Subject
private searchSubject = new Subject
private loadingState = false;
private searchSubscription: Subscription | null = null;
private refreshSubscription: Subscription | null = null;
// 是否正在加载
get isLoading(): boolean {
return this.loadingState;
}
// 设置加载状态
set isLoading(value: boolean) {
this.loadingState = value;
}
/**
* 设置搜索处理器
* @param searchHandler 搜索处理函数
* @param debounceTimeMs 去抖时间(毫秒)
*/
setupSearch(searchHandler: (searchText: string) => void, debounceTimeMs: number = 300) {
// 取消之前的订阅
if (this.searchSubscription) {
this.searchSubscription.unsubscribe();
}
// 创建新的订阅,处理搜索操作
this.searchSubscription = this.searchSubject.pipe(
debounceTime(debounceTimeMs),
distinctUntilChanged(),
tap(() => {
this.isLoading = true; // 搜索开始时设置loading状态
}),
switchMap(searchText => {
return timer(500).pipe(
map(() => searchText)
);
}),
).subscribe(searchText => {
searchHandler(searchText);
this.isLoading = false; // 搜索完成后关闭loading状态
});
}
/**
* 设置刷新处理器
* @param refreshHandler 刷新处理函数
*/
setupRefresh(refreshHandler: () => void) {
// 取消之前的订阅
if (this.refreshSubscription) {
this.refreshSubscription.unsubscribe();
}
// 创建新的订阅,处理刷新操作
this.refreshSubscription = this.refreshSubject.pipe(
tap(() => {
this.isLoading = true; // 刷新开始时设置loading状态
}),
switchMap(() => {
return timer(500).pipe(
map(() => {})
);
}),
).subscribe(() => {
refreshHandler();
this.isLoading = false; // 刷新完成后关闭loading状态
});
}
/**
* 触发搜索
* @param searchText 搜索文本
*/
search(searchText: string) {
this.searchSubject.next(searchText);
}
/**
* 触发刷新
*/
refresh() {
this.refreshSubject.next();
}
/**
* 清理订阅,避免内存泄漏
*/
destroy() {
if (this.searchSubscription) {
this.searchSubscription.unsubscribe();
this.searchSubscription = null;
}
if (this.refreshSubscription) {
this.refreshSubscription.unsubscribe();
this.refreshSubscription = null;
}
}
}
End Fileimport { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core’;
import { CommonModule } from '@angular/common’;
import { SciFiHUDRenderer } from ’./sci-fi-hud-renderer’;
// 定义HUD模式枚举
export enum HUDMode {
NORMAL = 'normal’, // 正常模式
ALERT = 'alert’, // 警告模式
SUCCESS = 'success’, // 成功模式
INACTIVE = 'inactive’ // 不活跃模式
}
// HUD显示数据接口
export interface HUDData {
title?: string; // 标题
data?: any; // 数据
targetValue?: number; // 目标值
currentValue?: number; // 当前值
percentage?: number; // 百分比
isLocked?: boolean; // 是否锁定
status?: string; // 状态文本
mode?: HUDMode; // HUD模式
subtitle?: string; // 副标题
}
@Component({
selector: 'app-sci-fi-hud’,
standalone: true,
imports: [CommonModule],
templateUrl: ’./sci-fi-hud.component.html’,
styleUrl: ’./sci-fi-hud.component.scss’
})
export class SciFiHudComponent implements OnInit {
@ViewChild(’hudCanvas’, { static: true }) hudCanvas!: ElementRef
@Input() width: number = 300;
@Input() height: number = 200;
@Input() data: HUDData = {};
private renderer!: SciFiHUDRenderer;
private animationId: number = 0;
constructor() {}
ngOnInit(): void {
this.initializeCanvas();
this.startRendering();
}
ngOnChanges(): void {
if (this.renderer) {
this.renderer.setData(this.data);
}
}
ngOnDestroy(): void {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
}
private initializeCanvas(): void {
const canvas = this.hudCanvas.nativeElement;
canvas.width = this.width;
canvas.height = this.height;
const ctx = canvas.getContext(’2d’);
if (!ctx) {
console.error(’Cannot get 2D context from canvas’);
return;
}
// 初始化渲染器
this.renderer = new SciFiHUDRenderer(ctx, this.width, this.height);
this.renderer.setData(this.data);
}
private startRendering(): void {
const renderFrame = () => {
if (!this.renderer) return;
this.renderer.render();
this.animationId = requestAnimationFrame(renderFrame);
};
renderFrame();
}
/**
* 更新数据并刷新HUD显示
* @param data 要更新的HUD数据
*/
public updateData(data: Partial
this.data = { …this.data, …data };
if (this.renderer) {
this.renderer.setData(this.data);
}
}
/**
* 设置HUD模式
* @param mode HUD模式
*/
public setMode(mode: HUDMode): void {
this.updateData({ mode });
}
/**
* 设置进度值
* @param currentValue 当前值
* @param targetValue 目标值(可选)
*/
public setProgress(currentValue: number, targetValue?: number): void {
const updateData: Partial
if (targetValue !== undefined) {
updateData.targetValue = targetValue;
}
if (updateData.targetValue) {
updateData.percentage = Math.min(100, Math.max(0,
(updateData.currentValue || 0) / updateData.targetValue * 100));
}
this.updateData(updateData);
}
/**
* 设置锁定状态
* @param isLocked 是否锁定
*/
public setLocked(isLocked: boolean): void {
this.updateData({ isLocked });
}
/**
* 设置状态文本
* @param status 状态文本
*/
public setStatus(status: string): void {
this.updateData({ status });
}
}
End Fileimport { Component, EventEmitter, Input, OnInit, Output } from '@angular/core’;
import { CommonModule } from '@angular/common’;
import { FormsModule } from '@angular/forms’;
import { ListLoadingController } from ’../list-loading-controller.service’;
/**
* 表格分页事件接口
*/
export interface PageEvent {
pageIndex: number; // 当前页码索引(从0开始)
pageSize: number; // 每页显示条数
length: number; // 总条数
}
/**
* 表格列配置接口
*/
export interface TableColumn {
field: string; // 字段名
header: string; // 显示的表头
sortable?: boolean; // 是否可排序
class?: string; // 列样式类
type?: 'text’ | 'number’ | 'date’ | 'boolean’ | 'custom’; // 列数据类型
format?: (value: any) => any; // 自定义格式化函数
width?: string; // 列宽度
align?: 'left’ | 'center’ | 'right’; // 对齐方式
visible?: boolean; // 是否可见,默认为true
}
/**
* 加载中表格组件
* 一个现代化、高性能的数据表格组件,支持排序、分页和加载状态
*/
@Component({
selector: 'app-list-loading-table’,
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: ’./list-loading-table.component.html’,
styleUrl: ’./list-loading-table.component.scss’
})
export class ListLoadingTableComponent implements OnInit {
@Input() data: any[] = []; // 表格数据
@Input() columns: TableColumn[] = []; // 列配置
@Input() enablePagination: boolean = true; // 是否启用分页
@Input() pageSize: number = 10; // 每页数据量
@Input() pageSizeOptions: number[] = [5, 10, 25, 50]; // 可选每页数量
@Input() totalRecords: number = 0; // 总记录数
@Input() currentPageIndex: number = 0; // 当前页码(从0开始)
@Input() showSearch: boolean = true; // 是否显示搜索
@Input() searchPlaceholder: string = '搜索…’; // 搜索框提示文本
@Input() noDataMessage: string = '没有数据’; // 无数据时的提示信息
@Input() loading: boolean = false; // 加载状态
@Input() showRefresh: boolean = true; // 是否显示刷新按钮
@Input() stripped: boolean = true; // 是否使用条纹背景
@Input() bordered: boolean = false; // 是否显示边框
@Input() hoverable: boolean = true; // 行是否有悬停效果
@Input() emptyStateImage?: string; // 空状态图片
@Input() expandableRows: boolean = false; // 是否允许行展开
@Input() selectable: boolean = false; // 是否可选择行
@Output() pageChange = new EventEmitter
@Output() sortChange = new EventEmitter<{field: string, direction: 'asc' | 'desc'}>(); // 排序变更事件
@Output() searchChange = new EventEmitter
@Output() refreshRequest = new EventEmitter
@Output() rowClick = new EventEmitter
@Output() rowSelect = new EventEmitter
searchText: string = ”; // 搜索文本
selectedRows: Set
expandedRows: Set
sortField: string | null = null; // 当前排序字段
sortDirection: 'asc’ | 'desc’ = 'asc’; // 当前排序方向
get visibleColumns(): TableColumn[] {
return this.columns.filter(col => col.visible !== false);
}
constructor(public loadingController: ListLoadingController) {}
ngOnInit() {
// 设置搜索处理
this.loadingController.setupSearch((searchText) => {
this.searchChange.emit(searchText);
});
// 设置刷新处理
this.loadingController.setupRefresh(() => {
this.refreshRequest.emit();
});
}
/**
* 处理搜索变更
*/
onSearchChange() {
this.loadingController.search(this.searchText);
}
/**
* 处理刷新请求
*/
onRefreshClick() {
this.loadingController.refresh();
}
/**
* 处理排序变更
* @param field 排序字段
*/
onSort(field: string) {
// 查找列配置,检查是否可排序
const column = this.columns.find(col => col.field === field);
if (!column || column.sortable === false) return;
// 如果点击的是当前排序列,则切换排序方向
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc’ ? 'desc’ : 'asc’;
} else {
// 否则使用新的排序列,并设置默认排序方向为升序
this.sortField = field;
this.sortDirection = 'asc’;
}
this.sortChange.emit({ field, direction: this.sortDirection });
}
/**
* 处理页大小变更
* @param size 新的页大小
*/
onPageSizeChange(size: number) {
this.pageSize = size;
this.currentPageIndex = 0; // 重置页码
this.emitPageEvent();
}
/**
* 处理页面变更
* @param offset 页码偏移量(1 表示下一页,-1 表示上一页)
*/
onPageChange(offset: number) {
const newPageIndex = this.currentPageIndex + offset;
if (newPageIndex >= 0 && newPageIndex * this.pageSize < this.totalRecords) {
this.currentPageIndex = newPageIndex;
this.emitPageEvent();
}
}
/**
* 发送分页事件
*/
private emitPageEvent() {
this.pageChange.emit({
pageIndex: this.currentPageIndex,
pageSize: this.pageSize,
length: this.totalRecords
});
}
/**
* 处理行点击
* @param row 被点击的行数据
* @param event 点击事件
*/
onRowClick(row: any, event: MouseEvent) {
this.rowClick.emit(row);
// 如果启用了行展开功能,则切换展开状态
if (this.expandableRows) {
if (this.expandedRows.has(row)) {
this.expandedRows.delete(row);
} else {
this.expandedRows.add(row);
}
}
}
/**
* 处理行选择变更
* @param row 行数据
* @param event 变更事件
*/
onRowSelectChange(row: any, event: Event) {
event.stopPropagation(); // 防止触发行点击事件
const checkbox = event.target as HTMLInputElement;
if (checkbox.checked) {
this.selectedRows.add(row);
} else {
this.selectedRows.delete(row);
}
this.rowSelect.emit(Array.from(this.selectedRows));
}
/**
* 处理全选/取消全选
* @param event 变更事件
*/
onSelectAll(event: Event) {
const checkbox = event.target as HTMLInputElement;
if (checkbox.checked) {
this.data.forEach(row => this.selectedRows.add(row));
} else {
this.selectedRows.clear();
}
this.rowSelect.emit(Array.from(this.selectedRows));
}
/**
* 检查行是否被选择
* @param row 行数据
*/
isSelected(row: any): boolean {
return this.selectedRows.has(row);
}
/**
* 获取全选状态
* 如果所有行都被选中,返回true;
* 如果部分行被选中,返回null(indeterminate状态);
* 如果没有行被选中,返回false。
*/
get selectAllState(): boolean | null {
if (this.data.length === 0) return false;
if (this.selectedRows.size === 0) return false;
if (this.selectedRows.size === this.data.length) return true;
return null; // indeterminate state
}
/**
* 检查行是否展开
* @param row 行数据
*/
isExpanded(row: any): boolean {
return this.expandedRows.has(row);
}
/**
* 格式化单元格值
* @param column 列配置
* @param row 行数据
*/
getCellValue(column: TableColumn, row: any): any {
const value = row[column.field];
// 使用列格式化函数(如果有)
if (column.format) {
return column.format(value);
}
// 根据列类型进行格式化
switch (column.type) {
case 'date’:
return value ? new Date(value).toLocaleDateString() : ”;
case 'boolean’:
return value ? '是’ : '否’;
default:
return value;
}
}
/**
* 获取当前分页信息文本
*/
get paginationInfo(): string {
const start = this.currentPageIndex * this.pageSize + 1;
const end = Math.min(start + this.pageSize – 1, this.totalRecords);
return `${start}-${end} / ${this.totalRecords}`;
}
/**
* 获取列对齐样式类
* @param column 列配置
*/
getColumnAlignClass(column: TableColumn): string {
return column.align ? `text-${column.align}` : ”;
}
/**
* 获取当前页数据
*/
get pageData(): any[] {
if (!this.enablePagination) return this.data;
const start = this.currentPageIndex * this.pageSize;
return this.data.slice(start, start + this.pageSize);
}
/**
* 重置所有筛选和分页状态
*/
reset() {
this.searchText = ”;
this.currentPageIndex = 0;
this.sortField = null;
this.sortDirection = 'asc’;
this.selectedRows.clear();
this.expandedRows.clear();
this.searchChange.emit(”);
this.emitPageEvent();
}
}
End File# serbogdanov/project
/**
* 科幻风格HUD (Heads-Up Display)渲染器
* 负责绘制各种科幻元素和动画效果
*/
export class SciFiHUDRenderer {
private context: CanvasRenderingContext2D;
private width: number;
private height: number;
private startTime: number;
private data: any = {};
private animationValues: any = {
scale: 0,
opacity: 0,
progress: 0,
rotation: 0
};
// 颜色方案
private colorSchemes = {
normal: {
primary: 'rgba(0, 149, 255, 0.8)’,
secondary: 'rgba(0, 149, 255, 0.4)’,
background: 'rgba(10, 20, 40, 0.3)’,
text: 'rgba(150, 210, 255, 0.9)’,
highlight: 'rgba(0, 200, 255, 1)’
},
alert: {
primary: 'rgba(255, 60, 50, 0.8)’,
secondary: 'rgba(255, 60, 50, 0.4)’,
background: 'rgba(40, 15, 15, 0.3)’,
text: 'rgba(255, 180, 170, 0.9)’,
highlight: 'rgba(255, 100, 70, 1)’
},
success: {
primary: 'rgba(50, 200, 100, 0.8)’,
secondary: 'rgba(50, 200, 100, 0.4)’,
background: 'rgba(15, 30, 20, 0.3)’,
text: 'rgba(170, 255, 200, 0.9)’,
highlight: 'rgba(80, 240, 120, 1)’
},
inactive: {
primary: 'rgba(150, 150, 150, 0.5)’,
secondary: 'rgba(100, 100, 100, 0.3)’,
background: 'rgba(20, 20, 20, 0.3)’,
text: 'rgba(200, 200, 200, 0.7)’,
highlight: 'rgba(180, 180, 180, 0.8)’
}
};
// 当前颜色方案
private currentColorScheme: any;
constructor(context: CanvasRenderingContext2D, width: number, height: number) {
this.context = context;
this.width = width;
this.height = height;
this.startTime = Date.now();
this.currentColorScheme = this.colorSchemes.normal;
}
/**
* 设置HUD数据
* @param data HUD数据对象
*/
setData(data: any): void {
this.data = data;
// 根据模式更新颜色方案
if (data.mode) {
this.currentColorScheme = this.colorSchemes[data.mode] || this.colorSchemes.normal;
}
}
/**
* 渲染HUD帧
*/
render(): void {
const ctx = this.context;
const currentTime = Date.now();
const elapsed = currentTime – this.startTime;
// 清除画布
ctx.clearRect(0, 0, this.width, this.height);
// 更新动画值
this.updateAnimationValues(elapsed);
// 绘制背景
this.drawBackground();
// 绘制边框和装饰元素
this.drawBorder();
this.drawDecorativeElements(elapsed);
// 绘制内容
this.drawContent(elapsed);
}
/**
* 更新动画值
* @param elapsed 经过的时间(毫秒)
*/
private updateAnimationValues(elapsed: number): void {
// 平滑的进入动画
this.animationValues.scale = Math.min(1, elapsed / 500);
this.animationValues.opacity = Math.min(1, elapsed / 700);
// 进度动画(如果有目标值和当前值)
if (this.data.percentage !== undefined) {
const targetProgress = this.data.percentage / 100;
this.animationValues.progress += (targetProgress – this.animationValues.progress) * 0.1;
}
// 旋转动画
this.animationValues.rotation = (elapsed / 3000) % (Math.PI * 2);
}
/**
* 绘制HUD背景
*/
private drawBackground(): void {
const ctx = this.context;
const { width, height } = this;
// 半透明背景
ctx.fillStyle = this.currentColorScheme.background;
ctx.fillRect(0, 0, width, height);
// 网格效果
ctx.strokeStyle = this.currentColorScheme.secondary;
ctx.lineWidth = 0.5;
ctx.globalAlpha = 0.3;
const gridSize = 20;
for (let x = 0; x <= width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y <= height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
/**
* 绘制HUD边框
*/
private drawBorder(): void {
const ctx = this.context;
const { width, height } = this;
ctx.strokeStyle = this.currentColorScheme.primary;
ctx.lineWidth = 2;
// 外边框
ctx.beginPath();
ctx.rect(2, 2, width - 4, height - 4);
ctx.stroke();
// 绘制角落装饰
const cornerSize = 15;
// 左上角
ctx.beginPath();
ctx.moveTo(2, cornerSize);
ctx.lineTo(2, 2);
ctx.lineTo(cornerSize, 2);
ctx.stroke();
// 右上角
ctx.beginPath();
ctx.moveTo(width - cornerSize, 2);
ctx.lineTo(width - 2, 2);
ctx.lineTo(width - 2, cornerSize);
ctx.stroke();
// 右下角
ctx.beginPath();
ctx.moveTo(width - 2, height - cornerSize);
ctx.lineTo(width - 2, height - 2);
ctx.lineTo(width - cornerSize, height - 2);
ctx.stroke();
// 左下角
ctx.beginPath();
ctx.moveTo(cornerSize, height - 2);
ctx.lineTo(2, height - 2);
ctx.lineTo(2, height - cornerSize);
ctx.stroke();
}
/**
* 绘制装饰性元素
* @param elapsed 经过的时间(毫秒)
*/
private drawDecorativeElements(elapsed: number): void {
const ctx = this.context;
const { width, height } = this;
// 标题栏装饰
ctx.fillStyle = this.currentColorScheme.primary;
ctx.fillRect(10, 10, width - 20, 4);
// 底部装饰线
ctx.fillRect(10, height - 14, width - 20, 2);
// 动态旋转的装饰圆圈
const rotation = elapsed / 2000;
const circleRadius = 8;
ctx.save();
ctx.translate(20, 20);
ctx.rotate(rotation);
ctx.beginPath();
ctx.arc(0, 0, circleRadius, 0, Math.PI * 2);
ctx.strokeStyle = this.currentColorScheme.highlight;
ctx.lineWidth = 1.5;
ctx.stroke();
// 内部十字
ctx.beginPath();
ctx.moveTo(-circleRadius, 0);
ctx.lineTo(circleRadius, 0);
ctx.moveTo(0, -circleRadius);
ctx.lineTo(0, circleRadius);
ctx.strokeStyle = this.currentColorScheme.primary;
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
// 另一个旋转圆圈(在右上角)
ctx.save();
ctx.translate(width - 20, 20);
ctx.rotate(-rotation);
ctx.beginPath();
ctx.arc(0, 0, circleRadius, 0, Math.PI * 2);
ctx.strokeStyle = this.currentColorScheme.highlight;
ctx.lineWidth = 1.5;
ctx.stroke();
// 内部十字
ctx.beginPath();
ctx.moveTo(-circleRadius, 0);
ctx.lineTo(circleRadius, 0);
ctx.moveTo(0, -circleRadius);
ctx.lineTo(0, circleRadius);
ctx.strokeStyle = this.currentColorScheme.primary;
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
/**
* 绘制HUD内容
* @param elapsed 经过的时间(毫秒)
*/
private drawContent(elapsed: number): void {
const ctx = this.context;
const { width, height } = this;
// 设置文本样式
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = this.currentColorScheme.text;
// 绘制标题
if (this.data.title) {
ctx.font = 'bold 16px sans-serif';
ctx.fillText(this.data.title, width / 2, 25);
}
// 绘制副标题
if (this.data.subtitle) {
ctx.font = '12px sans-serif';
ctx.fillText(this.data.subtitle, width / 2, 45);
}
// 绘制进度条(如果有百分比数据)
if (this.data.percentage !== undefined) {
this.drawProgressBar(width / 2 - 60, height / 2 - 10, 120, 20, this.animationValues.progress);
// 绘制百分比文本
ctx.font = 'bold 14px sans-serif';
ctx.fillText(`${Math.round(this.data.percentage)}%`, width / 2, height / 2);
// 绘制当前值/目标值(如果有)
if (this.data.currentValue !== undefined && this.data.targetValue !== undefined) {
ctx.font = '12px sans-serif';
ctx.fillText(`${this.data.currentValue}/${this.data.targetValue}`, width / 2, height / 2 + 25);
}
}
// 绘制状态文本
if (this.data.status) {
ctx.font = '12px sans-serif';
ctx.fillText(this.data.status, width / 2, height - 25);
}
// 绘制锁定状态(如果适用)
if (this.data.isLocked) {
this.drawLockIcon(width - 30, height - 30, 16);
}
}
/**
* 绘制进度条
* @param x X坐标
* @param y Y坐标
* @param width 宽度
* @param height 高度
* @param progress 进度值(0-1)
*/
private drawProgressBar(x: number, y: number, width: number, height: number, progress: number): void {
const ctx = this.context;
// 进度条背景
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(x, y, width, height);
// 进度条边框
ctx.strokeStyle = this.currentColorScheme.secondary;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
// 进度部分
const progressWidth = width * progress;
ctx.fillStyle = this.currentColorScheme.primary;
ctx.fillRect(x, y, progressWidth, height);
// 进度条纹理
if (progress > 0) {
ctx.save();
ctx.globalAlpha = 0.4;
ctx.fillStyle = this.currentColorScheme.highlight;
const stripeWidth = 10;
const stripeAngle = Math.PI / 4;
const stripeSpacing = 15;
ctx.beginPath();
for (let i = -width; i < width * 2; i += stripeSpacing) {
const xPos = x + i;
ctx.moveTo(xPos, y);
ctx.lineTo(xPos + height / Math.tan(stripeAngle), y + height);
}
ctx.stroke();
ctx.restore();
}
}
/**
* 绘制锁定图标
* @param x X坐标
* @param y Y坐标
* @param size 图标大小
*/
private drawLockIcon(x: number, y: number, size: number): void {
const ctx = this.context;
ctx.save();
// 锁的底部(矩形部分)
ctx.fillStyle = this.currentColorScheme.primary;
ctx.fillRect(x - size/2, y - size/2, size, size);
// 锁的顶部(弧形部分)
ctx.strokeStyle = this.currentColorScheme.highlight;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y - size/2, size/3, Math.PI, Math.PI * 2);
ctx.stroke();
// 锁的钥匙孔
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.beginPath();
ctx.arc(x, y, size/6, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
End File# serbogdanov/project
# 高科技组件库设计方案
## 概述
本文档详细描述了高科技组件库的设计方案,包含组件的功能特性、UI设计风格、技术实现方案以及后续开发计划。我们的目标是打造一套现代化、高性能且视觉效果极具科技感的Angular组件库,满足企业级应用的需求。
## 设计原则
我们的组件库设计遵循以下核心原则:
1. **科技感** - 所有组件应具有未来科技感的视觉效果,包括霓虹发光边框、全息投影效果、动态粒子背景等
2. **高性能** - 组件应当高效渲染,不引入不必要的性能开销
3. **可定制性** - 提供丰富的配置选项,允许用户自定义外观和行为
4. **响应式设计** - 支持各种屏幕尺寸和设备类型
5. **无障碍访问** - 遵循WCAG准则,确保组件库对所有用户都可用
6. **模块化设计** - 每个组件可独立使用,无不必要的依赖
## 技术栈
- **框架**:Angular 17+
- **渲染技术**:HTML5 Canvas, WebGL, CSS3动画
- **状态管理**:RxJS
- **样式处理**:SCSS with CSS Variables
- **打包工具**:Angular CLI
- **文档工具**:Storybook
## 视觉风格指南
### 色彩系统
基于明暗两套主题,每套主题包含:
- **主色调**:#1a73e8(蓝色系,代表科技)
- **辅助色**:#e33b3b(红)、#4ade80(绿)、#f9b700(黄)
- **中性色**:从#f8f9fa到#202124的灰度梯度
- **特效色**:霓虹蓝光色、全息光谱色
### 排版
- **标题字体**:'Orbitron', sans-serif(科技感强)
- **正文字体**:'Roboto', sans-serif(清晰易读)
- **等宽字体**:'Source Code Pro', monospace(代码显示)
### 视觉元素
- **发光边框**:1-2px描边外围带有扩散光晕
- **全息投影**:多层次半透明元素与光谱渐变
- **玻璃态效果**:磨砂玻璃背景,微弱透明度
- **网格线**:细微网格背景,增强空间感
- **动态元素**:呼吸效果、脉冲动画、粒子流动
## 组件规划
### 第一阶段核心组件
1. **SciFiCard** - 科幻风格卡片
- 带发光边框和立体视觉效果
- 支持头部、内容、底部三区域布局
- 可选动态背景效果
2. **HologramButton** - 全息按钮
- 带有3D立体感和光效
- 支持不同状态:默认、悬停、按下、禁用
- 可自定义发光颜色和强度
3. **NeonInput** - 霓虹输入框
- 键入时边框发光效果
- 支持验证状态视觉反馈
- 带有浮动标签效果
4. **TechRadar** - 科技雷达图
- 动态扫描效果
- 可自定义数据点和区域
- 交互式提示
5. **DataMatrix** - 数据矩阵可视化
- 类似《黑客帝国》码雨效果
- 可用于数据加载状态展示
- 支持自定义文字和颜色
### 第二阶段扩展组件
6. **HolographicChart** - 全息图表
- 3D立体数据可视化
- 支持柱状图、折线图、饼图等
- 带有动态过渡效果
7. **VirtualScroll** - 虚拟滚动列表
- 高效渲染大数据集
- 科技风格滚动条
- 自定义项目模板
8. **TechTimeline** - 科技时间线
- 垂直或水平布局
- 动态连接线效果
- 支持里程碑标记
9. **AIPoweredSearch** - AI搜索组件
- 带有语音输入功能
- 打字时的预测补全
- 结果高亮显示
10. **SciFiDashboard** - 科幻仪表盘
- 模块化布局系统
- 实时数据更新
- 支持拖拽重组
### 第三阶段高级组件
11. **HologramMap** - 3D全息地图
- 基于WebGL的3D地球或地图
- 数据点动态显示
- 缩放和旋转交互
12. **BioAuthUI** - 生物识别UI模拟
- 指纹/面部扫描动画
- 识别过程视觉反馈
- 成功/失败状态展示
13. **Neural Connection** - 神经网络连接可视化
- 动态节点和连接线
- 数据流动画效果
- 交互式节点探索
14. **VirtualAssistant** - 虚拟助手界面
- 语音波形动画
- AI响应状态指示
- 对话界面设计
15. **QuantumState** - 量子态可视化
- 粒子效果展示
- 概率分布动画
- 状态变化过渡效果
## 组件库架构
### 模块结构
```
hightech-ui/
├── core/ # 核心功能和工具
│ ├── animations/ # 通用动画
│ ├── directives/ # 通用指令
│ ├── services/ # 共享服务
│ └── styles/ # 基础样式
├── components/ # 所有组件
│ ├── sci-fi-card/
│ ├── hologram-button/
│ ├── neon-input/
│ └── ...
├── theme/ # 主题系统
│ ├── dark-theme.scss
│ └── light-theme.scss
└── utils/ # 工具函数
├── canvas-utils.ts
├── color-utils.ts
└── ...
```
### 渲染策略
- **简单组件**:使用CSS3和HTML实现
- **中等复杂度**:结合Canvas 2D API提升视觉效果
- **高复杂度**:利用WebGL/Three.js实现3D效果
### 状态管理
- 通过RxJS管理组件内部状态和交互
- 提供`Input()`和`Output()`接口与外部系统交互
- 复杂组件状态提供独立的状态服务
## 技术实现挑战与解决方案
### 挑战1:高性能动画效果
**问题**:科技感组件通常需要复杂动画,可能导致性能问题。
**解决方案**:
- 使用CSS硬件加速属性(transform, opacity)
- 实现动画节流和防抖
- 复杂动画使用`requestAnimationFrame`精确控制
- 非可视区域组件自动暂停动画
### 挑战2:兼容性问题
**问题**:高级CSS和WebGL特性在某些浏览器可能不完全支持。
**解决方案**:
- 实现功能降级策略
- 使用特性检测而非浏览器检测
- 为关键效果提供备选方案
- 清晰文档说明兼容性要求
### 挑战3:可定制性与简便使用之间的平衡
**问题**:既要支持高度定制,又要保持API简洁易用。
**解决方案**:
- 采用合理默认值设计
- 使用分层配置:基础配置 + 高级配置选项
- 提供主题系统统一控制视觉风格
- 组件API设计遵循一致性原则
## 开发路线图
### 阶段1(1-2个月)
- 设计系统基础建设
- 实现5个核心组件
- 建立基本开发文档
- 发布0.1.0版本用于内部测试
### 阶段2(2-3个月)
- 完善核心组件功能
- 实现第二阶段5个扩展组件
- 建立单元测试和集成测试
- 完善组件文档和示例
- 发布0.5.0版本用于受限公开测试
### 阶段3(3-4个月)
- 实现第三阶段5个高级组件
- 优化组件性能和可访问性
- 完成全面的组件文档
- 添加动画和交互示例
- 发布1.0.0正式版本
## 贡献指南
我们欢迎社区贡献,但需要遵循以下原则:
1. **代码风格**:遵循Angular风格指南
2. **测试覆盖**:新组件需有单元测试
3. **文档完整**:提供使用示例和API说明
4. **可访问性**:确保组件支持键盘导航和屏幕阅读器
5. **性能考虑**:避免不必要的计算和重绘
## 总结
本组件库旨在为现代Web应用提供令人惊艳的科技感UI组件,同时保证高性能和良好的用户体验。通过分阶段实现,我们将逐步完善组件功能,并根据社区反馈持续优化。我们相信,这套组件库将为科技感十足的网站和应用提供绝佳的视觉表现力。
# myrta-ds/myrta
# packages/colors/scripts/generate.js
/* eslint-disable no-console */
import fs from 'fs';
import path from 'path';
import prettier from 'prettier';
import { ESLint } from 'eslint';
import chroma from 'chroma-js';
import swatch from '../swatch/index.js';
export const JS_PATH = path.resolve('src/js/swatch.js');
export const SCSS_PATH = path.resolve('src/scss/_swatch.scss');
export const DOCS_PATH = path.resolve('docs', 'swatch.md');
export const SWATCH_BASE_PATH = path.resolve('swatch');
export const SWATCH_JS_PATH = path.resolve('swatch', 'index.js');
// Utility to format code
const formatJS = async (jsStr) => {
const prettierRC = JSON.parse(fs.readFileSync(’.prettierrc’, 'utf8′));
const eslint = new ESLint({
fix: true,
useEslintrc: true,
});
// Format with Prettier
const formattedJS = prettier.format(jsStr, {
…prettierRC,
parser: 'babel’,
});
// Apply ESLint fixes
const [result] = await eslint.lintText(formattedJS);
return result.output || formattedJS;
};
// Utility to format SCSS code
const formatSCSS = (scssStr) => {
const prettierRC = JSON.parse(fs.readFileSync(’.prettierrc’, 'utf8′));
return prettier.format(scssStr, {
…prettierRC,
parser: 'scss’,
});
};
// Format number with 0 decimal places.
const formatNumber = (num) => parseFloat(num.toFixed(0));
// Create and detect dark/light colors
const generateColorInfo = (color) => {
const chromaColor = chroma(color);
const luminance = chromaColor.luminance();
const isDark = luminance < 0.25; // Enhanced threshold for determining dark colors
const contrast = {
white: chroma.contrast(color, '#FFFFFF'),
black: chroma.contrast(color, '#1A1A1A'),
};
// Create text color based on best contrast
const textColor = contrast.white > contrast.black ? '#FFFFFF’ : '#1A1A1A’;
return {
hex: color.toUpperCase(),
rgb: chromaColor.rgb().map(formatNumber),
isDark,
textColor,
};
};
// Generate JS output
const generateJS = async () => {
// Generate JavaScript version of the color swatch
let jsOutput = `/**
* Auto-generated color swatch.
* DO NOT EDIT DIRECTLY.
*/
const swatch = ${JSON.stringify(swatch, null, 2)};n
export default swatch;`;
// Format and write the JS file
const formattedJS = await formatJS(jsOutput);
fs.writeFileSync(JS_PATH, formattedJS);
console.log(`✅ Generated JavaScript color swatch at ${JS_PATH}`);
};
// Generate SCSS output
const generateSCSS = () => {
let scssOutput = '// Auto-generated color swatch variables.n// DO NOT EDIT DIRECTLY.nn’;
// Process each palette
Object.entries(swatch).forEach(([paletteName, palette]) => {
scssOutput += `// ${paletteName} paletten`;
// Process each color in the palette
Object.entries(palette).forEach(([colorName, color]) => {
if (typeof color === 'string’) {
// For base colors (strings)
const varName = `$color-${paletteName}-${colorName}`;
scssOutput += `${varName}: ${color.toUpperCase()};n`;
} else {
// For color objects with shades
Object.entries(color).forEach(([shadeName, shadeValue]) => {
const varName = `$color-${paletteName}-${colorName}-${shadeName}`;
scssOutput += `${varName}: ${shadeValue.toUpperCase()};n`;
});
}
});
scssOutput += 'n’;
});
// Add CSS custom properties (variables)
scssOutput += '// CSS custom propertiesn’;
scssOutput += ’:root {n’;
// Process each palette for CSS variables
Object.entries(swatch).forEach(([paletteName, palette]) => {
scssOutput += ` // ${paletteName} paletten`;
// Process each color in the palette
Object.entries(palette).forEach(([colorName, color]) => {
if (typeof color === 'string’) {
// For base colors (strings)
const varName = `–color-${paletteName}-${colorName}`;
scssOutput += ` ${varName}: ${color.toUpperCase()};n`;
} else {
// For color objects with shades
Object.entries(color).forEach(([shadeName, shadeValue]) => {
const varName = `–color-${paletteName}-${colorName}-${shadeName}`;
scssOutput += ` ${varName}: ${shadeValue.toUpperCase()};n`;
});
}
});
});
scssOutput += ’}n’;
// Format and write the SCSS file
const formattedSCSS = formatSCSS(scssOutput);
fs.writeFileSync(SCSS_PATH, formattedSCSS);
console.log(`✅ Generated SCSS color variables at ${SCSS_PATH}`);
};
// Generate documentation
const generateDocs = () => {
let mdOutput = '# Color SwatchnnAuto-generated color documentation.nn’;
// Process each palette
Object.entries(swatch).forEach(([paletteName, palette]) => {
mdOutput += `## ${paletteName} Palettenn`;
// Process each color in the palette
Object.entries(palette).forEach(([colorName, color]) => {
if (typeof color === 'string’) {
// For base colors (strings)
const colorInfo = generateColorInfo(color);
mdOutput += `### ${colorName}nn`;
mdOutput += `
mdOutput += ` ${colorName}
n`;
mdOutput += ` HEX: ${colorInfo.hex}
n`;
mdOutput += ` RGB: ${colorInfo.rgb.join(’, ’)}n`;
mdOutput += `
nn`;
mdOutput += `CSS Variable: `–color-${paletteName}-${colorName}`nn`;
mdOutput += `SCSS Variable: `$color-${paletteName}-${colorName}`nn`;
} else {
// For color objects with shades
mdOutput += `### ${colorName}nn`;
mdOutput += ’
Object.entries(color).forEach(([shadeName, shadeValue]) => {
const colorInfo = generateColorInfo(shadeValue);
mdOutput += `
mdOutput += ` ${colorName}-${shadeName}
n`;
mdOutput += ` HEX: ${colorInfo.hex}
n`;
mdOutput += ` RGB: ${colorInfo.rgb.join(’, ’)}n`;
mdOutput += `
n`;
});
mdOutput += ’
nn’;
// Add variables reference
mdOutput += 'CSS Variables:nn’;
Object.keys(color).forEach((shadeName) => {
mdOutput += `- `–color-${paletteName}-${colorName}-${shadeName}`n`;
});
mdOutput += 'n’;
mdOutput += 'SCSS Variables:nn’;
Object.keys(color).forEach((shadeName) => {
mdOutput += `- `$color-${paletteName}-${colorName}-${shadeName}`n`;
});
mdOutput += 'n’;
}
});
});
fs.writeFileSync(DOCS_PATH, mdOutput);
console.log(`✅ Generated color documentation at ${DOCS_PATH}`);
};
// Main function to run all generators
const main = async () => {
try {
// Make sure the directories exist
if (!fs.existsSync(path.dirname(JS_PATH))) {
fs.mkdirSync(path.dirname(JS_PATH), { recursive: true });
}
if (!fs.existsSync(path.dirname(SCSS_PATH))) {
fs.mkdirSync(path.dirname(SCSS_PATH), { recursive: true });
}
if (!fs.existsSync(path.dirname(DOCS_PATH))) {
fs.mkdirSync(path.dirname(DOCS_PATH), { recursive: true });
}
// Generate files
await generateJS();
generateSCSS();
generateDocs();
console.log(’✨ All color files generated successfully!’);
} catch (error) {
console.error(’Error generating color files:’, error);
process.exit(1);
}
};
main();
End File# packages/react/src/hooks/useMediaQuery.ts
import { useEffect, useState } from 'react’;
/**
* Media query breakpoints
*/
export const breakpoints = {
xs: '(max-width: 575px)’,
sm: '(min-width: 576px)’,
md: '(min-width: 768px)’,
lg: '(min-width: 992px)’,
xl: '(min-width: 1200px)’,
xxl: '(min-width: 1400px)’,
};
export type Breakpoint = keyof typeof breakpoints;
interface UseMediaQueryOptions {
/* If true, will return false on the server regardless of actual matches */
noSSR?: boolean;
/* Default value to return before the media query is evaluated */
defaultValue?: boolean;
}
/**
* React hook for using media queries in components
*
* @param query The media query string or a breakpoint key
* @param options Configuration options
* @returns Boolean indicating if the media query matches
*
* @example
* // Basic usage with breakpoint
* const isMobile = useMediaQuery(’sm’);
*
* // Custom media query
* const isPrint = useMediaQuery(’print’);
* const isLandscape = useMediaQuery('(orientation: landscape)’);
*
* // With options
* const isDesktop = useMediaQuery(’lg’, { noSSR: true, defaultValue: false });
*/
export function useMediaQuery(
query: string | Breakpoint,
options: UseMediaQueryOptions = {},
): boolean {
const { noSSR = false, defaultValue = false } = options;
const [matches, setMatches] = useState(() => {
// Handle SSR case
if (typeof window === 'undefined’) {
return noSSR ? false : defaultValue;
}
// Resolve the query string
const mediaQuery = breakpoints[query as Breakpoint] || query;
// Initial match
return window.matchMedia(mediaQuery).matches;
});
useEffect(() => {
// Resolve the query string
const mediaQuery = breakpoints[query as Breakpoint] || query;
// Get the media query list
const mediaQueryList = window.matchMedia(mediaQuery);
// Set initial value
setMatches(mediaQueryList.matches);
// Define handler for media query changes
const handler = (event: MediaQueryListEvent) => setMatches(event.matches);
// Add event listener
mediaQueryList.addEventListener(’change’, handler);
// Cleanup
return () => {
mediaQueryList.removeEventListener(’change’, handler);
};
}, [query]);
return matches;
}
End File# myrta-ds/myrta
import { useState, useEffect, RefObject } from 'react’;
/**
* Options for the click outside hook
*/
interface UseClickOutsideOptions {
/**
* Whether the hook is active
* @default true
*/
active?: boolean;
/**
* Callback to execute when click outside occurs
*/
onClickOutside: () => void;
}
/**
* React hook to detect clicks outside of the specified element
*
* @param ref Reference to the element to detect clicks outside of
* @param options Configuration options
*
* @example
* „`jsx
* const MyComponent = () => {
* const ref = useRef(null);
* const [isOpen, setIsOpen] = useState(false);
*
* useClickOutside(ref, {
* active: isOpen,
* onClickOutside: () => setIsOpen(false),
* });
*
* return (
*
* {isOpen && (
*
*
* )}
*
* );
* };
* „`
*/
export function useClickOutside
ref: RefObject
options: UseClickOutsideOptions,
): void {
const { active = true, onClickOutside } = options;
const [initialized, setInitialized] = useState(false);
useEffect(() => {
setInitialized(true);
}, []);
useEffect(() => {
// Skip if not active or not initialized (for SSR compatibility)
if (!active || !initialized) {
return undefined;
}
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
onClickOutside();
}
};
// Add event listener with capture to ensure it runs before other click handlers
document.addEventListener(’mousedown’, handleClickOutside, true);
// Cleanup function
return () => {
document.removeEventListener(’mousedown’, handleClickOutside, true);
};
}, [ref, active, initialized, onClickOutside]);
}
End Fileimport { useRef, useCallback, Dispatch, SetStateAction, useState } from 'react’;
/**
* Options for useDisclosure hook
*/
interface UseDisclosureOptions {
/**
* The default state
* @default false
*/
defaultIsOpen?: boolean;
/**
* Function called when state changes
*/
onStateChange?: (isOpen: boolean) => void;
}
/**
* The return value of useDisclosure
*/
interface UseDisclosureReturn {
/** Current state */
isOpen: boolean;
/** Function to open */
open: () => void;
/** Function to close */
close: () => void;
/** Function to toggle */
toggle: () => void;
/** Function to set state directly */
setOpen: Dispatch
}
/**
* Hook for managing disclosure state (open/close)
* Useful for modals, dropdowns, etc.
*
* @param options Configuration options
* @returns Object with state and handlers
*
* @example
* „`jsx
* function Modal() {
* const { isOpen, open, close } = useDisclosure();
*
* return (
* <>
*
* {isOpen && (
*
*
*
* )}
* >
* );
* }
* „`
*/
export function useDisclosure(
options: UseDisclosureOptions = {}
): UseDisclosureReturn {
const { defaultIsOpen = false, onStateChange } = options;
const [isOpen, setIsOpen] = useState(defaultIsOpen);
const onStateChangeRef = useRef(onStateChange);
// Keep the onStateChange ref up to date
onStateChangeRef.current = onStateChange;
const setOpen = useCallback((value: boolean | ((prevState: boolean) => boolean)) => {
setIsOpen((prev) => {
const next = typeof value === 'function’ ? value(prev) : value;
if (prev !== next) {
onStateChangeRef.current?.(next);
}
return next;
});
}, []);
const open = useCallback(() => setOpen(true), [setOpen]);
const close = useCallback(() => setOpen(false), [setOpen]);
const toggle = useCallback(() => setOpen((prev) => !prev), [setOpen]);
return {
isOpen,
open,
close,
toggle,
setOpen,
};
}
End Fileimport { useEffect, useRef, useState } from 'react’;
/**
* Options for useCopyToClipboard hook
*/
interface UseCopyToClipboardOptions {
/**
* Reset the copied state after this many milliseconds
* @default null (no reset)
*/
resetAfter?: number | null;
/**
* Callback called after copy success
*/
onSuccess?: (text: string) => void;
/**
* Callback called after copy failure
*/
onError?: (error: Error) => void;
}
/**
* The return value of useCopyToClipboard
*/
interface UseCopyToClipboardReturn {
/** The currently copied text or null if nothing copied */
copied: string | null;
/** Is the copy operation in progress */
copying: boolean;
/** Whether the last copy operation succeeded */
success: boolean | null;
/** Function to copy text to clipboard */
copy: (text: string) => Promise
/** Function to clear the copied state */
reset: () => void;
}
/**
* Hook to copy text to clipboard
*
* @param options Configuration options
* @returns Object with copied state and copy function
*
* @example
* „`jsx
* function CopyButton() {
* const { copied, copying, success, copy } = useCopyToClipboard({
* resetAfter: 2000,
* onSuccess: () => toast.success(’Copied to clipboard!’),
* });
*
* return (
*
* );
* }
* „`
*/
export function useCopyToClipboard(
options: UseCopyToClipboardOptions = {}
): UseCopyToClipboardReturn {
const { resetAfter = null, onSuccess, onError } = options;
const [copied, setCopied] = useState
const [copying, setCopying] = useState(false);
const [success, setSuccess] = useState
const resetTimeoutRef = useRef
// Clear timeout on unmount
useEffect(() => {
return () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
}
};
}, []);
const reset = () => {
setCopied(null);
setSuccess(null);
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
};
const copy = async (text: string): Promise
if (!navigator?.clipboard) {
const error = new Error(’Clipboard API not supported’);
onError?.(error);
return false;
}
// Clear any existing reset timeout
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current);
resetTimeoutRef.current = null;
}
setCopying(true);
setSuccess(null);
try {
await navigator.clipboard.writeText(text);
setCopied(text);
setSuccess(true);
onSuccess?.(text);
// Set up reset timeout if specified
if (resetAfter !== null) {
resetTimeoutRef.current = window.setTimeout(reset, resetAfter);
}
return true;
} catch (error) {
setCopied(null);
setSuccess(false);
onError?.(error as Error);
return false;
} finally {
setCopying(false);
}
};
return {
copied,
copying,
success,
copy,
reset,
};
}
End File# packages/react/src/hooks/useCountdown.ts
import { useState, useEffect, useRef, useCallback } from 'react’;
/**
* Options for useCountdown hook
*/
interface UseCountdownOptions {
/**
* Time in milliseconds
*/
milliseconds: number;
/**
* Interval for countdown updates in milliseconds
* @default 1000
*/
interval?: number;
/**
* Whether to start the countdown automatically
* @default true
*/
autoStart?: boolean;
/**
* Callback when countdown reaches zero
*/
onComplete?: () => void;
/**
* Callback on each countdown tick
*/
onTick?: (millisecondsLeft: number) => void;
}
/**
* The return value of useCountdown
*/
interface UseCountdownReturn {
/** Whether the countdown is currently running */
isRunning: boolean;
/** Whether the countdown is complete */
isComplete: boolean;
/** Time left in milliseconds */
millisecondsLeft: number;
/** Function to start/resume the countdown */
start: () => void;
/** Function to pause the countdown */
pause: () => void;
/** Function to stop and reset the countdown */
reset: () => void;
/** Percentage of time elapsed (0-100) */
progress: number;
/** Formatted time left as mm:ss */
formatted: string;
}
/**
* Hook to create a countdown timer
*
* @param options Configuration options
* @returns Object with countdown state and controls
*
* @example
* „`jsx
* function Timer() {
* const {
* isRunning,
* formatted,
* progress,
* start,
* pause,
* reset
* } = useCountdown({
* milliseconds: 60000, // 1 minute
* onComplete: () => console.log(’Countdown finished!’),
* });
*
* return (
*
*
*
*
*
* );
* }
* „`
*/
export function useCountdown(
options: UseCountdownOptions
): UseCountdownReturn {
const {
milliseconds,
interval = 1000,
autoStart = true,
onComplete,
onTick,
} = options;
const [millisecondsLeft, setMillisecondsLeft] = useState(milliseconds);
const [isRunning, setIsRunning] = useState(autoStart);
const [isComplete, setIsComplete] = useState(false);
const intervalRef = useRef
const startTimeRef = useRef
const timeRemainingRef = useRef
// Format time as mm:ss
const formatTime = (ms: number): string => {
const totalSeconds = Math.ceil(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, '0′)}:${seconds.toString().padStart(2, '0′)}`;
};
// Calculate progress percentage
const calculateProgress = (ms: number): number => {
return ((milliseconds – ms) / milliseconds) * 100;
};
const stop = useCallback(() => {
if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsRunning(false);
}, []);
const reset = useCallback(() => {
stop();
setMillisecondsLeft(milliseconds);
timeRemainingRef.current = milliseconds;
setIsComplete(false);
}, [milliseconds, stop]);
const tick = useCallback(() => {
const currentTime = Date.now();
const elapsedTime = currentTime – startTimeRef.current;
startTimeRef.current = currentTime;
const newTimeRemaining = Math.max(0, timeRemainingRef.current – elapsedTime);
timeRemainingRef.current = newTimeRemaining;
setMillisecondsLeft(newTimeRemaining);
onTick?.(newTimeRemaining);
if (newTimeRemaining <= 0) {
stop();
setIsComplete(true);
onComplete?.();
}
}, [onComplete, onTick, stop]);
const start = useCallback(() => {
if (isRunning || isComplete) return;
setIsRunning(true);
startTimeRef.current = Date.now();
// Clear any existing interval to be safe
if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current);
}
// Start the interval
intervalRef.current = window.setInterval(tick, interval);
}, [interval, isComplete, isRunning, tick]);
const pause = useCallback(() => {
stop();
}, [stop]);
// Set up interval when running
useEffect(() => {
if (isRunning) {
startTimeRef.current = Date.now();
intervalRef.current = window.setInterval(tick, interval);
}
return () => {
if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current);
}
};
}, [interval, isRunning, tick]);
return {
isRunning,
isComplete,
millisecondsLeft,
start,
pause,
reset,
progress: calculateProgress(millisecondsLeft),
formatted: formatTime(millisecondsLeft),
};
}
End Fileimport React, { useState, useRef, useEffect } from 'react’;
import clsx from 'clsx’;
import { useId } from ’../../hooks/useId’;
import { Box, BoxProps } from ’../Box/Box’;
import ’./Tooltip.scss’;
export interface TooltipProps extends BoxProps {
/**
* Content to display inside the tooltip
*/
content: React.ReactNode;
/**
* Tooltip position relative to the trigger
* @default 'top’
*/
position?: 'top’ | 'right’ | 'bottom’ | 'left’;
/**
* Trigger element to hook the tooltip to
*/
children: React.ReactElement;
/**
* Tooltip width in pixels
* @default 200
*/
width?: number;
/**
* Delay before showing the tooltip in milliseconds
* @default 200
*/
showDelay?: number;
/**
* Delay before hiding the tooltip in milliseconds
* @default 100
*/
hideDelay?: number;
/**
* Whether the tooltip should show on click instead of hover
* @default false
*/
clickTrigger?: boolean;
/**
* Whether the tooltip should be visible initially
* @default false
*/
defaultVisible?: boolean;
/**
* Whether the tooltip is disabled
* @default false
*/
disabled?: boolean;
}
export const Tooltip: React.FC
content,
position = 'top’,
children,
width = 200,
showDelay = 200,
hideDelay = 100,
clickTrigger = false,
defaultVisible = false,
disabled = false,
className,
…props
}) => {
const [visible, setVisible] = useState(defaultVisible);
const [tooltipStyle, setTooltipStyle] = useState
const triggerRef = useRef
const tooltipRef = useRef
const timeoutRef = useRef
const tooltipId = useId();
const calculatePosition = () => {
if (!triggerRef.current || !tooltipRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
let top, left;
switch (position) {
case 'top’:
top = triggerRect.top + scrollTop – tooltipRect.height – 8;
left = triggerRect.left + scrollLeft + (triggerRect.width / 2) – (tooltipRect.width / 2);
break;
case 'right’:
top = triggerRect.top + scrollTop + (triggerRect.height / 2) – (tooltipRect.height / 2);
left = triggerRect.right + scrollLeft + 8;
break;
case 'bottom’:
top = triggerRect.bottom + scrollTop + 8;
left = triggerRect.left + scrollLeft + (triggerRect.width / 2) – (tooltipRect.width / 2);
break;
case 'left’:
top = triggerRect.top + scrollTop + (triggerRect.height / 2) – (tooltipRect.height / 2);
left = triggerRect.left + scrollLeft – tooltipRect.width – 8;
break;
}
// Ensure tooltip stays within viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (left < 0) left = 0;
if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth – tooltipRect.width;
}
if (top < 0) top = 0;
if (top + tooltipRect.height > viewportHeight + scrollTop) {
top = viewportHeight + scrollTop – tooltipRect.height;
}
setTooltipStyle({
top: `${top}px`,
left: `${left}px`,
width: `${width}px`,
opacity: 1,
visibility: 'visible’
});
};
const handleShowTooltip = () => {
if (disabled) return;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {
setVisible(true);
}, showDelay);
};
const handleHideTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {
setVisible(false);
}, hideDelay);
};
const handleToggleTooltip = () => {
if (disabled) return;
setVisible(prevVisible => !prevVisible);
};
useEffect(() => {
if (visible) {
calculatePosition();
// Recalculate on window resize
window.addEventListener(’resize’, calculatePosition);
// Recalculate on scroll
window.addEventListener(’scroll’, calculatePosition);
}
return () => {
window.removeEventListener(’resize’, calculatePosition);
window.removeEventListener(’scroll’, calculatePosition);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [visible]);
// Clone the trigger element to add event handlers
const triggerElement = React.cloneElement(children, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node;
// Forward ref if the child has one
const { ref } = children as any;
if (typeof ref === 'function’) ref(node);
else if (ref) ref.current = node;
},
…(clickTrigger
? {
onClick: (e: React.MouseEvent) => {
handleToggleTooltip();
// Call the original onClick if it exists
if (children.props.onClick) children.props.onClick(e);
},
}
: {
onMouseEnter: (e: React.MouseEvent) => {
handleShowTooltip();
// Call the original onMouseEnter if it exists
if (children.props.onMouseEnter) children.props.onMouseEnter(e);
},
onMouseLeave: (e: React.MouseEvent) => {
handleHideTooltip();
// Call the original onMouseLeave if it exists
if (children.props.onMouseLeave) children.props.onMouseLeave(e);
},
onFocus: (e: React.FocusEvent) => {
handleShowTooltip();
// Call the original onFocus if it exists
if (children.props.onFocus) children.props.onFocus(e);
},
onBlur: (e: React.FocusEvent) => {
handleHideTooltip();
// Call the original onBlur if it exists
if (children.props.onBlur) children.props.onBlur(e);
},
}),
'aria-describedby’: visible ? tooltipId : undefined,
});
return (
<>
{triggerElement}
{visible && (
{content}
)}
>
);
};
End Fileimport React, { useEffect, useRef, useState } from 'react’;
import clsx from 'clsx’;
import { Icon } from ’../Icon/Icon’;
import { useId } from ’../../hooks/useId’;
import ’./Accordion.scss’;
export interface AccordionProps {
/**
* Unique identifier for the accordion
*/
id?: string;
/**
* Accordion item title
*/
title: React.ReactNode;
/**
* Whether the accordion is expanded by default
* @default false
*/
defaultExpanded?: boolean;
/**
* Whether the accordion is expanded (controlled component)
*/
expanded?: boolean;
/**
* Function called when the expanded state changes
*/
onExpandedChange?: (expanded: boolean) => void;
/**
* Content of the accordion
*/
children: React.ReactNode;
/**
* Additional CSS class for the accordion
*/
className?: string;
/**
* Whether to show the divider between accordion items
* @default true
*/
showDivider?: boolean;
/**
* Custom icon to be displayed instead of the default
*/
icon?: React.ReactNode;
/**
* Additional CSS class for the title button
*/
titleClassName?: string;
/**
* Additional CSS class for the content panel
*/
contentClassName?: string;
/**
* Callback when accordion is focused
*/
onFocus?: React.FocusEventHandler
/**
* Callback when accordion loses focus
*/
onBlur?: React.FocusEventHandler
/**
* Whether the accordion is disabled
* @default false
*/
disabled?: boolean;
}
export const Accordion = React.forwardRef
(
{
id,
title,
defaultExpanded = false,
expanded: expandedProp,
onExpandedChange,
children,
className,
showDivider = true,
icon,
titleClassName,
contentClassName,
onFocus,
onBlur,
disabled = false,
…rest
},
ref,
) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const generatedId = useId();
const accordionId = id || `accordion-${generatedId}`;
const headingId = `${accordionId}-heading`;
const contentId = `${accordionId}-content`;
const contentRef = useRef
const [contentHeight, setContentHeight] = useState
defaultExpanded ? undefined : 0,
);
// Handle controlled component
const expanded = expandedProp !== undefined ? expandedProp : isExpanded;
const handleToggle = () => {
if (disabled) return;
const newExpanded = !expanded;
setIsExpanded(newExpanded);
onExpandedChange?.(newExpanded);
};
// Update height when expanded state changes
useEffect(() => {
if (!contentRef.current) return;
if (expanded) {
const height = contentRef.current.scrollHeight;
setContentHeight(height);
// Remove fixed height after transition to allow for content changes
const timer = setTimeout(() => {
setContentHeight(undefined);
}, 300); // Should match transition duration
return () => clearTimeout(timer);
} else {
// Set current height before collapsing for smooth animation
setContentHeight(contentRef.current.scrollHeight);
// Force a reflow
void contentRef.current.offsetHeight;
// Set to 0 for animation
const timer = setTimeout(() => {
setContentHeight(0);
}, 10);
return () => clearTimeout(timer);
}
}, [expanded]);
return (
);
},
);
Accordion.displayName = 'Accordion’;
End File# packages/react/src/components/Button/Button.tsx
import React from 'react’;
import clsx from 'clsx’;
import ’./Button.scss’;
import { Spinner } from ’../Spinner/Spinner’;
export type ButtonVariant = 'primary’ | 'secondary’ | 'tertiary’ | 'danger’;
export type ButtonSize = 'small’ | 'medium’ | 'large’;
export interface ButtonProps
extends React.ButtonHTMLAttributes
/**
* Button variant
* @default 'primary’
*/
variant?: ButtonVariant;
/**
* Button size
* @default 'medium’
*/
size?: ButtonSize;
/**
* Whether the button extends to fill its container
* @default false
*/
fullWidth?: boolean;
/**
* Icon to display at the start of the button
*/
startIcon?: React.ReactNode;
/**
* Icon to display at the end of the button
*/
endIcon?: React.ReactNode;
/**
* Whether the button is in a loading state
* @default false
*/
loading?: boolean;
/**
* Text to display when in loading state
*/
loadingText?: string;
/**
* CSS class for the loading spinner
*/
spinnerClassName?: string;
/**
* Whether the button should be rendered as a link
*/
href?: string;
/**
* Target attribute for link buttons
*/
target?: string;
/**
* Rel attribute for link buttons
*/
rel?: string;
}
const ButtonRoot = React.forwardRef<
HTMLButtonElement | HTMLAnchorElement,
ButtonProps & { as: React.ElementType }
>(
(
{
as: Component,
className,
variant = 'primary’,
size = 'medium’,
disabled,
fullWidth = false,
startIcon,
endIcon,
loading = false,
loadingText,
spinnerClassName,
children,
…rest
},
ref
) => {
const isDisabled = disabled || loading;
// Use proper DOM props based on the component type
const domProps = Component === 'button’ ? { disabled: isDisabled } : { 'aria-disabled’: isDisabled };
return (
{loading && (
)}
{startIcon && {startIcon}}
{loading && loadingText ? loadingText : children}
{endIcon && {endIcon}}
);
}
);
export const Button = React.forwardRef
(props, ref) => {
if (props.href && !props.disabled) {
return
}
return
}
);
Button.displayName = 'Button’;
End File# myrta-ds/myrta
# packages/react/src/components/Toggle/Toggle.tsx
import React, { useState, useEffect } from 'react’;
import clsx from 'clsx’;
import ’./Toggle.scss’;
import { useId } from ’../../hooks/useId’;
export interface ToggleProps {
/**
* Whether the toggle is checked
*/
checked?: boolean;
/**
* Default checked state when uncontrolled
* @default false
*/
defaultChecked?: boolean;
/**
* Whether the toggle is disabled
* @default false
*/
disabled?: boolean;
/**
* Function called when the toggle state changes
*/
onChange?: (checked: boolean, event: React.ChangeEvent
/**
* Size of the toggle
* @default 'medium’
*/
size?: 'small’ | 'medium’ | 'large’;
/**
* An accessible label for the toggle
*/
label?: string;
/**
* ID for the toggle input
*/
id?: string;
/**
* Additional CSS class for the toggle
*/
className?: string;
/**
* Additional CSS class for the toggle track
*/
trackClassName?: string;
/**
* Additional CSS class for the toggle thumb
*/
thumbClassName?: string;
/**
* Name attribute for the hidden input
*/
name?: string;
/**
* ARIA label for the toggle
*/
'aria-label’?: string;
/**
* ARIA labelledby for the toggle
*/
'aria-labelledby’?: string;
/**
* Loading state
* @default false
*/
loading?: boolean;
/**
* Required state
* @default false
*/
required?: boolean;
}
export const Toggle = React.forwardRef
(
{
checked,
defaultChecked = false,
disabled = false,
onChange,
size = 'medium’,
label,
id,
className,
trackClassName,
thumbClassName,
name,
'aria-label’: ariaLabel,
'aria-labelledby’: ariaLabelledBy,
loading = false,
required = false,
…rest
},
ref
) => {
const [isChecked, setIsChecked] = useState(defaultChecked);
const generatedId = useId();
const toggleId = id || `toggle-${generatedId}`;
// Handle the controlled component case
useEffect(() => {
if (checked !== undefined) {
setIsChecked(checked);
}
}, [checked]);
const handleChange = (event: React.ChangeEvent
if (disabled || loading) return;
const newChecked = event.target.checked;
// Only update internal state for uncontrolled component
if (checked === undefined) {
setIsChecked(newChecked);
}
onChange?.(newChecked, event);
};
return (
{label && {label}}
);
}
);
Toggle.displayName = 'Toggle’;
End Fileimport React, { useState, useRef, useEffect } from 'react’;
import clsx from 'clsx’;
import { useId } from ’../../hooks/useId’;
import ’./Select.scss’;
import { Icon } from ’../Icon/Icon’;
export type SelectOption = {
value: string;
label: React.ReactNode;
disabled?: boolean;
};
export type SelectSize = 'small’ | 'medium’ | 'large’;
export interface SelectProps
extends Omit
/**
* Options for the select dropdown
*/
options: SelectOption[];
/**
* Label for the select field
*/
label?: string;
/**
* Helper text to be displayed below the select
*/
helperText?: string;
/**
* Error message to be displayed below the select
*/
error?: string;
/**
* Size of the select
* @default 'medium’
*/
size?: SelectSize;
/**
* Placeholder text displayed when no option is selected
* @default 'Select an option’
*/
placeholder?: string;
/**
* Called when the selected option changes
*/
onValueChange?: (value: string) => void;
/**
* Whether the select should take the full width of its container
* @default false
*/
fullWidth?: boolean;
/**
* Additional CSS class for the select container
*/
className?: string;
/**
* Additional CSS class for the select element
*/
selectClassName?: string;
/**
* Additional CSS class for the label
*/
labelClassName?: string;
/**
* Whether the select is loading
* @default false
*/
loading?: boolean;
/**
* Loading text to display when loading
* @default 'Loading…’
*/
loadingText?: string;
/**
* Required field indicator
*/
required?: boolean;
}
export const Select = React.forwardRef
(
{
options,
label,
helperText,
error,
size = 'medium’,
placeholder = 'Select an option’,
value,
defaultValue,
onChange,
onValueChange,
fullWidth = false,
className,
selectClassName,
labelClassName,
disabled = false,
loading = false,
loadingText = 'Loading…’,
required = false,
id,
name,
…props
},
ref
) => {
const [selectedValue, setSelectedValue] = useState
value !== undefined ? String(value) : defaultValue !== undefined ? String(defaultValue) : undefined
);
const selectRef = useRef
const generatedId = useId();
const selectId = id || `select-${generatedId}`;
const helperTextId = `helper-text-${generatedId}`;
const errorId = `error-${generatedId}`;
// Update internal state when controlled value changes
useEffect(() => {
if (value !== undefined) {
setSelectedValue(String(value));
}
}, [value]);
const handleChange = (e: React.ChangeEvent
const newValue = e.target.value;
// Only update internal state if it’s an uncontrolled component
if (value === undefined) {
setSelectedValue(newValue);
}
// Call the provided onChange handler
if (onChange) {
onChange(e);
}
// Call the custom onValueChange handler
if (onValueChange) {
onValueChange(newValue);
}
};
return (
{label && (
)}
{error ? (
) : helperText ? (
) : null}
);
}
);
Select.displayName = 'Select’;
End File# myrta-ds/myrta
# packages/react/src/components/Table/Table.tsx
import React from 'react’;
import clsx from 'clsx’;
import ’./Table.scss’;
// Table component
export interface TableProps extends React.TableHTMLAttributes
/**
* Whether the table has zebra-striped rows
* @default false
*/
striped?: boolean;
/**
* Whether the table has borders
* @default false
*/
bordered?: boolean;
/**
* Whether the table’s cells are compact
* @default false
*/
compact?: boolean;
/**
* Whether the table has hover state on rows
* @default false
*/
hoverable?: boolean;
/**
* Whether the table is in a loading state
* @default false
*/
loading?: boolean;
/**
* Width of the table
* @default '100%’
*/
width?: string | number;
/**
* Custom CSS class for table container
*/
wrapperClassName?: string;
}
export const Table = React.forwardRef
(
{
children,
className,
striped = false,
bordered = false,
compact = false,
hoverable = false,
loading = false,
width = '100%’,
wrapperClassName,
…rest
},
ref
) => (
{loading && (
)}
)
);
Table.displayName = 'Table’;
// Table Head component
export interface TableHeadProps extends React.HTMLAttributes
/**
* Whether the header is sticky
* @default false
*/
sticky?: boolean;
}
export const TableHead = React.forwardRef
({ children, className, sticky = false, …rest }, ref) => (
{children}
)
);
TableHead.displayName = 'TableHead’;
// Table Body component
export const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ children, className, …rest }, ref) => (
{children}
));
TableBody.displayName = 'TableBody’;
// Table Row component
export interface TableRowProps extends React.HTMLAttributes
/**
* Whether the row is selected
* @default false
*/
selected?: boolean;
/**
* Whether the row is clickable
* @default false
*/
clickable?: boolean;
/**
* Whether the row is disabled
* @default false
*/
disabled?: boolean;
}
export const TableRow = React.forwardRef
({ children, className, selected = false, clickable = false, disabled = false, …rest }, ref) => (
{children}
)
);
TableRow.displayName = 'TableRow’;
// Table Cell component
export interface TableCellProps extends React.TdHTMLAttributes
/**
* Text alignment within the cell
*/
align?: 'left’ | 'center’ | 'right’;
/**
* Truncate text with ellipsis if it’s too long
* @default false
*/
truncate?: boolean;
/**
* Maximum width of the cell
*/
maxWidth?: string | number;
}
export const TableCell = React.forwardRef
({ children, className, align, truncate = false, maxWidth, …rest }, ref) => (
{children}
)
);
TableCell.displayName = 'TableCell’;
// Table Header Cell component
export interface TableHeaderCellProps extends React.ThHTMLAttributes
/**
* Text alignment within the header cell
*/
align?: 'left’ | 'center’ | 'right’;
/**
* Whether the column is sortable
* @default false
*/
sortable?: boolean;
/**
* Current sort direction for this column
*/
sortDirection?: 'asc’ | 'desc’ | null;
/**
* Width of the column
*/
width?: string | number;
}
export const TableHeaderCell = React.forwardRef
(
{ children, className, align, sortable = false, sortDirection, width, …rest },
ref
) => (
{sortable && (
{sortDirection === 'asc’ ? '▲’ : sortDirection === 'desc’ ? '▼’ : '⇅’}
)}
)
);
TableHeaderCell.displayName = 'TableHeaderCell’;
// Table Footer component
export const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ children, className, …rest }, ref) => (
{children}
));
TableFooter.displayName = 'TableFooter’;
// Table Pagination component
export interface TablePaginationProps extends React.HTMLAttributes
/**
* Total number of items
*/
count: number;
/**
* Number of items per page
* @default 10
*/
rowsPerPage?: number;
/**
* Available options for rows per page
* @default [5, 10, 25]
*/
rowsPerPageOptions?: number[];
/**
* Current page (0-based)
* @default 0
*/
page: number;
/**
* Callback fired when the page changes
*/
onPageChange: (page: number) => void;
/**
* Callback fired when rows per page changes
*/
onRowsPerPageChange?: (rowsPerPage: number) => void;
/**
* Label for the rows per page selector
* @default 'Rows per page:’
*/
labelRowsPerPage?: string;
}
export const TablePagination: React.FC
count,
rowsPerPage = 10,
rowsPerPageOptions = [5, 10, 25],
page,
onPageChange,
onRowsPerPageChange,
labelRowsPerPage = 'Rows per page:’,
className,
…rest
}) => {
const totalPages = Math.ceil(count / rowsPerPage);
const from = page * rowsPerPage + 1;
const to = Math.min(count, (page + 1) * rowsPerPage);
const handlePrevPage = () => {
if (page > 0) {
onPageChange(page – 1);
}
};
const handleNextPage = () => {
if (page < totalPages - 1) {
onPageChange(page + 1);
}
};
const handleRowsPerPageChange = (e: React.ChangeEvent
onRowsPerPageChange?.(Number(e.target.value));
};
return (
);
};
TablePagination.displayName = 'TablePagination’;
End File# packages/react/src/components/Dialog/Dialog.tsx
import React, { useEffect, useRef } from 'react’;
import { createPortal } from 'react-dom’;
import clsx from 'clsx’;
import { useId } from ’../../hooks/useId’;
import ’./Dialog.scss’;
export interface DialogProps {
/**
* Whether the dialog is open
*/
open: boolean;
/**
* Title of the dialog
*/
title?: React.ReactNode;
/**
* Content of the dialog
*/
children: React.ReactNode;
/**
* Function called when the dialog should close
*/
onClose: () => void;
/**
* Additional content for the dialog’s footer
*/
footer?: React.ReactNode;
/**
* CSS class for the dialog container
*/
className?: string;
/**
* CSS class for the dialog content
*/
contentClassName?: string;
/**
* CSS class for the dialog backdrop
*/
backdropClassName?: string;
/**
* Whether to show a close button in the top-right corner
* @default true
*/
showCloseButton?: boolean;
/**
* The size of the dialog
* @default 'medium’
*/
size?: 'small’ | 'medium’ | 'large’ | 'fullscreen’;
/**
* Whether to close the dialog when the backdrop is clicked
* @default true
*/
closeOnBackdropClick?: boolean;
/**
* Whether to close the dialog when Escape key is pressed
* @default true
*/
closeOnEsc?: boolean;
/**
* The maximum height of the dialog content
*/
maxContentHeight?: string;
/**
* The ID of the dialog
*/
id?: string;
/**
* Whether the dialog is in a loading state
* @default false
*/
loading?: boolean;
/**
* Whether to prevent scrolling of the body when the dialog is open
* @default true
*/
preventBodyScroll?: boolean;
}
export const Dialog: React.FC
open,
title,
children,
onClose,
footer,
className,
contentClassName,
backdropClassName,
showCloseButton = true,
size = 'medium’,
closeOnBackdropClick = true,
closeOnEsc = true,
maxContentHeight,
id,
loading = false,
preventBodyScroll = true,
}) => {
const dialogRef = useRef
const generatedId = useId();
const dialogId = id || `dialog-${generatedId}`;
const dialogTitleId = `${dialogId}-title`;
useEffect(() => {
// Focus the dialog when it opens
if (open && dialogRef.current) {
// Set focus after a small delay to ensure the dialog is fully rendered
const timeoutId = setTimeout(() => {
if (dialogRef.current) {
dialogRef.current.focus();
}
}, 50);
return () => clearTimeout(timeoutId);
}
}, [open]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape’ && open && closeOnEsc) {
onClose();
}
};
document.addEventListener(’keydown’, handleKeyDown);
return () => {
document.removeEventListener(’keydown’, handleKeyDown);
};
}, [open, closeOnEsc, onClose]);
useEffect(() => {
if (preventBodyScroll) {
if (open) {
// Store the original overflow style
const originalOverflow = document.body.style.overflow;
// Prevent scrolling on the body
document.body.style.overflow = 'hidden’;
return () => {
// Restore original overflow style
document.body.style.overflow = originalOverflow;
};
}
}
}, [open, preventBodyScroll]);
const handleBackdropClick = (event: React.MouseEvent
if (
closeOnBackdropClick &&
event.target === event.currentTarget
) {
onClose();
}
};
if (!open) return null;
const dialogContent = (
{title && (
{title}
{showCloseButton && (
)}
)}
{children}
{footer &&
}
);
// Use createPortal to render the dialog at the end of the document body
return createPortal(dialogContent, document.body);
};
End File# myrta-ds/myrta
import React, { useState, useRef, useEffect } from 'react’;
import clsx from 'clsx’;
import { useId } from ’../../hooks/useId’;
import ’./TextField.scss’;
export interface TextFieldProps
extends Omit
/**
* Label for the text field
*/
label?: string;
/**
* Helper text to be displayed below the text field
*/
helperText?: string;
/**
* Error message to be displayed below the text field
*/
error?: string;
/**
* Size of the text field
* @default 'medium’
*/
size?: 'small’ | 'medium’ | 'large’;
/**
* Whether the text field should take the full width of its container
* @default false
*/
fullWidth?: boolean;
/**
* Icon to display at the start of the text field
*/
startIcon?: React.ReactNode;
/**
* Icon to display at the end of the text field
*/
endIcon?: React.ReactNode;
/**
* Additional CSS class for the text field container
*/
className?: string;
/**
* Additional CSS class for the input element
*/
inputClassName?: string;
/**
* Additional CSS class for the label
*/
labelClassName?: string;
/**
* Whether the text field is loading
* @default false
*/
loading?: boolean;
/**
* Type of the input
* @default 'text’
*/
type?: string;
/**
* Maximum length of text input
*/
maxLength?: number;
/**
* Whether to show character count
* @default false
*/
showCharacterCount?: boolean;
}
export const TextField = React.forwardRef
(
{
label,
helperText,
error,
size = 'medium’,
fullWidth = false,
startIcon,
endIcon,
className,
inputClassName,
labelClassName,
disabled = false,
loading = false,
type = 'text’,
maxLength,
showCharacterCount = false,
id,
value,
defaultValue,
onChange,
required,
…props
},
ref
) => {
const [inputValue, setInputValue] = useState
value !== undefined
? String(value)
: defaultValue !== undefined
? String(defaultValue)
: ”
);
const inputRef = useRef
const generatedId = useId();
const inputId = id || `text-field-${generatedId}`;
const helperTextId = `helper-text-${generatedId}`;
const errorId = `error-${generatedId}`;
// Update internal state when controlled value changes
useEffect(() => {
if (value !== undefined) {
setInputValue(String(value));
}
}, [value]);
const handleChange = (e: React.ChangeEvent
const newValue = e.target.value;
// Only update internal state if it’s an uncontrolled component
if (value === undefined) {
setInputValue(newValue);
}
if (onChange) {
onChange(e);
}
};
return (
},
className
)}
>
{label && (
)}
)}
{
// Handle both the ref prop and the ref object
if (ref) {
if (typeof ref === 'function’) {
ref(node);
} else {
ref.current = node;
}
}
inputRef.current = node;
}}
id={inputId}
type={type}
value={value !== undefined ? value : inputValue}
onChange={handleChange}
disabled={disabled || loading}
className={clsx(’mds-text-field__input’, inputClassName)}
aria-invalid={Boolean(error)}
aria-describedby={
error
? errorId
: helperText
? helperTextId
: undefined
}
maxLength={maxLength}
required={required}
{…props}
/>
{endIcon && (
)}
{loading && (
)}
) : helperText ? (
) : null}
{(maxLength && showCharacterCount) && (
)}
);
}
);
TextField.displayName = 'TextField’;
End File# myrta-ds/myrta
import React, { useState, useRef, useEffect } from 'react’;
import clsx from 'clsx’;
import { useId } from ’../../hooks/useId’;
import ’./Tabs.scss’;
export interface TabProps extends React.ButtonHTMLAttributes
/**
* Value of the tab, used for selection
*/
value: string;
/**
* Whether the tab is disabled
* @default false
*/
disabled?: boolean;
/**
* Icon to display before the label
*/
icon?: React.ReactNode;
/**
* Badge or notification indicator
*/
badge?: React.ReactNode;
}
export const Tab = React.forwardRef
(
{
children,
className,
value,
disabled = false,
icon,
badge,
…rest
},
ref
) => {
// This component is not rendered directly
// It serves as a configuration component for TabsRoot
return null;
}
);
Tab.displayName = 'Tab’;
export interface TabPanelProps extends React.HTMLAttributes
/**
* Value of the tab this panel is associated with
*/
value: string;
/**
* Additional CSS class
*/
className?: string;
}
export const TabPanel = React.forwardRef
({ children, className, value, …rest }, ref) => {
// This component is not rendered directly
// It serves as a configuration component for TabsRoot
return null;
}
);
TabPanel.displayName = 'TabPanel’;
export interface TabsProps extends React.HTMLAttributes
/**
* Current active tab value
*/
value: string;
/**
* Callback when tab changes
*/
onChange: (value: string) => void;
/**
* Orientation of the tabs
* @default 'horizontal’
*/
orientation?: 'horizontal’ | 'vertical’;
/**
* Variant of the tabs
* @default 'default’
*/
variant?: 'default’ | 'enclosed’ | 'pills’;
/**
* Size of the tabs
* @default 'medium’
*/
size?: 'small’ | 'medium’ | 'large’;
/**
* Whether tabs should fill the container width
* @default false
*/
fullWidth?: boolean;
/**
* Whether to center the tabs
* @default false
*/
centered?: boolean;
/**
* Custom CSS class for the tabs container
*/
className?: string;
/**
* Custom CSS class for the tab list
*/
tabListClassName?: string;
/**
* Custom CSS class for the tab panels container
*/
tabPanelsClassName?: string;
/**
* Children should be Tab and TabPanel components
*/
children: React.ReactNode;
}
/**
* A component that displays a set of tabs
*/
export const Tabs = React.forwardRef
(
{
children,
value,
onChange,
orientation = 'horizontal’,
variant = 'default’,
size = 'medium’,
fullWidth = false,
centered = false,
className,
tabListClassName,
tabPanelsClassName,
…rest
},
ref
) => {
const [tabs, setPanels] = useState
const [panels, setTabs] = useState
const [indicatorStyle, setIndicatorStyle] = useState
const tabRefs = useRef
// Separate Tab and TabPanel components
useEffect(() => {
const tabsArray: React.ReactElement[] = [];
const panelsArray: React.ReactElement[] = [];
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return;
if (child.type === Tab) {
tabsArray.push(child);
} else if (child.type === TabPanel) {
panelsArray.push(child);
}
});
setPanels(tabsArray);
setTabs(panelsArray);
}, [children]);
// Update indicator position when value changes
useEffect(() => {
const updateIndicator = () => {
const activeTab = tabRefs.current.get(value);
if (!activeTab || !tabListRef.current) return;
const tabRect = activeTab.getBoundingClientRect();
const tabListRect = tabListRef.current.getBoundingClientRect();
if (orientation === 'horizontal’) {
setIndicatorStyle({
left: `${activeTab.offsetLeft}px`,
width: `${tabRect.width}px`,
bottom: '0′,
});
} else {
setIndicatorStyle({
top: `${activeTab.offsetTop}px`,
height: `${tabRect.height}px`,
right: '0′,
});
}
};
updateIndicator();
window.addEventListener(’resize’, updateIndicator);
return () => {
window.removeEventListener(’resize’, updateIndicator);
};
}, [value, orientation, tabs]);
return (
{tabs.map((tab, index) => {
const {
value: tabValue,
disabled,
icon,
badge,
children: tabChildren,
className: tabClassName,
…tabProps
} = tab.props;
const isSelected = value === tabValue;
const tabId = `tab-${generatedId}-${tabValue}`;
const panelId = `panel-${generatedId}-${tabValue}`;
return (
);
})}
const { value: panelValue, children: panelChildren, className: panelClassName, …panelProps } = panel.props;
const isSelected = value === panelValue;
const tabId = `tab-${generatedId}-${panelValue}`;
const panelId = `panel-${generatedId}-${panelValue}`;
if (!isSelected) return null;
return (
{panelChildren}
);
})}
);
}
);
Tabs.displayName = 'Tabs’;
End File# myrta-ds/myrta
# packages/react/src/components/Slider/Slider.tsx
import React, { useRef, useState, useEffect } from 'react’;
import clsx from 'clsx’;
import { useId } from ’../../hooks/useId’;
import ’./Slider.scss’;
export interface SliderProps {
/**
* The current value of the slider
*/
value?: number;
/**
* Default value for uncontrolled component
* @default 0
*/
defaultValue?: number;
/**
* Minimum allowed value
* @default 0
*/
min?: number;
/**
* Maximum allowed value
* @default 100
*/
max?: number;
/**
* Step size
* @default 1
*/
step?: number;
/**
* Callback when value changes
*/
onChange?: (value: number) => void;
/**
* Whether the slider is disabled
* @default false
*/
disabled?: boolean;
/**
* Size of the slider
* @default 'medium’
*/
size?: 'small’ | 'medium’ | 'large’;
/**
* Label for the slider
*/
label?: string;
/**
* Whether to show the current value
* @default false
*/
showValue?: boolean;
/**
* Format function for the displayed value
*/
formatValue?: (value: number) => string;
/**
* Mark specific values on the track
*/
marks?: { value: number; label?: string }[];
/**
* Custom CSS class
*/
className?: string;
/**
* Custom CSS class for the track
*/
trackClassName?: string;
/**
* Custom CSS class for the thumb
*/
thumbClassName?: string;
/**
* ID attribute for the slider
*/
id?: string;
/**
* Callback when dragging starts
*/
onDragStart?: () => void;
/**
* Callback when dragging ends
*/
onDragEnd?: () => void;
}
export const Slider = React.forwardRef
(
{
value: valueProp,
defaultValue = 0,
min = 0,
max = 100,
step = 1,
onChange,
disabled = false,
size = 'medium’,
label,
showValue = false,
formatValue = (value) => value.toString(),
marks,
className,
trackClassName,
thumbClassName,
id,
onDragStart,
onDragEnd,
…props
},
ref
) => {
const [value, setValue] = useState(
valueProp !== undefined ? valueProp : defaultValue
);
const [isDragging, setIsDragging] = useState(false);
const trackRef = useRef
const thumbRef = useRef
const generatedId = useId();
const sliderId = id || `slider-${generatedId}`;
const labelId = `${sliderId}-label`;
const valueId = `${sliderId}-value`;
// Handle controlled component updates
useEffect(() => {
if (valueProp !== undefined) {
setValue(valueProp);
}
}, [valueProp]);
// Calculate percentage for positioning
const percentage = ((value – min) / (max – min)) * 100;
const handleTrackClick = (e: React.MouseEvent
if (disabled || !trackRef.current) return;
const rect = trackRef.current.getBoundingClientRect();
const clickPosition = e.clientX – rect.left;
const percentageClicked = (clickPosition / rect.width) * 100;
const rawValue = min + (percentageClicked / 100) * (max – min);
const snappedValue = Math.round(rawValue / step) * step;
const clampedValue = Math.max(min, Math.min(max, snappedValue));
setValue(clampedValue);
onChange?.(clampedValue);
};
const startDragging = (e: React.MouseEvent | React.TouchEvent) => {
if (disabled) return;
setIsDragging(true);
onDragStart?.();
// Capture initial position
const clientX = 'touches’ in e
? e.touches[0].clientX
: e.clientX;
updateValueFromClientX(clientX);
// Prevent text selection during drag
document.body.style.userSelect = 'none’;
};
const stopDragging = () => {
if (isDragging) {
setIsDragging(false);
onDragEnd?.();
document.body.style.userSelect = ”;
}
};
const updateValueFromClientX = (clientX: number) => {
if (!trackRef.current || !isDragging) return;
const rect = trackRef.current.getBoundingClientRect();
const clickPosition = Math.max(0, Math.min(rect.width, clientX – rect.left));
const percentageClicked = (clickPosition / rect.width) * 100;
const rawValue = min + (percentageClicked / 100) * (max – min);
const snappedValue = Math.round(rawValue / step) * step;
const clampedValue = Math.max(min, Math.min(max, snappedValue));
setValue(clampedValue);
onChange?.(clampedValue);
};
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
updateValueFromClientX(e.clientX);
}
};
const handleTouchMove = (e: TouchEvent) => {
if (isDragging && e.touches.length > 0) {
updateValueFromClientX(e.touches[0].clientX);
}
};
// Set up event listeners for drag
useEffect(() => {
if (isDragging) {
document.addEventListener(’mousemove’, handleMouseMove);
document.addEventListener(’touchmove’, handleTouchMove);
document.addEventListener(’mouseup’, stopDragging);
document.addEventListener(’touchend’, stopDragging);
}
return () => {
document.removeEventListener(’mousemove’, handleMouseMove);
document.removeEventListener(’touchmove’, handleTouchMove);
document.removeEventListener(’mouseup’, stopDragging);
document.removeEventListener(’touchend’, stopDragging);
};
}, [isDragging]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
let newValue = value;
switch (e.key) {
case 'ArrowRight’:
case 'ArrowUp’:
newValue = Math.min(max, value + step);
break;
case 'ArrowLeft’:
case 'ArrowDown’:
newValue = Math.max(min, value – step);
break;
case 'Home’:
newValue = min;
break;
case 'End’:
newValue = max;
break;
default:
return;
}
if (newValue !== value) {
e.preventDefault();
setValue(newValue);
onChange?.(newValue);
}
};
return (
{(label || showValue) && (
)}
{showValue && (
)}
)}
{/* Render marks if provided */}
{marks && marks.length > 0 && (
const markPercentage = ((mark.value – min) / (max – min)) * 100;
return (
})}
style={{ left: `${markPercentage}%` }}
>
{mark.label && (
)}
);
})}
)}
);
}
);
Slider.displayName = 'Slider’;
End Fileimport React, { useState, useRef, useEffect } from 'react’;
import clsx from 'clsx’;
import { useId } from ’../../hooks/useId’;
import ’./Checkbox.scss’;
export interface CheckboxProps extends Omit
/**
* Label for the checkbox
*/
label?: React.ReactNode;
/**
* Additional helper text
*/
helperText?: React.ReactNode;
/**
* Error message
*/
error?: React.ReactNode;
/**
* Whether the checkbox is checked
*/
checked?: boolean;
/**
* Default checked state for uncontrolled component
* @default false
*/
defaultChecked?: boolean;
/**
* Whether the checkbox is in an indeterminate state
* @default false
*/
indeterminate?: boolean;
/**
* Whether the checkbox is disabled
* @default false
*/
disabled?: boolean;
/**
* Callback when the checked state changes
*/
onChange?: (checked: boolean, event: React.ChangeEvent
/**
* Size of the checkbox
* @default 'medium’
*/
size?: 'small’ | 'medium’ | 'large’;
/**
* Additional CSS class for the checkbox container
*/
className?: string;
/**
* Additional CSS class for the checkbox input element
*/
inputClassName?: string;
/**
* Additional CSS class for the checkbox label
*/
labelClassName?: string;
/**
* Name attribute for the input element
*/
name?: string;
/**
* ID attribute for the input element
*/
id?: string;
/**
* Whether the checkbox is in a loading state
* @default false
*/
loading?: boolean;
}
export const Checkbox = React.forwardRef
(
{
label,
helperText,
error,
checked,
defaultChecked = false,
indeterminate = false,
disabled = false,
onChange,
size = 'medium’,
className,
inputClassName,
labelClassName,
name,
id,
loading = false,
…props
},
ref
) => {
const [isChecked, setIsChecked] = useState(
checked !== undefined ? checked : defaultChecked
);
const inputRef = useRef
const generatedId = useId();
const checkboxId = id || `checkbox-${generatedId}`;
const helperTextId = `helper-text-${generatedId}`;
const errorId = `error-${generatedId}`;
// Update internal state when controlled value changes
useEffect(() => {
if (checked !== undefined) {
setIsChecked(checked);
}
}, [checked]);
// Set the indeterminate prop
useEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = indeterminate;
}
}, [indeterminate]);
const handleChange = (event: React.ChangeEvent
if (disabled || loading) return;
const newChecked = event.target.checked;
// Only update internal state for uncontrolled component
if (checked === undefined) {
setIsChecked(newChecked);
}
if (onChange) {
onChange(newChecked, event);
}
};
return (
// Handle both the ref prop and the ref object
if (ref) {
if (typeof ref === 'function’) {
ref(node);
} else {
ref.current = node;
}
}
inputRef.current = node;
}}
type=”checkbox”
id={checkboxId}
name={name}
checked={isChecked}
onChange={handleChange}
disabled={disabled || loading}
className={clsx(’mds-checkbox__input’, inputClassName)}
aria-invalid={Boolean(error)}
aria-describedby={
error
? errorId
: helperText
? helperTextId
: undefined
}
{…props}
/>
}
{label && (
)}
{error ? (
) : helperText ? (
) : null}
);
}
);
Checkbox.displayName = 'Checkbox’;
End File# myrta-ds/myrta
# packages/react/src/components/Menu/Menu.tsx
import React, { useState, useRef, useEffect } from 'react’;
import { createPortal } from 'react-dom’;
import clsx from 'clsx’;
import { useId } from ’../../hooks/useId’;
import { useClickOutside } from ’../../hooks/useClickOutside’;
import ’./Menu.scss’;
export interface MenuProps {
/**
* The trigger element
*/
trigger: React.ReactElement;
/**
* The content of the menu
*/
children: React.ReactNode;
/**
* Whether the menu is open
*/
open?: boolean;
/**
* Callback when the menu should open or close
*/
onOpenChange?: (open: boolean) => void;
/**
* Position of the menu relative to the trigger
* @default 'bottom-start’
*/
placement?: 'top’ | 'top-start’ | 'top-end’ | 'bottom’ | 'bottom-start’ | 'bottom-end’ | 'left’ | 'right’;
/**
* Custom CSS class for the menu
*/
className?: string;
/**
* Width of the menu
*/
width?: number | string;
/**
* Maximum height of the menu before scrolling
*/
maxHeight?: number | string;
/**
* Whether to close the menu when an item is clicked
* @default true
*/
closeOnItemClick?: boolean;
/**
* CSS z-index for the menu
* @default 1000
*/
zIndex?: number;
/**
* Whether to match the width of the menu with the trigger
* @default false
*/
matchWidth?: boolean;
/**
* Distance between the menu and the trigger
* @default 4
*/
offset?: number;
}
export interface MenuItemProps extends React.HTMLAttributes
/**
* Whether the item is disabled
* @default false
*/
disabled?: boolean;
/**
* Icon to display before item text
*/
icon?: React.ReactNode;
/**
* Custom CSS class for the menu item
*/
className?: string;
}
export const MenuItem: React.FC
children,
disabled = false,
icon,
className,
onClick,
…rest
}) => {
const menuContext = useMenuContext();
const handleClick = (e: React.MouseEvent
if (disabled) {
e.preventDefault();
return;
}
// Call the original onClick handler
onClick?.(e);
// Close the menu if closeOnItemClick is true
if (menuContext?.closeOnItemClick) {
menuContext.onClose();
}
};
return (
{icon && {icon}}
{children}
);
};
MenuItem.displayName = 'MenuItem’;
export interface MenuDividerProps extends React.HTMLAttributes
/**
* Custom CSS class for the divider
*/
className?: string;
}
export const MenuDivider: React.FC
className,
…rest
}) => (
);
MenuDivider.displayName = 'MenuDivider’;
export interface MenuGroupProps extends React.HTMLAttributes
/**
* Group title
*/
title?: React.ReactNode;
/**
* Custom CSS class for the group
*/
className?: string;
}
export const MenuGroup: React.FC
title,
children,
className,
…rest
}) => (
}
-
{children}
);
MenuGroup.displayName = 'MenuGroup’;
// Create a context for the menu
interface MenuContextValue {
closeOnItemClick: boolean;
onClose: () => void;
}
const MenuContext = React.createContext
const useMenuContext = () => React.useContext(MenuContext);
export const Menu: React.FC
trigger,
children,
open: openProp,
onOpenChange,
placement = 'bottom-start’,
className,
width,
maxHeight,
closeOnItemClick = true,
zIndex = 1000,
matchWidth = false,
offset = 4,
}) => {
const [isOpen, setIsOpen] = useState(Boolean(openProp));
const [menuStyle, setMenuStyle] = useState
const triggerRef = useRef
const menuRef = useRef
const generatedId = useId();
const menuId = `menu-${generatedId}`;
// Set up click outside handling
useClickOutside(menuRef, {
active: isOpen,
onClickOutside: () => {
setIsOpen(false);
onOpenChange?.(false);
},
});
// Handle controlled component
useEffect(() => {
if (openProp !== undefined) {
setIsOpen(openProp);
}
}, [openProp]);
// Calculate and set menu position
const updateMenuPosition = () => {
if (!triggerRef.current || !menuRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const menuRect = menuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let top, left;
// Calculate position based on placement
switch (placement) {
case 'top’:
top = triggerRect.top – menuRect.height – offset;
left = triggerRect.left + triggerRect.width / 2 – menuRect.width / 2;
break;
case 'top-start’:
top = triggerRect.top – menuRect.height – offset;
left = triggerRect.left;
break;
case 'top-end’:
top = triggerRect.top – menuRect.height – offset;
left = triggerRect.right – menuRect.width;
break;
case 'bottom’:
top = triggerRect.bottom + offset;
left = triggerRect.left + triggerRect.width / 2 – menuRect.width / 2;
break;
case 'bottom-start’:
top = triggerRect.bottom + offset;
left = triggerRect.left;
break;
case 'bottom-end’:
top = triggerRect.bottom + offset;
left = triggerRect.right – menuRect.width;
break;
case 'left’:
top = triggerRect.top + triggerRect.height / 2 – menuRect.height / 2;
left = triggerRect.left – menuRect.width – offset;
break;
case 'right’:
top = triggerRect.top + triggerRect.height / 2 – menuRect.height / 2;
left = triggerRect.right + offset;
break;
default:
top = triggerRect.bottom + offset;
left = triggerRect.left;
}
// Adjust for viewport boundaries
if (left < 0) left = 0;
if (left + menuRect.width > viewportWidth) left = viewportWidth – menuRect.width;
if (top < 0) top = 0;
if (top + menuRect.height > viewportHeight) top = viewportHeight – menuRect.height;
const style: React.CSSProperties = {
position: 'fixed’,
top: `${top}px`,
left: `${left}px`,
zIndex,
};
if (width) {
style.width = typeof width === 'number’ ? `${width}px` : width;
} else if (matchWidth) {
style.width = `${triggerRect.width}px`;
}
if (maxHeight) {
style.maxHeight = typeof maxHeight === 'number’ ? `${maxHeight}px` : maxHeight;
}
setMenuStyle(style);
};
// Update position when menu opens
useEffect(() => {
if (isOpen) {
updateMenuPosition();
// Update position on resize/scroll
window.addEventListener(’resize’, updateMenuPosition);
window.addEventListener(’scroll’, updateMenuPosition);
}
return () => {
window.removeEventListener(’resize’, updateMenuPosition);
window.removeEventListener(’scroll’, updateMenuPosition);
};
}, [isOpen]);
// Toggle menu
const toggleMenu = () => {
const newState = !isOpen;
setIsOpen(newState);
onOpenChange?.(newState);
};
// Close menu
const closeMenu = () => {
setIsOpen(false);
onOpenChange?.(false);
};
// Clone trigger with ref and event handlers
const triggerElement = React.cloneElement(trigger, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node;
// Forward ref if the child has one
const { ref } = trigger as any;
if (typeof ref === 'function’) ref(node);
else if (ref) ref.current = node;
},
onClick: (e: React.MouseEvent) => {
toggleMenu();
// Call the original onClick if it exists
if (trigger.props.onClick) trigger.props.onClick(e);
},
'aria-haspopup’: 'menu’,
'aria-expanded’: isOpen,
'aria-controls’: isOpen ? menuId : undefined,
});
return (
<>
{triggerElement}
{isOpen && createPortal(
,
document.body
)}
>
);
};
Menu.displayName = 'Menu’;
End File# packages/react/src/components/Avatar/Avatar.tsx
import React, { useState } from 'react’;
import clsx from 'clsx’;
import ’./Avatar.scss’;
// Status types for avatar
type AvatarStatus = 'online’ | 'offline’ | 'away’ | 'busy’ | 'invisible’;
// Size variants for avatar
type AvatarSize = 'xs’ | 'small’ | 'medium’ | 'large’ | 'xl’;
// Shape variants for avatar
type AvatarShape = 'circle’ | 'square’ | ’rounded’;
export interface AvatarProps extends React.HTMLAttributes
/**
* Image source for the avatar
*/
src?: string;
/**
* Alt text for the avatar image
*/
alt?: string;
/**
* Text to display when no image is available
*/
name?: string;
/**
* Size of the avatar
* @default 'medium’
*/
size?: AvatarSize;
/**
* Shape of the avatar
* @default 'circle’
*/
shape?: AvatarShape;
/**
* Status indicator for the avatar
*/
status?: AvatarStatus;
/**
* Whether to show a border around the avatar
* @default false
*/
bordered?: boolean;
/**
* Background color for the avatar when displaying initials
*/
backgroundColor?: string;
/**
* Text color for the avatar when displaying initials
*/
textColor?: string;
/**
* Custom CSS class
*/
className?: string;
/**
* Optional icon to display instead of initials when no image is available
*/
icon?: React.ReactNode;
/**
* Callback when avatar fails to load
*/
onError?: (e: React.SyntheticEvent
/**
* Callback when avatar is clicked
*/
onClick?: (e: React.MouseEvent
}
export const Avatar = React.forwardRef
(
{
src,
alt,
name,
size = 'medium’,
shape = 'circle’,
status,
bordered = false,
backgroundColor,
textColor,
className,
icon,
onError,
onClick,
style,
…rest
},
ref
) => {
const [imgError, setImgError] = useState(false);
// Generate initials from name
const getInitials = () => {
if (!name) return ”;
return name
.split(’ ’)
.map(n => n[0])
.join(”)
.toUpperCase()
.substring(0, 2);
};
// Handle image load error
const handleError = (e: React.SyntheticEvent
setImgError(true);
onError?.(e);
};
// Combine styles
const avatarStyle = {
…style,
…(backgroundColor && !src && { backgroundColor }),
…(textColor && !src && { color: textColor }),
};
// Determine if we should render the image
const shouldRenderImage = src && !imgError;
return (
{shouldRenderImage ? (
) : icon ? (
) : (
)}
{status && (
)} );
}
);
Avatar.displayName = 'Avatar’;
End File# packages/react/src/components/Card/Card.tsx
import React from 'react’;
import clsx from 'clsx’;
import ’./Card.scss’;
export interface CardProps extends React.HTMLAttributes
/**
* Variant of the card
* @default 'outlined’
*/
variant?: 'outlined’ | 'elevated’ | 'filled’;
/**
* Whether the card has a hover effect
* @default false
*/
hoverable?: boolean;
/**
* Whether the card is clickable
* @default false
*/
clickable?: boolean;
/**
* Whether the card is disabled
* @default false
*/
disabled?: boolean;
/**
* Additional CSS class for the card
*/
className?: string;
}
export const Card = React.forwardRef
(
{
children,
variant = 'outlined’,
hoverable = false,
clickable = false,
disabled = false,
className,
…rest
},
ref
) => (
{children}
)
);
Card.displayName = 'Card’;
export interface CardHeaderProps extends React.HTMLAttributes
/**
* Title of the card
*/
title?: React.ReactNode;
/**
* Subtitle of the card
*/
subtitle?: React.ReactNode;
/**
* Action element to display in the header
*/
action?: React.ReactNode;
/**
* Avatar or icon to display in the header
*/
avatar?: React.ReactNode;
/**
* Additional CSS class for the header
*/
className?: string;
}
export const CardHeader = React.forwardRef
(
{
title,
subtitle,
action,
avatar,
className,
children,
…rest
},
ref
) => (
{avatar &&
}
}
{subtitle &&
}
{children}
{action &&
}
)
);
CardHeader.displayName = 'CardHeader’;
export interface CardContentProps extends React.HTMLAttributes
/**
* Additional CSS class for the content
*/
className?: string;
}
export const CardContent = React.forwardRef
({ className, children, …rest }, ref) => (
{children}
)
);
CardContent.displayName = 'CardContent’;
export interface CardFooterProps extends React.HTMLAttributes
/**
* Additional CSS class for the footer
*/
className?: string;
/**
* Align the footer content
* @default 'left’
*/
align?: 'left’ | 'center’ | 'right’ | 'space-between’;
}
export const CardFooter = React.forwardRef
(
{
className,
align = 'left’,
children,
…rest
},
ref
) => (
{children}
)
);
CardFooter.displayName = 'CardFooter’;
export interface CardMediaProps extends React.HTMLAttributes
/**
* Image source
*/
image?: string;
/**
* Alt text for the image
*/
alt?: string;
/**
* Height of the media area
*/
height?: number | string;
/**
* Position of the background image
* @default 'center’
*/
position?: string;
/**
* Title overlay on the media
*/
title?: React.ReactNode;
/**
* Additional CSS class for the media
*/
className?: string;
}
export const CardMedia = React.forwardRef
(
{
image,
alt,
height = 200,
position = 'center’,
title,
className,
children,
style,
…rest
},
ref
) => {
const mediaStyle = {
…style,
height: typeof height === 'number’ ? `${height}px` : height,
backgroundImage: image ? `url(${image})` : undefined,
backgroundPosition: position,
};
return (
{children}
{title &&
}
);
}
);
CardMedia.displayName = 'CardMedia’;
export interface CardDividerProps extends React.HTMLAttributes
/**
* Whether the divider is vertical
* @default false
*/
vertical?: boolean;
/**
* Additional CSS class for the divider
*/
className?: string;
}
export const CardDivider = React.forwardRef
({ vertical = false, className, …rest }, ref) => (
)
);
CardDivider.displayName = 'CardDivider’;
End Fileimport { Component, ElementType, HTMLAttributes, MouseEvent, ReactNode } from 'react’;
import clsx from 'clsx’;
interface SpinnerProps extends HTMLAttributes
/**
* Custom spinner size
*/
size?: 'small’ | 'medium’ | 'large’;
/**
* Change the color of the spinner
*/
color?: string;
/**
* Display a text message along the spinner
*/
message?: string;
/**
* Custom classes to apply to the spinner
*/
className?: string;
/**
* Display the spinner as an inline element
*/
inline?: boolean;
/**
* Whether to center the spinner
*/
centered?: boolean;
/**
* Spinner overlay mode with backdrop
*/
overlay?: boolean;
/**
* Custom icon for the spinner
*/
customIcon?: ReactNode;
/**
* Icon to display instead of spinner
*/
icon?: string;
/**
* Whether the spinner should take up the entire height of the parent
*/
fullHeight?: boolean;
/**
* Aria label for accessibility
*/
'aria-label’?: string;
/**
* Component to render as
*/
as?: ElementType;
/**
* Whether the spinner should spin faster
*/
speedUp?: boolean;
/**
* Called when spinner is clicked
*/
onClick?: (event: MouseEvent
}
class Spinner extends Component
render() {
const {
size = 'medium’,
color,
message,
className,
inline = false,
centered = false,
overlay = false,
customIcon,
icon,
fullHeight = false,
'aria-label’: ariaLabel = 'Loading’,
as: Component = 'div’,
speedUp = false,
onClick,
…rest
} = this.props;
const spinnerClasses = clsx(
'mds-spinner’,
`mds-spinner–${size}`,
{
'mds-spinner–inline’: inline,
'mds-spinner–centered’: centered,
'mds-spinner–overlay’: overlay,
'mds-spinner–with-message’: !!message,
'mds-spinner–full-height’: fullHeight,
'mds-spinner–speed-up’: speedUp,
},
className
);
const spinnerStyle = color ? { ’–spinner-color’: color } as React.CSSProperties : undefined;
return (
) : icon ? (
) : (
)}
{message &&
}
{ariaLabel}
);
}
}
export { Spinner, SpinnerProps };
End File# myrta-ds/myrta
# packages/react/src/components/Radio/Radio.tsx
import React, { useState, useRef, useEffect } from 'react’;
import clsx from 'clsx’;
import { useId } from ’../../hooks/useId’;
import ’./Radio.scss’;
export interface RadioProps
extends Omit
/**
* Label for the radio
*/
label?: React.ReactNode;
/**
* Helper text to display
*/
helperText?: React.ReactNode;
/**
* Error message to display
*/
error?: React.ReactNode;
/**
* Whether the radio is checked
*/
checked?: boolean;
/**
* Default checked state for uncontrolled component
* @default false
*/
defaultChecked?: boolean;
/**
* Whether the radio is disabled
* @default false
*/
disabled?: boolean;
/**
* Callback when checked state changes
*/
onChange?: (checked: boolean, event: React.ChangeEvent
/**
* Size of the radio
* @default 'medium’
*/
size?: 'small’ | 'medium’ | 'large’;
/**
* CSS class for the radio container
*/
className?: string;
/**
* CSS class for the radio input
*/
inputClassName?: string;
/**
* CSS class for the radio label
*/
labelClassName?: string;
/**
* Name for the radio input
*/
name?: string;
/**
* ID for the radio input
*/
id?: string;
/**
* Whether the radio is in a loading state
* @default false
*/
loading?: boolean;
/**
* Value of the radio
*/
value?: string | number;
}
export const Radio = React.forwardRef
(
{
label,
helperText,
error,
checked,
defaultChecked = false,
disabled = false,
onChange,
size = 'medium’,
className,
inputClassName,
labelClassName,
name,
id,
loading = false,
value,
…props
},
ref
) => {
const [isChecked, setIsChecked] = useState(
checked !== undefined ? checked : defaultChecked
);
const inputRef = useRef
const generatedId = useId();
const radioId = id || `radio-${generatedId}`;
const helperTextId = `helper-text-${generatedId}`;
const errorId = `error-${generatedId}`;
// Update internal state when controlled value changes
useEffect(() => {
if (checked !== undefined) {
setIsChecked(checked);
}
}, [checked]);
const handleChange = (event: React.ChangeEvent
if (disabled || loading) return;
const newChecked = event.target.checked;
// Only update internal state for uncontrolled component
if (checked === undefined) {
setIsChecked(newChecked);
}
if (onChange) {
onChange(newChecked, event);
}
};
return (
// Handle both the ref prop and the ref object
if (ref) {
if (typeof ref === 'function’) {
ref(node);
} else {
ref.current = node;
}
}
inputRef.current = node;
}}
type=”radio”
id={radioId}
name={name}
checked={isChecked}
onChange={handleChange}
disabled={disabled || loading}
className={clsx(’mds-radio__input’, inputClassName)}
aria-invalid={Boolean(error)}
aria-describedby={
error
? errorId
: helperText
? helperTextId
: undefined
}
value={value}
{…props}
/>
}
{label && (
)}
{error ? (
) : helperText ? (
) : null}
);
}
);
Radio.displayName = 'Radio’;
export interface RadioGroupProps extends Omit
/**
* Name attribute to be applied to all radios
*/
name: string;
/**
* Value of the selected radio
*/
value?: string | number;
/**
* Default value for uncontrolled component
*/
defaultValue?: string | number;
/**
* Called when a radio is selected
*/
onChange?: (value: string | number, event: React.ChangeEvent
/**
* CSS class for the radio group
*/
className?: string;
/**
* Orientation of the radio group
* @default 'vertical’
*/
orientation?: 'horizontal’ | 'vertical’;
/**
* Whether the radio group is disabled
* @default false
*/
disabled?: boolean;
/**
* Error message to display
*/
error?: React.ReactNode;
/**
* Label for the radio group
*/
label?: React.ReactNode;
/**
* CSS class for the label
*/
labelClassName?: string;
/**
* Size for all radios in the group
* @default 'medium’
*/
size?: 'small’ | 'medium’ | 'large’;
/**
* Whether the radio group is required
* @default false
*/
required?: boolean;
}
export const RadioGroup = React.forwardRef
(
{
children,
name,
value,
defaultValue,
onChange,
className,
orientation = 'vertical’,
disabled = false,
error,
label,
labelClassName,
size = 'medium’,
required = false,
…rest
},
ref
) => {
const [selectedValue, setSelectedValue] = useState
value !== undefined ? value : defaultValue
);
const generatedId = useId();
const groupId = `radio-group-${generatedId}`;
const errorId = `radio-group-error-${generatedId}`;
// Update internal state when controlled value changes
useEffect(() => {
if (value !== undefined) {
setSelectedValue(value);
}
}, [value]);
const handleChange = (checked: boolean, event: React.ChangeEvent
if (checked) {
const newValue = event.target.value;
// Only update internal state for uncontrolled component
if (value === undefined) {
setSelectedValue(newValue);
}
if (onChange) {
onChange(newValue, event);
}
}
};
// Clone children to pass props
const radioButtons = React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child;
// Only modify Radio components
if (child.type === Radio) {
return React.cloneElement(child, {
name,
size,
disabled: disabled || child.props.disabled,
checked: child.props.value === selectedValue,
onChange: handleChange,
});
}
return child;
});
return (
{label && (
{required && *}
)}
{error && (
)}
);
}
);
RadioGroup.displayName = 'RadioGroup’;
End Fileimport React from 'react’;
import clsx from 'clsx’;
import ’./Badge.scss’;
export interface BadgeProps extends React.HTMLAttributes
/**
* Content to be displayed inside the badge
*/
content?: React.ReactNode;
/**
* Maximum count to show (e.g., 99+)
* @default 99
*/
max?: number;
/**
* Badge color variant
* @default 'primary’
*/
variant?: 'primary’ | 'secondary’ | 'success’ | 'warning’ | 'error’ | 'info’;
/**
* Badge size
* @default 'medium’
*/
size?: 'small’ | 'medium’ | 'large’;
/**
* Whether the badge is a dot style (without content)
* @default false
*/
dot?: boolean;
/**
* Position of the badge relative to its children
* @default 'top-right’
*/
position?: 'top-right’ | 'top-left’ | 'bottom-right’ | 'bottom-left’;
/**
* Whether to show the badge even when content is zero
* @default false
*/
showZero?: boolean;
/**
* Whether the badge should be visible
* @default true
*/
visible?: boolean;
/**
* Horizontal offset of the badge
* @default 0
*/
offsetX?: number;
/**
* Vertical offset of the badge
* @default 0
*/
offsetY?: number;
/**
* Whether the badge is standalone (not positioned relative to children)
* @default false
*/
standalone?: boolean;
/**
* Elements that the badge wraps
*/
children?: React.ReactNode;
}
export const Badge = React.forwardRef
(
{
content,
max = 99,
variant = 'primary’,
size = 'medium’,
dot = false,
position = 'top-right’,
showZero = false,
visible = true,
offsetX = 0,
offsetY = 0,
standalone = false,
children,
className,
style,
…rest
},
ref
) => {
// Determine if the badge should be shown
const shouldShowBadge = () => {
if (!visible) return false;
if (dot) return true;
if (content === 0 || content === '0′) return showZero;
return Boolean(content);
};
// Format the content for the badge
const formattedContent = () => {
if (dot) return null;
if (typeof content === 'number’ && content > max) {
return `${max}+`;
}
return content;
};
// Create style with offsets
const badgeStyle = {
…style,
…(offsetX && { ’–badge-offset-x’: `${offsetX}px` }),
…(offsetY && { ’–badge-offset-y’: `${offsetY}px` }),
};
// For standalone badge, just render the badge itself
if (standalone) {
return (
{formattedContent()}
);
}
// For badge with children, render the badge positioned relative to children
return (
{children}
{shouldShowBadge() && (
{formattedContent()}
)}
);
}
);
Badge.displayName = 'Badge’;
End Fileimport React, { useId } from 'react’;
import clsx from 'clsx’;
// Icon type definition
export type IconName =
| 'alert-circle’
| 'alert-triangle’
| 'arrow-down’
| 'arrow-left’
| 'arrow-right’
| 'arrow-up’
| 'bell’
| 'calendar’
| 'check’
| 'check-circle’
| 'chevron-down’
| 'chevron-left’
| 'chevron-right’
| 'chevron-up’
| 'clock’
| 'close’
| 'download’
| 'edit’
| 'eye’
| 'eye-off’
| 'file’
| 'folder’
| 'globe’
| 'heart’
| 'home’
| 'info’
| 'link’
| 'lock’
| 'mail’
| 'menu’
| 'message’
| 'more-horizontal’
| 'more-vertical’
| 'phone’
| 'plus’
| 'search’
| 'settings’
| 'share’
| 'star’
| 'trash’
| 'unlock’
| 'upload’
| 'user’
| 'users’
| 'x’;
// Icon size definition
export type IconSize = 'small’ | 'medium’ | 'large’ | number;
// Icon props interface
export interface IconProps {
/**
* Name of the icon
*/
name: IconName;
/**
* Size of the icon
* @default 'medium’
*/
size?: IconSize;
/**
* Color of the icon
* @default 'currentColor’
*/
color?: string;
/**
* Additional CSS class
*/
className?: string;
/**
* ARIA label for accessibility
*/
'aria-label’?: string;
/**
* ARIA hidden attribute
* @default false when aria-label is provided, otherwise true
*/
'aria-hidden’?: boolean;
/**
* Viewbox for the SVG
* @default '0 0 24 24′
*/
viewBox?: string;
/**
* Custom stroke width
* @default 2
*/
strokeWidth?: number;
/**
* HTML title for the icon
*/
title?: string;
/**
* Whether the icon should spin
* @default false
*/
spin?: boolean;
/**
* Whether the icon should pulse
* @default false
*/
pulse?: boolean;
/**
* Custom CSS style
*/
style?: React.CSSProperties;
/**
* Custom SVG path
*/
customPath?: string;
/**
* Custom SVG content
*/
customSvg?: React.ReactNode;
/**
* onClick event handler
*/
onClick?: (event: React.MouseEvent
}
// Icon component
export const Icon: React.FC
name,
size = 'medium’,
color = 'currentColor’,
className,
'aria-label’: ariaLabel,
'aria-hidden’: ariaHiddenProp,
viewBox = '0 0 24 24′,
strokeWidth = 2,
title,
spin = false,
pulse = false,
style,
customPath,
customSvg,
onClick,
…props
}) => {
// Generate unique IDs for accessibility
const titleId = useId();
// Determine if the icon should be hidden from screen readers
const ariaHidden = ariaHiddenProp ?? (ariaLabel ? false : true);
// Convert size to pixel value if needed
const getSizeInPixels = () => {
if (typeof size === 'number’) return size;
switch (size) {
case 'small’: return 16;
case 'large’: return 32;
case 'medium’:
default: return 24;
}
};
// Get the icon path based on name
const getIconPath = (): string => {
if (customPath) return customPath;
// Return path data based on icon name
switch (name) {
case 'alert-circle’:
return 'M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM11 7h2v7h-2V7zm0 8h2v2h-2v-2z’;
case 'alert-triangle’:
return 'M12.866 3l9.526 16.5a1 1 0 0 1-.866 1.5H2.474a1 1 0 0 1-.866-1.5L11.134 3a1 1 0 0 1 1.732 0zM11 16v2h2v-2h-2zm0-7v5h2V9h-2z’;
case 'arrow-down’:
return 'M12 4v12.25L17.25 11l1.42 1.41L12 19.08l-6.67-6.67L6.75 11 12 16.25V4h2z’;
case 'arrow-left’:
return 'M7.75 12L13 6.75 11.59 5.34 4.92 12l6.67 6.66L13 17.25 7.75 12z’;
case 'arrow-right’:
return 'M16.25 12L11 17.25l1.41 1.41L19.08 12l-6.67-6.66L11 6.75 16.25 12z’;
case 'arrow-up’:
return 'M12 20V7.75L6.75 13 5.34 11.59 12 4.92l6.66 6.67L17.25 13 12 7.75V20h-2z’;
case 'bell’:
return 'M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z’;
case 'calendar’:
return 'M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2zM7 12h5v5H7v-5z’;
case 'check’:
return 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z’;
case 'check-circle’:
return 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z’;
case 'chevron-down’:
return 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z’;
case 'chevron-left’:
return 'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z’;
case 'chevron-right’:
return 'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z’;
case 'chevron-up’:
return 'M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z’;
case 'clock’:
return 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z’;
case 'close’:
return 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z’;
case 'download’:
return 'M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z’;
case 'edit’:
return 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z’;
case 'eye’:
return 'M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z’;
case 'eye-off’:
return 'M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z’;
case 'file’:
return 'M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z’;
case 'folder’:
return 'M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z’;
case 'globe’:
return 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z’;
case 'heart’:
return 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z’;
case 'home’:
return 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z’;
case 'info’:
return 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z’;
case 'link’:
return 'M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z’;
case 'lock’:
return 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z’;
case 'mail’:
return 'M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z’;
case 'menu’:
return 'M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z’;
case 'message’:
return 'M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z’;
case 'more-horizontal’:
return 'M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z’;
case 'more-vertical’:
return 'M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z’;
case 'phone’:
return 'M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z’;
case 'plus’:
return 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z’;
case 'search’:
return 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z’;
case 'settings’:
return 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z’;
case 'share’:
return 'M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z’;
case 'star’:
return 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z’;
case 'trash’:
return 'M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z’;
case 'unlock’:
return 'M12 17c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm6-9h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h1.9c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z’;
case 'upload’:
return 'M5 20h14v-2H5v2zm0-10h4v7h6v-7h4l-7-7-7 7z’;
case 'user’:
return 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z’;
case 'users’:
return 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z’;
case 'x’:
return 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z’;
default:
return ”;
}
};
// Style object with size and animation properties
const sizeInPixels = getSizeInPixels();
const iconStyle: React.CSSProperties = {
…style,
width: sizeInPixels,
height: sizeInPixels,
color,
};
// Default SVG attributes
const svgProps = {
xmlns: 'http://www.w3.org/2000/svg’,
viewBox,
width: sizeInPixels,
height: sizeInPixels,
fill: 'currentColor’,
stroke: 'none’,
className: clsx(
'mds-icon’,
{
'mds-icon–spin’: spin,
'mds-icon–pulse’: pulse,
},
className
),
style: iconStyle,
'aria-hidden’: ariaHidden,
'aria-labelledby’: title ? titleId : undefined,
onClick,
…props,
};
// Render the icon
return (
);
};
End File# packages/react/rollup.config.mjs
import resolve from '@rollup/plugin-node-resolve’;
import commonjs from '@rollup/plugin-commonjs’;
import typescript from '@rollup/plugin-typescript’;
import terser from '@rollup/plugin-terser’;
import external from 'rollup-plugin-peer-deps-external’;
import dts from 'rollup-plugin-dts’;
import sass from 'rollup-plugin-sass’;
import autoprefixer from 'autoprefixer’;
import postcss from 'postcss’;
import url from '@rollup/plugin-url’;
import { readFileSync } from 'fs’;
// Parse package.json
const packageJson = JSON.parse(readFileSync(’./package.json’, 'utf8′));
// Define output directory
const outputDir = 'dist’;
const createPath = path => `${outputDir}/${path}`;
// Configure SASS processing
const sassOptions = {
// Process and insert CSS directly
insert: true,
// Process SASS with PostCSS for autoprefixing
processor: css => postcss([autoprefixer])
.process(css, { from: undefined })
.then(result => result.css)
};
// Main Rollup configuration
export default [
// ESM and CJS bundles (code)
{
input: 'src/index.ts’,
output: [
{
file: createPath(packageJson.module),
format: 'esm’,
sourcemap: true
},
{
file: createPath(packageJson.main),
format: 'cjs’,
sourcemap: true
}
],
plugins: [
// Handle external dependencies
external(),
// Resolve node_modules
resolve(),
// Convert CommonJS modules to ES6
commonjs(),
// Process TypeScript
typescript({
tsconfig: ’./tsconfig.json’,
exclude: [’**/*.test.tsx’, '**/*.test.ts’, '**/*.stories.tsx’]
}),
// Process SASS files
sass(sassOptions),
// Handle assets (limit: 8KB, otherwise use file)
url({
limit: 8 * 1024, // 8KB
include: [’**/*.svg’, '**/*.png’, '**/*.jpg’, '**/*.gif’],
fileName: '[dirname][name][extname]’
}),
// Minify for production
terser()
],
// Explicitly mark React as external
external: [’react’, 'react-dom’]
},
// TypeScript declaration files
{
input: 'src/index.ts’,
output: [
{
file: createPath(packageJson.types),
format: 'esm’
}
],
plugins: [
dts({
tsconfig: ’./tsconfig.json’,
compilerOptions: {
emitDeclarationOnly: true,
}
})
],
external: [/.scss$/]
}
];
End File# myrta-ds/myrta
„use client”;
import {
Card,
CardContent,
Button,
CardFooter,
CardHeader,
CardTitle,
} from „@/components/ui/card”;
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from „@/components/ui/table”;
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from „@/components/ui/select”;
import { Tabs, TabsList, TabsTrigger, TabsContent } from „@/components/ui/tabs”;
import { useCallback, useEffect, useState } from „react”;
import { Input } from „@/components/ui/input”;
import { fetchLogData, filterLogData } from „@/lib/data”;
import { LogData } from „@/lib/definitions”;
import { CopyIcon, LoaderIcon, SearchIcon } from „lucide-react”;
import { Label } from „@/components/ui/label”;
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrev,
PaginationLink,
PaginationEllipsis,
PaginationNext,
} from „@/components/ui/pagination”;
import { useDebounce } from „@/lib/hooks”;
import {
truncateFirstLine,
formatLogDate,
getSeverityClass,
formatStackTrace,
} from „@/lib/utils”;
import { Badge } from „@/components/ui/badge”;
import copyToClipboard from „@/lib/clipboard”;
import { ScrollArea } from „@/components/ui/scroll-area”;
import LogMessageCard from „@/components/logs/log-message-card”;
const DEBOUNCE_MS = 300;
const LOGS_PER_PAGE = 10;
export default function LogsPage() {
// State for logs data and filters
const [logs, setLogs] = useState
const [filteredLogs, setFilteredLogs] = useState
const [isLoading, setIsLoading] = useState
const [selectedLog, setSelectedLog] = useState
const [tableView, setTableView] = useState
// Filters state
const [searchTerm, setSearchTerm] = useState
const [severity, setSeverity] = useState
const [timeRange, setTimeRange] = useState
const [source, setSource] = useState
// Pagination state
const [currentPage, setCurrentPage] = useState
const [totalPages, setTotalPages] = useState
// Debounced search term
const debouncedSearchTerm = useDebounce(searchTerm, DEBOUNCE_MS);
// Load logs data
const loadLogs = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchLogData();
setLogs(data);
setIsLoading(false);
} catch (error) {
console.error(„Failed to fetch log data:”, error);
setIsLoading(false);
}
}, []);
// Initial data load
useEffect(() => {
loadLogs();
}, [loadLogs]);
// Apply filters whenever filter criteria change
useEffect(() => {
const applyFilters = () => {
const filtered = filterLogData(
logs,
debouncedSearchTerm,
severity,
timeRange,
source
);
setFilteredLogs(filtered);
setTotalPages(Math.ceil(filtered.length / LOGS_PER_PAGE));
setCurrentPage(1); // Reset to first page when filters change
// Clear selected log when filters change
setSelectedLog(null);
};
applyFilters();
}, [logs, debouncedSearchTerm, severity, timeRange, source]);
// Handle pagination
const getPaginatedLogs = () => {
const startIndex = (currentPage – 1) * LOGS_PER_PAGE;
const endIndex = startIndex + LOGS_PER_PAGE;
return filteredLogs.slice(startIndex, endIndex);
};
const handleCopySelected = () => {
if (selectedLog) {
copyToClipboard(
`${selectedLog.timestamp} [${selectedLog.severity}] ${selectedLog.message}n${selectedLog.stackTrace}`
);
}
};
// Render loading state
if (isLoading) {
return (
Loading logs…
);
}
const renderPagination = () => {
if (totalPages <= 1) return null;
const renderPageNumbers = () => {
const pages = [];
const showEllipsisStart = currentPage > 3;
const showEllipsisEnd = currentPage < totalPages - 2;
// Always show first page
if (currentPage > 3) {
pages.push(
);
}
// Show ellipsis if needed
if (showEllipsisStart) {
pages.push(
);
}
// Show current page and adjacent pages
for (
let i = Math.max(2, currentPage – 1);
i <= Math.min(totalPages - 1, currentPage + 1);
i++
) {
pages.push(
>
{i}
);
}
// Show ellipsis if needed
if (showEllipsisEnd) {
pages.push(
);
}
// Always show last page
if (currentPage < totalPages - 2 && totalPages > 3) {
pages.push(
{totalPages}
);
}
return pages;
};
return (
isDisabled={currentPage === 1}
/>
{renderPageNumbers()}
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
isDisabled={currentPage === totalPages}
/>
);
};
const paginatedLogs = getPaginatedLogs();
const renderTableView = () => (
{renderPagination()}
);
const renderCardView = () => (
) : (
paginatedLogs.map((log) => (
/>
))
)}
{renderPagination()}
);
return (
System Logs
setSearchTerm(e.target.value)}
className=”pl-10″
/>
{/* Main content area */}
>
{tableView ? renderTableView() : renderCardView()}
Showing {Math.min(filteredLogs.length, LOGS_PER_PAGE)} of{” „}
{filteredLogs.length} logs
{/* Log details panel */}
{selectedLog && (
)}
{selectedLog ? (
{selectedLog.severity}
{selectedLog.stackTrace && (
{formatStackTrace(selectedLog.stackTrace)}
)}
{selectedLog.metadata && (
{JSON.stringify(selectedLog.metadata, null, 2)}
)}
) : (
Select a log entry to view details
)}
);
}
End File# apps/dashboard-demo/app/analytics/performance/page.tsx
„use client”;
import { Card, CardContent, CardHeader, CardTitle } from „@/components/ui/card”;
import {
ArrowDownIcon,
ArrowUpIcon,
LoaderIcon,
MoreHorizontalIcon,
} from „lucide-react”;
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from „@/components/ui/select”;
import { useState, useEffect } from „react”;
import { Button } from „@/components/ui/button”;
import { Tabs, TabsList, TabsTrigger } from „@/components/ui/tabs”;
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from „@/components/ui/table”;
import { Progress } from „@/components/ui/progress”;
import { TimeSeriesChart } from „@/components/charts/time-series-chart”;
import { BarChart } from „@/components/charts/bar-chart”;
import { DonutChart } from „@/components/charts/donut-chart”;
import {
fetchPerformanceData,
fetchPerformanceRouteData,
fetchPerformanceResources,
} from „@/lib/data”;
import {
PerformanceData,
PerformanceRouteData,
PerformanceResource,
} from „@/lib/definitions”;
import { formatBytes, formatNumber } from „@/lib/utils”;
import ResourcesTable from „@/components/performance/resources-table”;
// Page component
export default function PerformancePage() {
const [timeRange, setTimeRange] = useState
const [isLoading, setIsLoading] = useState
const [performanceData, setPerformanceData] = useState
null
);
const [routeData, setRouteData] = useState
const [resources, setResources] = useState
useEffect(() => {
async function loadData() {
setIsLoading(true);
try {
const perfData = await fetchPerformanceData(timeRange);
const routesData = await fetchPerformanceRouteData(timeRange);
const resourcesData = await fetchPerformanceResources();
setPerformanceData(perfData);
setRouteData(routesData);
setResources(resourcesData);
setIsLoading(false);
} catch (error) {
console.error(„Error loading performance data:”, error);
setIsLoading(false);
}
}
loadData();
}, [timeRange]);
if (isLoading) {
return (
Loading performance data…
);
}
if (!performanceData) {
return (
);
}
return (
Performance Dashboard
{/* Key Metrics Cards */}
}
isGoodWhenNegative={true}
/>
}
isGoodWhenNegative={true}
/>
}
isGoodWhenNegative={true}
/>
}
isGoodWhenNegative={true}
/>
{/* Time Series Charts */}
Page Load Time (seconds)
First Contentful Paint (seconds)
{/* Routes Performance */}
Routes Performance
{/* Charts and Resources */}
Page Sizes by Type
name: item.type,
value: item.size,
}))}
height={300}
valueFormatter={(value) => formatBytes(value)}
/>
Loading Times by Device Type
{/* Resources Table */}
Resource Performance
);
}
// Metric Card Component
function MetricCard({
title,
value,
change,
changeLabel,
isGoodWhenNegative = false,
}: {
title: string;
value: string;
change: number;
changeLabel: string;
isGoodWhenNegative?: boolean;
}) {
// Determine if the change is positive or negative from a business perspective
const isPositiveChange = isGoodWhenNegative ? change < 0 : change > 0;
return (
{change >= 0 ? (
) : (
)}
{Math.abs(change).toFixed(1)}% {changeLabel}
vs. previous period
);
}
End File# apps/dashboard-demo/app/analytics/engagement/page.tsx
„use client”;
import { Card, CardContent, CardHeader, CardTitle } from „@/components/ui/card”;
import {
ArrowDownIcon,
ArrowUpIcon,
LoaderIcon,
MoreHorizontalIcon,
} from „lucide-react”;
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from „@/components/ui/select”;
import { useState, useEffect } from „react”;
import { Button } from „@/components/ui/button”;
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from „@/components/ui/table”;
import { TimeSeriesChart } from „@/components/charts/time-series-chart”;
import { BarChart } from „@/components/charts/bar-chart”;
import { DonutChart } from „@/components/charts/donut-chart”;
import {
fetchEngagementData,
fetchReferrers,
fetchPopularContent,
} from „@/lib/data”;
import { EngagementData, Referrer, ContentItem } from „@/lib/definitions”;
import { formatNumber, formatTime } from „@/lib/utils”;
// Page component
export default function EngagementPage() {
const [timeRange, setTimeRange] = useState
const [isLoading, setIsLoading] = useState
const [engagementData, setEngagementData] = useState
null
);
const [referrers, setReferrers] = useState
const [popularContent, setPopularContent] = useState
useEffect(() => {
async function loadData() {
setIsLoading(true);
try {
const data = await fetchEngagementData(timeRange);
const referrerData = await fetchReferrers(timeRange);
const contentData = await fetchPopularContent(timeRange);
setEngagementData(data);
setReferrers(referrerData);
setPopularContent(contentData);
setIsLoading(false);
} catch (error) {
console.error(„Error loading engagement data:”, error);
setIsLoading(false);
}
}
loadData();
}, [timeRange]);
if (isLoading) {
return (
Loading engagement data…
);
}
if (!engagementData) {
return (
);
}
return (
User Engagement
{/* Key Metrics Cards */}
{/* Time Series Charts */}
Average Session Duration
/>
Bounce Rate Over Time
{/* Charts and Referrers */}
Traffic by Device
/>
{item.name}
{item.value}%
))}
User Engagement by Age Group
/>
{/* Popular Content & Referrers */}
Most Popular Content
Top Referrers
{referrers.map((referrer) => (
{referrer.source}
{formatNumber(referrer.sessions)}
70
? „text-red-500 font-medium”
: referrer.bounceRate > 50
? „text-amber-500 font-medium”
: „text-green-500 font-medium”
}
>
{referrer.bounceRate.toFixed(1)}%
{referrer.conversionRate.toFixed(1)}%
))}
);
}
// Metric Card Component
function MetricCard({
title,
value,
change,
isGoodWhenPositive = true,
}: {
title: string;
value: string;
change: number;
isGoodWhenPositive?: boolean;
}) {
// Determine if the change is positive or negative from a business perspective
const isPositiveChange = isGoodWhenPositive
? change > 0
: change < 0;
return (
{change >= 0 ? (
) : (
)}
{Math.abs(change).toFixed(1)}%
vs. previous period
);
}
End File# apps/dashboard-demo/lib/data.ts
import { addDays, addHours, format, subDays, subHours, subMonths } from „date-fns”;
import {
LogData,
PerformanceData,
PerformanceRouteData,
PerformanceResource,
EngagementData,
Referrer,
ContentItem
} from „./definitions”;
// Mock log data
export async function fetchLogData(): Promise
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 800));
const now = new Date();
const logs: LogData[] = [];
// Generate random logs
for (let i = 0; i < 200; i++) {
const randomHours = Math.floor(Math.random() * 72); // Logs from the last 72 hours
const timestamp = subHours(now, randomHours).toISOString();
const severities = ['info', 'warning', 'error', 'debug'];
const severity = severities[Math.floor(Math.random() * severities.length)];
const sources = ['api', 'frontend', 'backend', 'database'];
const source = sources[Math.floor(Math.random() * sources.length)];
let message = '';
let stackTrace = '';
switch (severity) {
case 'error':
message = getRandomErrorMessage();
stackTrace = getRandomStackTrace();
break;
case 'warning':
message = getRandomWarningMessage();
break;
case 'info':
message = getRandomInfoMessage();
break;
case 'debug':
message = getRandomDebugMessage();
break;
}
// Add metadata for some logs
const metadata = Math.random() > 0.7 ? {
userId: `user_${Math.floor(Math.random() * 10000)}`,
requestId: `req_${Math.random().toString(36).substring(2, 12)}`,
path: `/api/${[’users’, 'products’, 'orders’][Math.floor(Math.random() * 3)]}/${Math.floor(Math.random() * 1000)}`,
browser: [’Chrome’, 'Firefox’, 'Safari’, 'Edge’][Math.floor(Math.random() * 4)],
os: [’Windows’, 'MacOS’, 'Linux’, 'iOS’, 'Android’][Math.floor(Math.random() * 5)]
} : undefined;
logs.push({
id: `log_${i}`,
timestamp,
severity,
source,
message,
stackTrace,
metadata
});
}
// Sort by most recent first
return logs.sort((a, b) => new Date(b.timestamp).getTime() – new Date(a.timestamp).getTime());
}
// Filter log data based on search term and filters
export function filterLogData(
logs: LogData[],
searchTerm: string = ”,
severity: string = 'all’,
timeRange: string = ’24h’,
source: string = 'all’
): LogData[] {
const now = new Date();
let filteredLogs = […logs];
// Filter by time range
if (timeRange !== 'all’) {
let cutoffDate;
switch (timeRange) {
case '1h’:
cutoffDate = subHours(now, 1);
break;
case ’24h’:
cutoffDate = subDays(now, 1);
break;
case '7d’:
cutoffDate = subDays(now, 7);
break;
case ’30d’:
cutoffDate = subDays(now, 30);
break;
default:
cutoffDate = subDays(now, 1); // Default to 24h
}
filteredLogs = filteredLogs.filter(log =>
new Date(log.timestamp) >= cutoffDate
);
}
// Filter by severity
if (severity !== 'all’) {
filteredLogs = filteredLogs.filter(log =>
log.severity.toLowerCase() === severity.toLowerCase()
);
}
// Filter by source
if (source !== 'all’) {
filteredLogs = filteredLogs.filter(log =>
log.source.toLowerCase() === source.toLowerCase()
);
}
// Filter by search term
if (searchTerm.trim() !== ”) {
const term = searchTerm.toLowerCase();
filteredLogs = filteredLogs.filter(log =>
log.message.toLowerCase().includes(term) ||
log.source.toLowerCase().includes(term) ||
log.severity.toLowerCase().includes(term) ||
(log.stackTrace && log.stackTrace.toLowerCase().includes(term))
);
}
return filteredLogs;
}
// Helper functions for generating random log messages
function getRandomErrorMessage(): string {
const errorMessages = [
„Failed to connect to database: Connection timeout after 30 seconds”,
„Uncaught TypeError: Cannot read property 'data’ of undefined”,
„Authentication failed: Invalid token signature”,
„Failed to process payment: Invalid card number”,
„500 Internal Server Error: Unexpected condition encountered”,
„OutOfMemoryError: Java heap space”,
„Unhandled exception in API request: Invalid JSON payload”,
„Database query failed: Syntax error in SQL statement”,
„Cache miss for critical resource, fallback failed”,
„Rate limit exceeded for API endpoint /api/users”,
„Failed to read file: Permission denied”,
„Network error: Unable to reach external service”
];
return errorMessages[Math.floor(Math.random() * errorMessages.length)];
}
function getRandomWarningMessage(): string {
const warningMessages = [
„High CPU usage detected: 85% for the last 5 minutes”,
„Memory usage approaching threshold: 78% of available memory”,
„Slow database query detected: Query took 4.2s to complete”,
„API rate limit at 80% – consider throttling requests”,
„Deprecated API endpoint still in use: /v1/legacy/users”,
„Cache hit ratio below acceptable threshold (65%)”,
„Session timeout shorter than recommended (10 minutes)”,
„Disk space running low: 85% used on /data volume”,
„Configuration using default values – recommended to set explicitly”,
„CORS policy may be too permissive, consider restricting origins”
];
return warningMessages[Math.floor(Math.random() * warningMessages.length)];
}
function getRandomInfoMessage(): string {
const infoMessages = [
„User successfully authenticated: user_12345”,
„Payment processed successfully for order #34567”,
„New user registered: [email protected]”,
„Email notification sent to [email protected]”,
„Database backup completed successfully, size: 1.2GB”,
„API request completed, response time: 235ms”,
„Background job completed: Report generation”,
„User session started: session_id_123456”,
„Config reloaded with new environment variables”,
„Cache purged for content region: product-catalog”
];
return infoMessages[Math.floor(Math.random() * infoMessages.length)];
}
function getRandomDebugMessage(): string {
const debugMessages = [
„Request parameters: {„userId”: 12345, „limit”: 50, „offset”: 0}”,
„Database query executed: SELECT * FROM users WHERE status = 'active’ LIMIT 100”,
„Cache lookup for key: user:profile:12345”,
„Processing item 45 of 230 in batch job”,
„Authentication flow starting for user: [email protected]”,
„API response payload size: 24.5KB”,
„Function execution time: method=getUserData, duration=125ms”,
„Initializing service dependencies: [UserService, AuthService, CacheManager]”,
„Route matched: GET /api/products/:id -> ProductController.getProductById”,
„Object state before transform: {„status”: „pending”, „attempts”: 2}”
];
return debugMessages[Math.floor(Math.random() * debugMessages.length)];
}
function getRandomStackTrace(): string {
const stackTraces = [
`Error: Cannot read property 'id’ of undefined
at UserService.getUserDetails (/src/services/UserService.js:42:23)
at async APIController.getUserProfile (/src/controllers/APIController.js:57:12)
at async processRequest (/src/middleware/requestHandler.js:28:7)`,
`TypeError: undefined is not a function
at Object.parseResponse [as parse] (/src/utils/responseParser.js:28:10)
at APIClient.handleResponse (/src/clients/APIClient.js:112:45)
at async fetchData (/src/hooks/useFetch.js:23:19)
at async ComponentDidMount (/src/components/DataView.js:31:5)`,
`ReferenceError: fetch is not defined
at fetchData (/src/utils/api.js:15:3)
at loadUserData (/src/components/UserProfile.js:47:19)
at Component.componentDidMount (/src/components/UserProfile.js:32:10)`,
`SyntaxError: Unexpected token } in JSON at position 42
at JSON.parse (
at parseResponse (/src/utils/http.js:23:19)
at async fetchData (/src/services/dataService.js:45:23)`,
`Error: Failed to connect to database
at connectToDatabase (/src/db/connection.js:28:11)
at initializeServices (/src/app.js:42:8)
at startServer (/src/server.js:15:3)
at main (/src/index.js:10:1)`,
`RangeError: Maximum call stack size exceeded
at Object.processNode (/src/utils/treeProcessor.js:45:12)
at Object.processNode (/src/utils/treeProcessor.js:47:14)
at Object.processNode (/src/utils/treeProcessor.js:47:14)`,
`DatabaseError: relation „users” does not exist
at Connection.parseE (/node_modules/pg/lib/connection.js:614:13)
at Connection.parseMessage (/node_modules/pg/lib/connection.js:413:19)
at Socket.
`AuthenticationError: Invalid credentials
at verifyToken (/src/auth/tokenVerifier.js:35:11)
at authenticateRequest (/src/middleware/auth.js:28:15)
at async processRequest (/src/middleware/requestHandler.js:24:5)`
];
return stackTraces[Math.floor(Math.random() * stackTraces.length)];
}
// Mock performance data
export async function fetchPerformanceData(timeRange: string): Promise
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
const now = new Date();
const days = timeRange === ’24h’ ? 1 :
timeRange === '7d’ ? 7 :
timeRange === ’30d’ ? 30 : 90;
// Generate history data
const pageLoadTimeHistory = [];
const firstContentfulPaintHistory = [];
for (let i = days; i >= 0; i–) {
const date = subDays(now, i);
// Add some randomness but maintain a trend
const basePageLoad = 2.2 + (Math.sin(i/10) * 0.3);
const baseFCP = 1.4 + (Math.cos(i/8) * 0.2);
pageLoadTimeHistory.push({
date: format(date, 'yyyy-MM-dd’),
value: Number((basePageLoad + (Math.random() * 0.5 – 0.25)).toFixed(2))
});
firstContentfulPaintHistory.push({
date: format(date, 'yyyy-MM-dd’),
value: Number((baseFCP + (Math.random() * 0.4 – 0.2)).toFixed(2))
});
}
return {
pageLoadTime: 2.4,
pageLoadTimeChange: -5.2,
firstContentfulPaint: 1.2,
firstContentfulPaintChange: -8.5,
timeToInteractive: 3.1,
timeToInteractiveChange: -3.8,
serverResponseTime: 0.42,
serverResponseTimeChange: 12.5,
pageLoadTimeHistory,
firstContentfulPaintHistory,
resourceSizesByType: [
{ type: 'JavaScript’, size: 845000 },
{ type: 'CSS’, size: 124000 },
{ type: 'Images’, size: 1250000 },
{ type: 'Fonts’, size: 298000 },
{ type: 'HTML’, size: 45000 },
{ type: 'Other’, size: 67000 },
],
loadTimesByDevice: [
{ device: 'Desktop’, loadTime: 2.1 },
{ device: 'Mobile’, loadTime: 3.4 },
{ device: 'Tablet’, loadTime: 2.8 },
]
};
}
// Mock performance route data
export async function fetchPerformanceRouteData(timeRange: string): Promise
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 800));
return [
{
route: '/dashboard’,
avgLoadTime: 2.1,
minLoadTime: 1.4,
maxLoadTime: 3.8,
pageViews: 15420,
performanceScore: 0.82
},
{
route: '/products’,
avgLoadTime: 2.8,
minLoadTime: 1.9,
maxLoadTime: 5.2,
pageViews: 8760,
performanceScore: 0.75
},
{
route: '/checkout’,
avgLoadTime: 3.2,
minLoadTime: 2.1,
maxLoadTime: 6.5,
pageViews: 4350,
performanceScore: 0.65
},
{
route: '/user/profile’,
avgLoadTime: 1.9,
minLoadTime: 1.2,
maxLoadTime: 3.6,
pageViews: 3280,
performanceScore: 0.88
},
{
route: '/blog’,
avgLoadTime: 2.4,
minLoadTime: 1.6,
maxLoadTime: 4.2,
pageViews: 7520,
performanceScore: 0.79
},
{
route: '/api/data’,
avgLoadTime: 0.8,
minLoadTime: 0.3,
maxLoadTime: 3.2,
pageViews: 142500,
performanceScore: 0.92
},
{
route: '/search’,
avgLoadTime: 4.2,
minLoadTime: 2.5,
maxLoadTime: 8.7,
pageViews: 6240,
performanceScore: 0.42
},
{
route: '/cart’,
avgLoadTime: 2.6,
minLoadTime: 1.5,
maxLoadTime: 5.4,
pageViews: 5870,
performanceScore: 0.70
}
];
}
// Mock resources performance data
export async function fetchPerformanceResources(): Promise
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 700));
return [
{
name: 'main.js’,
type: 'script’,
size: 287500,
transferSize: 76400,
loadTime: 420,
cacheable: true,
optimized: false
},
{
name: 'styles.css’,
type: 'stylesheet’,
size: 84200,
transferSize: 23500,
loadTime: 180,
cacheable: true,
optimized: true
},
{
name: 'vendor.js’,
type: 'script’,
size: 543000,
transferSize: 168400,
loadTime: 680,
cacheable: true,
optimized: false
},
{
name: 'hero-image.jpg’,
type: 'image’,
size: 485000,
transferSize: 482000,
loadTime: 540,
cacheable: true,
optimized: false
},
{
name: 'font-awesome.woff2′,
type: 'font’,
size: 98000,
transferSize: 97400,
loadTime: 220,
cacheable: true,
optimized: true
},
{
name: 'api/user-data’,
type: 'fetch’,
size: 12400,
transferSize: 5200,
loadTime: 350,
cacheable: false,
optimized: true
},
{
name: 'analytics.js’,
type: 'script’,
size: 67800,
transferSize: 22400,
loadTime: 310,
cacheable: true,
optimized: false
},
{
name: 'product-thumbnails.png’,
type: 'image’,
size: 265000,
transferSize: 264300,
loadTime: 480,
cacheable: true,
optimized: false
},
{
name: 'roboto.woff2′,
type: 'font’,
size: 142000,
transferSize: 141600,
loadTime: 290,
cacheable: true,
optimized: true
},
{
name: 'polyfill.js’,
type: 'script’,
size: 48600,
transferSize: 12800,
loadTime: 160,
cacheable: true,
optimized: true
}
];
}
// Mock user engagement data
export async function fetchEngagementData(timeRange: string): Promise
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 900));
const now = new Date();
const days = timeRange === '7d’ ? 7 :
timeRange === ’30d’ ? 30 :
timeRange === ’90d’ ? 90 : 365;
// Generate history data
const sessionDurationHistory = [];
const bounceRateHistory = [];
for (let i = days; i >= 0; i–) {
const date = subDays(now, i);
// Add some randomness but maintain a trend
const baseDuration = 3.2 + (Math.sin(i/15) * 0.4);
const baseBounce = 48 + (Math.cos(i/20) * 5);
sessionDurationHistory.push({
date: format(date, 'yyyy-MM-dd’),
value: Number((baseDuration + (Math.random() * 0.6 – 0.3)).toFixed(2))
});
bounceRateHistory.push({
date: format(date, 'yyyy-MM-dd’),
value: Number((baseBounce + (Math.random() * 3 – 1.5)).toFixed(1))
});
}
return {
avgSessionDuration: 186, // in seconds
avgSessionDurationChange: 4.8,
pagesPerSession: 3.4,
pagesPerSessionChange: 2.1,
bounceRate: 46.5,
bounceRateChange: -3.2,
returningUsers: 32.7,
returningUsersChange: 8.4,
sessionDurationHistory,
bounceRateHistory,
trafficByDevice: [
{ name: 'Desktop’, value: 48 },
{ name: 'Mobile’, value: 42 },
{ name: 'Tablet’, value: 10 },
],
engagementByAge: [
{ age: ’18-24′, sessionDuration: 2.8 },
{ age: ’25-34′, sessionDuration: 3.5 },
{ age: ’35-44′, sessionDuration: 4.2 },
{ age: ’45-54′, sessionDuration: 3.7 },
{ age: ’55-64′, sessionDuration: 3.1 },
{ age: ’65+’, sessionDuration: 2.5 },
]
};
}
// Mock referrers data
export async function fetchReferrers(timeRange: string): Promise
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 700));
return [
{
source: 'Google’,
sessions: 42650,
bounceRate: 42.3,
conversionRate: 3.8
},
{
source: 'Direct’,
sessions: 31470,
bounceRate: 38.7,
conversionRate: 5.2
},
{
source: 'Facebook’,
sessions: 18920,
bounceRate: 53.1,
conversionRate: 2.7
},
{
source: 'Instagram’,
sessions: 14580,
bounceRate: 64.5,
conversionRate: 3.1
},
{
source: 'Twitter’,
sessions: 8240,
bounceRate: 59.8,
conversionRate: 1.8
},
{
source: 'Email’,
sessions: 7830,
bounceRate: 31.4,
conversionRate: 6.4
},
{
source: 'LinkedIn’,
sessions: 4920,
bounceRate: 44.2,
conversionRate: 4.3
},
{
source: 'Reddit’,
sessions: 3750,
bounceRate: 66.7,
conversionRate: 1.5
}
];
}
// Mock popular content data
export async function fetchPopularContent(timeRange: string): Promise
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 800));
return [
{
id: 'page_1′,
title: 'Getting Started Guide’,
views: 28450,
avgTimeOnPage: 285,
engagementScore: 87
},
{
id: 'page_2′,
title: 'Product Features Overview’,
views: 24180,
avgTimeOnPage: 192,
engagementScore: 76
},
{
id: 'page_3′,
title: 'Pricing Plans’,
views: 19740,
avgTimeOnPage: 147,
engagementScore: 62
},
{
id: 'page_4′,
title: 'Latest Product Updates’,
views: 17920,
avgTimeOnPage: 240,
engagementScore: 81
},
{
id: 'page_5′,
title: 'API Documentation’,
views: 15840,
avgTimeOnPage: 328,
engagementScore: 93
},
{
id: 'page_6′,
title: 'Case Studies’,
views: 14250,
avgTimeOnPage: 265,
engagementScore: 84
},
{
id: 'page_7′,
title: 'Contact Support’,
views: 11380,
avgTimeOnPage: 118,
engagementScore: 54
},
{
id: 'page_8′,
title: 'Frequently Asked Questions’,
views: 9740,
avgTimeOnPage: 208,
engagementScore: 72
}
];
}
End File# myrta-ds/myrta
// Types for dashboard data structures
// Log data
export interface LogData {
id: string;
timestamp: string;
severity: 'info’ | 'warning’ | 'error’ | 'debug’;
source: string;
message: string;
stackTrace?: string;
metadata?: Record
}
// Performance data
export interface TimeSeriesDataPoint {
date: string;
value: number;
}
export interface ResourceSizeData {
type: string;
size: number;
}
export interface DeviceLoadTimeData {
device: string;
loadTime: number;
}
export interface PerformanceData {
pageLoadTime: number;
pageLoadTimeChange: number;
firstContentfulPaint: number;
firstContentfulPaintChange: number;
timeToInteractive: number;
timeToInteractiveChange: number;
serverResponseTime: number;
serverResponseTimeChange: number;
pageLoadTimeHistory: TimeSeriesDataPoint[];
firstContentfulPaintHistory: TimeSeriesDataPoint[];
resourceSizesByType: ResourceSizeData[];
loadTimesByDevice: DeviceLoadTimeData[];
}
export interface PerformanceRouteData {
route: string;
avgLoadTime: number;
minLoadTime: number;
maxLoadTime: number;
pageViews: number;
performanceScore: number;
}
export interface PerformanceResource {
name: string;
type: string;
size: number;
transferSize: number;
loadTime: number;
cacheable: boolean;
optimized: boolean;
}
// Engagement data
export interface DeviceTrafficData {
name: string;
value: number;
}
export interface AgeEngagementData {
age: string;
sessionDuration: number;
}
export interface EngagementData {
avgSessionDuration: number; // in seconds
avgSessionDurationChange: number;
pagesPerSession: number;
pagesPerSessionChange: number;
bounceRate: number;
bounceRateChange: number;
returningUsers: number;
returningUsersChange: number;
sessionDurationHistory: TimeSeriesDataPoint[];
bounceRateHistory: TimeSeriesDataPoint[];
trafficByDevice: DeviceTrafficData[];
engagementByAge: AgeEngagementData[];
}
export interface Referrer {
source: string;
sessions: number;
bounceRate: number;
conversionRate: number;
}
export interface ContentItem {
id: string;
title: string;
views: number;
avgTimeOnPage: number; // in seconds
engagementScore: number; // 0-100
}
End Fileimport { format, formatDistance } from 'date-fns’;
/**
* Format a date object or string to a readable date
* @param date Date object or string
* @param includeTime Include time in the formatted string
* @returns Formatted date string
*/
export function formatDate(date: Date | string, includeTime: boolean = false): string {
const dateObj = typeof date === 'string’ ? new Date(date) : date;
return format(dateObj, includeTime ? 'PPp’ : 'PP’);
}
/**
* Format a date with a relative time (e.g., '3 days ago’)
* @param date Date object or string
* @returns Relative time string
*/
export function formatRelativeTime(date: Date | string): string {
const dateObj = typeof date === 'string’ ? new Date(date) : date;
return formatDistance(dateObj, new Date(), { addSuffix: true });
}
/**
* Format a timestamp from a log entry
* @param timestamp ISO date string
* @param includeSeconds Include seconds in the output
* @returns Formatted date/time string
*/
export function formatLogDate(timestamp: string, includeSeconds: boolean = false): string {
const date = new Date(timestamp);
if (includeSeconds) {
return format(date, 'MMM d, yyyy HH:mm:ss’);
}
return format(date, 'MMM d, yyyy HH:mm’);
}
/**
* Truncate the first line of a multi-line string
* @param text Text to truncate
* @param maxLength Maximum length before truncation
* @returns Truncated first line
*/
export function truncateFirstLine(text: string, maxLength: number = 100): string {
// Get only the first line
const firstLine = text.split(’n’)[0];
// Truncate if necessary
if (firstLine.length <= maxLength) {
return firstLine;
}
return firstLine.substring(0, maxLength) + '...';
}
/**
* Get CSS class based on log severity
* @param severity Log severity
* @returns CSS class name
*/
export function getSeverityClass(severity: string): 'default' | 'destructive' | 'warning' | 'secondary' {
switch (severity.toLowerCase()) {
case 'error':
return 'destructive';
case 'warning':
return 'warning';
case 'info':
return 'secondary';
case 'debug':
default:
return 'default';
}
}
/**
* Format a stack trace for better readability
* @param stackTrace Raw stack trace
* @returns Formatted stack trace
*/
export function formatStackTrace(stackTrace: string): string {
if (!stackTrace) return '';
// Already has line breaks
if (stackTrace.includes('n')) {
return stackTrace;
}
// Add line breaks at common delimiters
return stackTrace
.replace(/at /g, 'n at ')
.replace(/^n/, ''); // Remove leading newline
}
/**
* Format a number with thousands separators
* @param num Number to format
* @returns Formatted number string
*/
export function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num);
}
/**
* Format time in seconds to a readable format
* @param seconds Time in seconds
* @returns Formatted time string
*/
export function formatTime(seconds: number): string {
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
}
/**
* Format a file size in bytes to a human-readable format
* @param bytes File size in bytes
* @returns Formatted size string
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
End File# myrta-ds/myrta
import * as React from "react";
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { Card } from "@/components/ui/card";
interface BarChartProps {
/**
* Data for the bar chart
*/
data: any[];
/**
* Key to use for the x-axis
*/
xAxisKey: string;
/**
* Key to use for the y-axis
*/
yAxisKey: string;
/**
* Height of the chart in pixels
* @default 300
*/
height?: number;
/**
* Width of the chart (can be percentage or pixels)
* @default '100%'
*/
width?: number | string;
/**
* Color of the bars
* @default 'hsl(var(--primary))'
*/
barColor?: string;
/**
* Whether to show the grid
* @default true
*/
showGrid?: boolean;
/**
* Format value for display
*/
valueFormatter?: (value: number) => string;
/**
* Text prefix for values
*/
valuePrefix?: string;
/**
* Text suffix for values
*/
valueSuffix?: string;
}
/**
* Bar chart component using Recharts
*/
export function BarChart({
data,
xAxisKey,
yAxisKey,
height = 300,
width = „100%”,
barColor = „hsl(var(–primary))”,
showGrid = true,
valueFormatter,
valuePrefix = „”,
valueSuffix = „”,
}: BarChartProps) {
// Default value formatter
const defaultFormatter = (value: number) => {
return `${valuePrefix}${value}${valueSuffix}`;
};
// Use the provided formatter or default
const formatValue = valueFormatter || defaultFormatter;
return (
{showGrid && (
)}
if (active && payload && payload.length) {
return (
{payload[0].payload[xAxisKey]}
);
}
return null;
}}
/>
);
}
End File# myrta-ds/myrta
import * as React from „react”;
import { DonutChart as RechartsDonutChart, PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from „recharts”;
import { Card } from „@/components/ui/card”;
/**
* Data item for donut charts
*/
interface DonutChartDataItem {
name: string;
value: number;
}
interface DonutChartProps {
/**
* Data for the donut chart
*/
data: DonutChartDataItem[];
/**
* Height of the chart in pixels
* @default 300
*/
height?: number;
/**
* Width of the chart (can be percentage or pixels)
* @default '100%’
*/
width?: number | string;
/**
* Inner radius as a percentage
* @default 70
*/
innerRadius?: number;
/**
* Outer radius as a percentage
* @default 90
*/
outerRadius?: number;
/**
* Colors for the segments
* @default Automatically generated based on theme
*/
colors?: string[];
/**
* Show a tooltip on hover
* @default true
*/
showTooltip?: boolean;
/**
* Show a legend for the chart
* @default false
*/
showLegend?: boolean;
/**
* Format value for display
*/
valueFormatter?: (value: number) => string;
}
/**
* Donut chart component using Recharts
*/
export function DonutChart({
data,
height = 300,
width = „100%”,
innerRadius = 70,
outerRadius = 90,
colors,
showTooltip = true,
showLegend = false,
valueFormatter,
}: DonutChartProps) {
// Default colors based on theme
const defaultColors = [
„hsl(var(–primary))”,
„hsl(var(–secondary))”,
„hsl(var(–accent))”,
„hsl(var(–muted))”,
„hsl(var(–primary) / 0.8)”,
„hsl(var(–secondary) / 0.8)”,
„hsl(var(–accent) / 0.8)”,
„hsl(var(–muted) / 0.8)”,
„hsl(var(–primary) / 0.6)”,
„hsl(var(–secondary) / 0.6)”,
];
// Use the provided colors or default colors
const chartColors = colors || defaultColors;
// Default value formatter
const defaultFormatter = (value: number) => {
return value.toString();
};
// Use the provided formatter or default
const formatValue = valueFormatter || defaultFormatter;
return (
{data.map((entry, index) => (
))}
{showTooltip && (
if (active && payload && payload.length) {
return (
{payload[0].name}
);
}
return null;
}}
/>
)}
{showLegend && (
)}
);
}
End Fileimport * as React from „react”;
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area } from „recharts”;
import { Card } from „@/components/ui/card”;
/**
* Data point for time series charts
*/
interface TimeSeriesPoint {
date: string;
value: number;
}
interface TimeSeriesChartProps {
/**
* Data for the time series chart
*/
data: TimeSeriesPoint[];
/**
* Height of the chart in pixels
* @default 300
*/
height?: number;
/**
* Width of the chart (can be percentage or pixels)
* @default '100%’
*/
width?: number | string;
/**
* Color of the line
* @default 'hsl(var(–primary))’
*/
lineColor?: string;
/**
* Color of the area under the line
* @default 'hsl(var(–primary) / 0.2)’
*/
areaColor?: string;
/**
* Whether to show the grid
* @default true
*/
showGrid?: boolean;
/**
* Whether to show the tooltip
* @default true
*/
showTooltip?: boolean;
/**
* Key to use for the x-axis
* @default 'date’
*/
xAxisKey?: string;
/**
* Key to use for the y-axis
* @default 'value’
*/
yAxisKey?: string;
/**
* Text prefix for values
* @default ”
*/
valuePrefix?: string;
/**
* Text suffix for values
* @default ”
*/
valueSuffix?: string;
/**
* Value formatter
*/
valueFormatter?: (value: number) => string;
}
/**
* Time series chart component using Recharts
*/
export function TimeSeriesChart({
data,
height = 300,
width = „100%”,
lineColor = „hsl(var(–primary))”,
areaColor = „hsl(var(–primary) / 0.2)”,
showGrid = true,
showTooltip = true,
xAxisKey = „date”,
yAxisKey = „value”,
valuePrefix = „”,
valueSuffix = „”,
valueFormatter,
}: TimeSeriesChartProps) {
// Format the value for display
const formatValue = (value: number) => {
if (valueFormatter) {
return valueFormatter(value);
}
return `${valuePrefix}${value}${valueSuffix}`;
};
return (
{showGrid && (
)}
{showTooltip && (
if (active && payload && payload.length) {
return (
{payload[0].payload[xAxisKey]}
);
}
return null;
}}
/>
)}
);
}
End File# myrta-ds/myrta
import { Badge } from „@/components/ui/badge”;
import { Button } from „@/components/ui/button”;
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from „@/components/ui/table”;
import { PerformanceResource } from „@/lib/definitions”;
import { formatBytes } from „@/lib/utils”;
import { ArrowDownIcon, ArrowUpIcon, SortIcon } from „lucide-react”;
import { useState } from „react”;
type SortDirection = „asc” | „desc” | null;
interface ResourcesTableProps {
resources: PerformanceResource[];
}
export default function ResourcesTable({ resources }: ResourcesTableProps) {
const [sortField, setSortField] = useState
const [sortDirection, setSortDirection] = useState
const handleSort = (field: string) => {
if (sortField === field) {
if (sortDirection === „asc”) {
setSortDirection(„desc”);
} else if (sortDirection === „desc”) {
setSortDirection(null);
setSortField(null);
} else {
setSortDirection(„asc”);
}
} else {
setSortField(field);
setSortDirection(„asc”);
}
};
const getSortIcon = (field: string) => {
if (sortField !== field) {
return
}
if (sortDirection === „asc”) {
return
}
if (sortDirection === „desc”) {
return
}
return
};
// Sort resources based on current sort settings
const sortedResources = […resources].sort((a, b) => {
if (!sortField || !sortDirection) return 0;
let valueA, valueB;
switch (sortField) {
case „name”:
valueA = a.name.toLowerCase();
valueB = b.name.toLowerCase();
break;
case „type”:
valueA = a.type.toLowerCase();
valueB = b.type.toLowerCase();
break;
case „size”:
valueA = a.size;
valueB = b.size;
break;
case „transferSize”:
valueA = a.transferSize;
valueB = b.transferSize;
break;
case „loadTime”:
valueA = a.loadTime;
valueB = b.loadTime;
break;
default:
return 0;
}
if (sortDirection === „asc”) {
return valueA > valueB ? 1 : -1;
} else {
return valueA < valueB ? 1 : -1;
}
});
// Helper function to get resource type badge
const getResourceTypeBadge = (type: string) => {
switch (type.toLowerCase()) {
case „script”:
return
case „stylesheet”:
return
case „image”:
return
case „font”:
return
case „fetch”:
return
default:
return
}
};
return (
);
}
End File# myrta-ds/myrta
import React from 'react’;
import { Card, CardBody, CardHeader, CardTitle } from '@/components/ui/card’;
import { Badge } from '@/components/ui/badge’;
import { LogData } from '@/lib/definitions’;
import { getSeverityClass, formatLogDate } from '@/lib/utils’;
import { Button } from '@/components/ui/button’;
import { CopyIcon } from 'lucide-react’;
import copyToClipboard from '@/lib/clipboard’;
interface LogMessageCardProps {
log: LogData;
isSelected?: boolean;
onClick?: () => void;
}
const LogMessageCard: React.FC
log,
isSelected = false,
onClick
}) => {
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
copyToClipboard(`${log.timestamp} [${log.severity}] ${log.message}`);
};
return (
{log.severity}
{log.message.split(’n’)[0]}
{log.stackTrace && (
{log.stackTrace.split(’n’).length > 1 && '…’}
)}
);
};
export default LogMessageCard;
End File’use client’
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
SortingState,
ColumnFiltersState,
getFilteredRowModel,
getSortedRowModel,
} from „@tanstack/react-table”
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from „@/components/ui/table”
import { Button } from „@/components/ui/button”
import { useState } from 'react’
import { Input } from '@/components/ui/input’
interface DataTableProps
/**
* Column definitions for the table
*/
columns: ColumnDef
/**
* Data to display in the table
*/
data: TData[]
/**
* Whether to show search functionality
* @default true
*/
showSearch?: boolean
/**
* The column key to search on
*/
searchKey?: string
/**
* Placeholder text for the search input
* @default „Search…”
*/
searchPlaceholder?: string
/**
* Whether to show pagination controls
* @default true
*/
showPagination?: boolean
/**
* The number of rows per page
* @default 10
*/
pageSize?: number
}
/**
* A reusable data table component with sorting, filtering, and pagination
*/
export function DataTable
columns,
data,
showSearch = true,
searchKey,
searchPlaceholder = „Search…”,
showPagination = true,
pageSize = 10,
}: DataTableProps
const [sorting, setSorting] = useState
const [columnFilters, setColumnFilters] = useState
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
columnFilters,
},
initialState: {
pagination: {
pageSize,
},
},
})
return (
{showSearch && searchKey && (
table.getColumn(searchKey)?.setFilterValue(event.target.value)
}
className=”max-w-sm”
/>
)}
{/* Table */}
{/* Pagination */}
{showPagination && (
)}
)
}
End File# myrta-ds/myrta
import * as THREE from 'three’;
import { useRef, useState, useEffect } from 'react’;
import { Canvas, useFrame } from '@react-three/fiber’;
import { useGLTF, useTexture, Environment, Float, MeshReflectorMaterial } from '@react-three/drei’;
import { EffectComposer, Bloom } from '@react-three/postprocessing’;
import { easing } from 'maath’;
export function MeshStage({ children, color, …props }) {
// Reflective floor setup
const [hovered, setHovered] = useState(false);
return (
);
}
export function RoundedBox({ position = [0, 0, 0], args = [1, 1, 1], radius = 0.1, …props }) {
const mesh = useRef();
const [hovered, setHovered] = useState(false);
useFrame((state, delta) => {
easing.dampC(mesh.current.material.color, hovered ? '#40a0ff’ : '#ff6080′, 0.1, delta);
mesh.current.rotation.x = mesh.current.rotation.y += delta / 4;
});
return (
onPointerOut={() => setHovered(false)}
castShadow
receiveShadow
{…props}
>
);
}
export function Shoe({ position = [0, 0, 0], rotation = [0, 0, 0], …props }) {
const mesh = useRef();
const { nodes, materials } = useGLTF(’/models/shoe.glb’);
useFrame((state, delta) => {
const t = state.clock.elapsedTime;
mesh.current.rotation.set(
Math.cos(t / 4) / 8,
Math.sin(t / 3) / 4,
Math.sin(t / 2) / 8
);
mesh.current.position.y = (1 + Math.sin(t / 2)) / 3;
});
return (
);
}
export function WatchModel({ position = [0, 0, 0], rotation = [0, 0, 0], …props }) {
const mesh = useRef();
const [hovered, setHovered] = useState(false);
const { nodes, materials } = useGLTF(’/models/watch.glb’);
useFrame((state, delta) => {
const t = state.clock.elapsedTime;
mesh.current.rotation.x = Math.cos(t / 2) / 8;
mesh.current.rotation.y = Math.sin(t / 4) * 0.5;
easing.dampC(materials.glass.color, hovered ? '#40a0ff’ : '#74b9ff’, 0.2, delta);
easing.dampC(materials.body.color, hovered ? '#303030′ : '#202020′, 0.2, delta);
});
return (
onPointerOut={() => setHovered(false)}
{…props}
dispose={null}
>
);
}
export function PhoneModel({ position = [0, 0, 0], rotation = [0, 0, 0], …props }) {
const mesh = useRef();
const [hovered, setHovered] = useState(false);
const texture = useTexture(’/textures/screen.jpg’);
useEffect(() => {
texture.flipY = false;
}, [texture]);
useFrame((state, delta) => {
const t = state.clock.elapsedTime;
mesh.current.rotation.x = Math.cos(t / 3) / 10;
mesh.current.rotation.y = Math.sin(t / 2) * 0.3;
});
return (
onPointerOut={() => setHovered(false)}
{…props}
>
{/* Phone Body */}
{/* Screen */}
{/* Camera Module */}
);
}
export function LaptopModel({ position = [0, 0, 0], rotation = [0, 0, 0], …props }) {
const mesh = useRef();
const [hovered, setHovered] = useState(false);
const screenTexture = useTexture(’/textures/laptop-screen.jpg’);
useEffect(() => {
screenTexture.flipY = false;
}, [screenTexture]);
useFrame((state, delta) => {
const t = state.clock.elapsedTime;
mesh.current.rotation.x = Math.cos(t / 4) / 10;
mesh.current.rotation.y = Math.sin(t / 3) * 0.2;
});
return (
onPointerOut={() => setHovered(false)}
{…props}
>
{/* Base */}
{/* Keyboard Area */}
{/* Trackpad */}
{/* Screen Housing */}
{/* Screen */}
{/* Logo on back */}
);
}
useGLTF.preload(’/models/shoe.glb’);
useGLTF.preload(’/models/watch.glb’);
End File# myrta-ds/myrta
# apps/docs/app/components/design-system/color-palette.tsx
import * as React from 'react’;
import { Label } from '@/components/ui/label’;
import { getContrast } from 'polished’;
interface ColorSwatch {
colorName: string;
colorValue: string;
isDark: boolean;
}
interface ColorPaletteProps {
/**
* The colors to display
* Format: { colorName: colorValue }
*/
colors: Record
}
export function ColorPalette({ colors }: ColorPaletteProps) {
const swatches = React.useMemo(() => {
return Object.entries(colors).map(([colorName, colorValue]) => {
// Calculate if text should be white or black
const contrastWithWhite = getContrast(colorValue, '#FFFFFF’);
const isDark = contrastWithWhite >= 3; // WCAG AA for normal text
return {
colorName,
colorValue,
isDark,
};
});
}, [colors]);
const sortedSwatches = React.useMemo(() => {
return […swatches].sort((a, b) => {
// Extract numbers like „50”, „100”, „200” from colorName
const valueA = parseInt((a.colorName.match(/d+/) || [0])[0], 10);
const valueB = parseInt((b.colorName.match(/d+/) || [0])[0], 10);
return valueA – valueB;
});
}, [swatches]);
return (
))}
);
}
/**
* Individual color swatch component
*/
function ColorSwatch({ colorName, colorValue, isDark }: ColorSwatch) {
return (
{colorValue}
);
}
/**
* Color grid to display a set of colors
*/
export function ColorGrid({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
{children}
);
}
/**
* Color grid item
*/
export function ColorGridItem({
color,
name,
className,
}: {
color: string;
name: string;
className?: string;
}) {
// Calculate if text should be white or black
const contrastWithWhite = getContrast(color, '#FFFFFF’);
const isDark = contrastWithWhite >= 3; // WCAG AA for normal text
return (
{color}
);
}
End File”use client”;
import React, { useState } from „react”;
import { Tabs, TabsContent, TabsList, TabsTrigger } from „@/components/ui/tabs”;
import { Copy } from „lucide-react”;
import { Button } from „@/components/ui/button”;
import { cn } from „@/lib/utils”;
interface CodeBlockProps {
/**
* The code to display
*/
code: string;
/**
* The language of the code (for syntax highlighting)
* @default „tsx”
*/
language?: string;
/**
* The file name to display
*/
fileName?: string;
/**
* Disable syntax highlighting
* @default false
*/
disableSyntaxHighlighting?: boolean;
/**
* Show line numbers
* @default false
*/
showLineNumbers?: boolean;
/**
* Hide copy button
* @default false
*/
hideCopyButton?: boolean;
/**
* Additional HTML attributes for the code element
*/
codeProps?: React.HTMLAttributes
/**
* Alternative code examples in different languages
*/
examples?: {
language: string;
code: string;
fileName?: string;
}[];
}
/**
* Code block component for displaying code snippets with syntax highlighting
*/
export function CodeBlock({
code,
language = „tsx”,
fileName,
disableSyntaxHighlighting = false,
showLineNumbers = false,
hideCopyButton = false,
codeProps,
examples = [],
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const allExamples = [
{
language,
code,
fileName,
},
…examples,
];
const handleCopy = async (codeToСopy: string) => {
await navigator.clipboard.writeText(codeToСopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
{allExamples.length === 1 && fileName && (
{!hideCopyButton && (
)}
)}
{/* Multiple examples tabs */}
{allExamples.length > 1 ? (
{allExamples.map((example, i) => (
{example.language.toUpperCase()}
))}
{!hideCopyButton && (
)}
{allExamples.map((example, i) => (
{example.code}
))}
) : (
<>
{/* Single code block */}
{code}
{/* Copy button at the top right if no filename */}
{!fileName && !hideCopyButton && (
)}
>
)}
);
}
End File# myrta-ds/myrta
# apps/docs/app/components/docs/installation-steps.tsx
„use client”;
import { Tabs, TabsContent, TabsList, TabsTrigger } from „@/components/ui/tabs”;
import { CodeBlock } from „./code-block”;
// Package manager tab content
const packageManagers = [
{
name: „npm”,
installCmd: „npm install @myrta-ds/core @myrta-ds/react”,
peerCmd: „npm install react react-dom”,
},
{
name: „yarn”,
installCmd: „yarn add @myrta-ds/core @myrta-ds/react”,
peerCmd: „yarn add react react-dom”,
},
{
name: „pnpm”,
installCmd: „pnpm add @myrta-ds/core @myrta-ds/react”,
peerCmd: „pnpm add react react-dom”,
},
];
// Component import examples
const importExamples = {
typescript: `import { Button } from '@myrta-ds/react’;
export default function App() {
return (
);
}`,
javascript: `import { Button } from '@myrta-ds/react’;
export default function App() {
return (
);
}`,
};
// CSS setup examples
const cssSetupExamples = {
typescript: `// In your entry file (e.g., main.tsx, index.tsx, or app.tsx)
import '@myrta-ds/core/styles.css’;
import { App } from ’./App’;
// Rest of your application setup…`,
javascript: `// In your entry file (e.g., main.js, index.js, or app.js)
import '@myrta-ds/core/styles.css’;
import { App } from ’./App’;
// Rest of your application setup…`,
};
export default function InstallationSteps() {
return (
1. Install the packages
{packageManagers.map((pm) => (
{pm.name}
))}
{packageManagers.map((pm) => (
Myrta DS has peer dependencies on React and React DOM:
))}
2. Import the CSS
Import the CSS file in your main entry file to apply global styles:
3. Use components
Now you can import and use components in your application:
);
}
End File”use client”;
import * as React from „react”;
import Link from „next/link”;
import { usePathname } from „next/navigation”;
import { cn } from „@/lib/utils”;
import { ChevronRight } from „lucide-react”;
export interface SidebarNavProps extends React.HTMLAttributes
items: {
title: string;
href?: string;
disabled?: boolean;
links?: {
title: string;
href: string;
disabled?: boolean;
}[];
}[];
}
/**
* SidebarNav component for documentation sidebar
*/
export function SidebarNav({ className, items, …props }: SidebarNavProps) {
const pathname = usePathname();
// Track expanded sections
const [expandedSections, setExpandedSections] = React.useState
// Check if section should be initially expanded
const initializeExpandedSections = React.useCallback(() => {
const initialExpanded: string[] = [];
items.forEach((item) => {
if (
item.links?.some((link) => pathname.startsWith(link.href)) ||
pathname === item.href
) {
initialExpanded.push(item.title);
}
});
setExpandedSections(initialExpanded);
}, [items, pathname]);
// Initialize expanded sections on mount and pathname change
React.useEffect(() => {
initializeExpandedSections();
}, [initializeExpandedSections, pathname]);
// Toggle a section’s expanded state
const toggleSection = (title: string) => {
setExpandedSections((prev) =>
prev.includes(title)
? prev.filter((item) => item !== title)
: […prev, title]
);
};
return (
);
}
End File# apps/docs/app/components/docs/api-tbl.tsx
/**
* API table component for documenting props and other APIs
*/
import React from 'react’;
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table’;
import { Badge } from '@/components/ui/badge’;
interface ApiTableProps {
/**
* The data to display in the table
*/
data: ApiItem[];
/**
* Optional table caption/title
*/
caption?: string;
/**
* The name of the interface being documented
*/
interfaceName?: string;
/**
* Whether to display default values
* @default true
*/
showDefaults?: boolean;
}
export interface ApiItem {
/**
* Property name
*/
name: string;
/**
* Property type (e.g. string, number, boolean)
*/
type: string;
/**
* Default value (if any)
*/
default?: string;
/**
* Property description
*/
description: string;
/**
* Whether the property is required
* @default false
*/
required?: boolean;
}
export function ApiTable({
data,
caption,
interfaceName,
showDefaults = true,
}: ApiTableProps) {
return (
);
}
/**
* Format a type string for display
*/
function formatType(type: string): React.ReactNode {
// Highlight certain types
if ([’string’, 'number’, 'boolean’, 'any’, 'void’].includes(type)) {
return {type};
}
// Format union types
if (type.includes(’|’)) {
return type.split(’|’).map((t, i) => (
{i > 0 && ’ | ’}
{t.trim()}
));
}
return type;
}
/**
* Format a default value for display
*/
function formatDefaultValue(value: string): React.ReactNode {
// Boolean values
if (value === 'true’ || value === 'false’) {
return {value};
}
// Numbers
if (!isNaN(Number(value))) {
return {value};
}
// Strings (quoted)
if ((value.startsWith(’”’) && value.endsWith(’”’)) ||
(value.startsWith(„’”) && value.endsWith(„’”))) {
return {value};
}
return value;
}
End File# myrta-ds/myrta
import { MetadataRoute } from 'next’;
interface SitemapEntry {
url: string;
lastModified?: string | Date;
changeFrequency?: 'always’ | 'hourly’ | 'daily’ | 'weekly’ | 'monthly’ | 'yearly’ | 'never’;
priority?: number;
}
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://myrta-ds.vercel.app’;
const pages: SitemapEntry[] = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'monthly’,
priority: 1,
},
];
// Documentation pages
const docsPaths = [
// Getting started
'/docs/introduction’,
'/docs/installation’,
'/docs/theming’,
'/docs/roadmap’,
// Foundation
'/docs/foundation/colors’,
'/docs/foundation/typography’,
'/docs/foundation/spacing’,
'/docs/foundation/breakpoints’,
// Components
'/docs/components/accordion’,
'/docs/components/avatar’,
'/docs/components/badge’,
'/docs/components/button’,
'/docs/components/card’,
'/docs/components/checkbox’,
'/docs/components/dialog’,
'/docs/components/icon’,
'/docs/components/menu’,
'/docs/components/radio’,
'/docs/components/select’,
'/docs/components/slider’,
'/docs/components/spinner’,
'/docs/components/table’,
'/docs/components/tabs’,
'/docs/components/textfield’,
'/docs/components/toggle’,
'/docs/components/tooltip’,
// Examples
'/examples/dashboard’,
'/examples/landing’,
];
const docsEntries = docsPaths.map(path => ({
url: `${baseUrl}${path}`,
lastModified: new Date(),
changeFrequency: 'weekly’ as const,
priority: 0.8,
}));
// Legal and auxiliary pages
const auxiliaryPaths = [
'/examples’,
'/docs’,
'/changelog’,
];
const auxiliaryEntries = auxiliaryPaths.map(path => ({
url: `${baseUrl}${path}`,
lastModified: new Date(),
changeFrequency: 'monthly’ as const,
priority: 0.5,
}));
// Combine all entries
return [
…pages,
…docsEntries,
…auxiliaryEntries,
];
}
End Fileimport { Metadata } from 'next’;
import { ScrollArea } from '@/components/ui/scroll-area’;
import { Products } from '@/app/examples/landing/components/Products’;
import { Hero } from '@/app/examples/landing/components/Hero’;
import { Features } from '@/app/examples/landing/components/Features’;
import { Brands } from '@/app/examples/landing/components/Brands’;
import { Faq } from '@/app/examples/landing/components/Faq’;
import { Testimonials } from '@/app/examples/landing/components/Testimonials’;
import { Pricing } from '@/app/examples/landing/components/Pricing’;
import { Statistics } from '@/app/examples/landing/components/Statistics’;
import { Footer } from '@/app/examples/landing/components/Footer’;
import { Header } from '@/app/examples/landing/components/Header’;
export const metadata: Metadata = {
title: 'Landing Page Example | Myrta Design System’,
description: 'Example landing page built with Myrta Design System components’,
};
export default function LandingPage() {
return (
);
}
End File# myrta-ds/myrta
# apps/docs/app/examples/landing/components/Hero.tsx
'use client’;
import { Button } from '@/components/ui/button’;
import Link from 'next/link’;
import { ArrowRight, Play } from 'lucide-react’;
import { motion } from 'framer-motion’;
import { MeshStage, RoundedBox } from '@/components/3d/mesh-stage’;
import Image from 'next/image’;
export function Hero() {
return (
Next Generation Platform
Kolejne rozdziały
Zapraszamy do dalszego czytania naszego leksykonu.
Wybierz kolejny rozdział z menu poniżej, aby otworzyć nową podstronę kompedium wiedzy i uzyskać szczegółowe informację o leku, substancji lub chorobie.
- Dawkowanie i sposób podawania
- Działania niepożądane
- Interakcje leku
- Profil bezpieczeństwa leku
- Przeciwwskazania
- Przedawkowanie
- Przedkliniczne dane o bezpieczeństwie
- Skład i postać leku
- Specjalne ostrzeżenia
- Właściwości farmakodynamiczne
- Właściwości farmakokinetyczne
- Wpływ na płodność, ciążę i laktację
- Wpływ na zdolność prowadzenia pojazdów i obsługiwania maszyn
- Wskazania do stosowania