diff --git a/.gitignore b/.gitignore index 5d4be9669829e68e2cfaba33d69614fde9d6703a..930d26950b4632e0728aa35b227445f7abe3484f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ media/ uploads/ -/uploads/ \ No newline at end of file +/uploads/ + +/node_modules/ \ No newline at end of file diff --git a/frontend/src/components/Common/input/AmazingDropdown.js b/frontend/src/components/Common/input/AmazingDropdown.js deleted file mode 100644 index 2841e1532622b1e7c4e734f4b7387b2224b7ee79..0000000000000000000000000000000000000000 --- a/frontend/src/components/Common/input/AmazingDropdown.js +++ /dev/null @@ -1,35 +0,0 @@ -// src/components/Common/input/AmazingDropdown.js -import React from 'react'; -import 'bootstrap/dist/css/bootstrap.min.css'; -import 'font-awesome/css/font-awesome.min.css'; -import '../../../styles/input/AmazingDropdown.css'; - -const AmazingDropdown = ({ options, selectedOption, onSelect }) => { - return ( - <div className="dropdown"> - <button - className="btn btn-primary dropdown-toggle" - type="button" - id="dropdownMenuButton" - data-toggle="dropdown" - aria-haspopup="true" - aria-expanded="false" - > - {selectedOption ? selectedOption : 'Select an option'} - </button> - <div className="dropdown-menu" aria-labelledby="dropdownMenuButton"> - {options.map(option => ( - <button - key={option} - className="dropdown-item" - onClick={() => onSelect(option)} - > - {option} - </button> - ))} - </div> - </div> - ); -}; - -export default AmazingDropdown; diff --git a/frontend/src/components/Common/input/Dropdown.js b/frontend/src/components/Common/input/Dropdown.js new file mode 100644 index 0000000000000000000000000000000000000000..8a6a17747ecf72da4cb8766db967d0c0a9423a41 --- /dev/null +++ b/frontend/src/components/Common/input/Dropdown.js @@ -0,0 +1,49 @@ +// src/components/Common/input/Dropdown.js + +import React, { useState, useEffect, useRef } from 'react'; +import '../../../styles/input/Dropdown.css'; + +const Dropdown = ({ items, selectedItem, onItemSelect, className }) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const toggleDropdown = () => { + setIsOpen(!isOpen); + }; + + const handleItemClick = (item) => { + onItemSelect(item); + setIsOpen(false); + }; + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( + <div className={`dropdown-container ${className}`} ref={dropdownRef}> + <div className={`dropdown-header ${isOpen ? 'open' : ''}`} onClick={toggleDropdown}> + {selectedItem || "Select a number"} + <span className={`icon ${isOpen ? 'open' : ''}`}>▾</span> + </div> + <ul className={`dropdown-list ${isOpen ? 'open' : ''}`}> + {items.map((item, index) => ( + <li key={index} className="dropdown-item" onClick={() => handleItemClick(item)}> + {item} + </li> + ))} + </ul> + </div> + ); +}; + +export default Dropdown; diff --git a/frontend/src/components/Common/input/FileInput.js b/frontend/src/components/Common/input/FileInput.js new file mode 100644 index 0000000000000000000000000000000000000000..ed5f6fea8ada18424fd964f8fcce396fb88b28d3 --- /dev/null +++ b/frontend/src/components/Common/input/FileInput.js @@ -0,0 +1,58 @@ +import React, { useRef } from 'react'; +import { FaUpload, FaTrash } from 'react-icons/fa'; +import '../../../styles/input/FileInput.css'; + +const FileInput = ({ label, onChange, fileName, disabled, onRemove }) => { + const fileInputRef = useRef(null); + + const handleFileRemove = () => { + if (fileInputRef.current) { + fileInputRef.current.value = null; + } + onRemove(); + console.log('File input remove clicked'); + }; + + return ( + <div className="file-input-container"> + <label className="file-input-label">{label}</label> + <div className="file-input-wrapper"> + {!fileName && ( + <> + <input + type="file" + className="file-input-field" + onChange={onChange} + ref={fileInputRef} + disabled={disabled} + accept="video/*" + /> + <button + type="button" + className="file-input-upload-button" + onClick={() => fileInputRef.current.click()} + disabled={disabled} + > + <FaUpload className="upload-icon" /> Upload Video + </button> + </> + )} + </div> + {fileName && ( + <div className="file-details"> + <span className="file-name">{fileName}</span> + <button + type="button" + className="file-remove-button" + onClick={handleFileRemove} + disabled={disabled} + > + <FaTrash className="remove-icon" /> Remove + </button> + </div> + )} + </div> + ); +}; + +export default FileInput; diff --git a/frontend/src/components/Common/input/NumberInput.js b/frontend/src/components/Common/input/NumberInput.js new file mode 100644 index 0000000000000000000000000000000000000000..c16158f3d7ec692d409af0c07f48514747742830 --- /dev/null +++ b/frontend/src/components/Common/input/NumberInput.js @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from 'react'; +import '../../../styles/input/NumberInput.css'; + +const NumberInput = ({ value, min, max, step, onValueChange, disabled }) => { + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + setLocalValue(value); // Update local value when prop changes + }, [value]); + + const handleChange = (e) => { + const newValue = parseInt(e.target.value, 10); + if (!isNaN(newValue)) { + if (newValue < min) { + setLocalValue(min); + onValueChange(min); + } else if (newValue > max) { + setLocalValue(max); + onValueChange(max); + } else { + setLocalValue(newValue); + onValueChange(newValue); + } + } else { + setLocalValue(min); + onValueChange(min); + } + }; + + const handleIncrement = (e) => { + e.preventDefault(); + const newValue = localValue + step; + if (newValue <= max) { + setLocalValue(newValue); + onValueChange(newValue); + } + }; + + const handleDecrement = (e) => { + e.preventDefault(); + const newValue = localValue - step; + if (newValue >= min) { + setLocalValue(newValue); + onValueChange(newValue); + } + }; + + return ( + <div className="number-input-container"> + <div className="number-input-wrapper"> + <input + type="number" + className="number-input-field" + value={localValue} + onChange={handleChange} + disabled={disabled} + min={min} + max={max} + /> + <div className="number-input-controls"> + <button className="number-input-button up" onClick={handleIncrement} disabled={disabled}> + ▲ + </button> + <button className="number-input-button down" onClick={handleDecrement} disabled={disabled}> + ▼ + </button> + </div> + </div> + </div> + ); +}; + +export default NumberInput; diff --git a/frontend/src/components/detection/video/VideoInputs.js b/frontend/src/components/detection/video/VideoInputs.js new file mode 100644 index 0000000000000000000000000000000000000000..c57bf248066264fb2d78992b77c3ab963726346b --- /dev/null +++ b/frontend/src/components/detection/video/VideoInputs.js @@ -0,0 +1,50 @@ +import React, { useEffect } from 'react'; +import Dropdown from '../../Common/input/Dropdown'; +import NumberInput from '../../Common/input/NumberInput'; +import { Form } from 'react-bootstrap'; +const VideoInputs = ({ framesJump, frameDelay, handleFramesJumpChange, handleFrameDelayChange, validFramesJumpOptions, minFrameDelay, maxFrameDelay, isLoading, videoDuration, updateConstraintsAndExpectedLength }) => { + + useEffect(() => { + if (framesJump && frameDelay) { + updateConstraintsAndExpectedLength(videoDuration, framesJump, frameDelay); + } + }, [framesJump, frameDelay, videoDuration, updateConstraintsAndExpectedLength]); + + const handleFramesJumpInputChange = (item) => { + const value = parseInt(item); + handleFramesJumpChange({ target: { value } }); + updateConstraintsAndExpectedLength(videoDuration, value, frameDelay); + }; + + const handleFrameDelayInputChange = (value) => { + handleFrameDelayChange({ target: { value } }); + updateConstraintsAndExpectedLength(videoDuration, framesJump, value); + }; + + return ( + <> + <Form.Group controlId="framesJump"> + <Form.Label>Frames Jump Seconds</Form.Label> + <Dropdown + items={validFramesJumpOptions} + selectedItem={framesJump.toString()} + onItemSelect={handleFramesJumpInputChange} + className={isLoading ? "disabled" : ""} + /> + </Form.Group> + <Form.Group controlId="frameDelay"> + <Form.Label>Delay per Frame (seconds)</Form.Label> + <NumberInput + value={frameDelay} + min={minFrameDelay} + max={maxFrameDelay} + step={1} + onValueChange={handleFrameDelayInputChange} + disabled={isLoading} + /> + </Form.Group> + </> + ); +}; + +export default VideoInputs; diff --git a/frontend/src/components/detection/video/VideoUploadForm.js b/frontend/src/components/detection/video/VideoUploadForm.js index daccf2d7218480d4ae663f0693bd360b86af29d9..5bb312a4ed0ee3647ee9933d111dd093638da212 100644 --- a/frontend/src/components/detection/video/VideoUploadForm.js +++ b/frontend/src/components/detection/video/VideoUploadForm.js @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Form, Button } from 'react-bootstrap'; -import { getVideoDuration, calculateMinimumDelay, calculateMaximumDelay, calculateExpectedLength, isValidVideoLength, calculateOptimalValues, calculateMaxFrameJump, isDelayPerFrameValid, calculateValidFramesJumpOptions, calculateMinimumValidFrameDelay } from './videoUtils'; +import VideoInputs from './VideoInputs'; +import { getVideoDuration, calculateMinimumValidFrameDelay, calculateMaximumDelay, calculateExpectedLength, calculateOptimalValues, calculateValidFramesJumpOptions, isDelayPerFrameValid } from './videoUtils'; const VideoUploadForm = ({ handleFileChange, handleSubmit, handleFramesJumpChange, handleFrameDelayChange, framesJump, frameDelay, isLoading }) => { const [videoFile, setVideoFile] = useState(null); @@ -29,16 +30,6 @@ const VideoUploadForm = ({ handleFileChange, handleSubmit, handleFramesJumpChang } }, [videoFile]); - const calculateValidFramesJumpOptions = (duration) => { - const options = []; - for (let i = 1; i <= duration; i++) { - if (duration % i === 0 && duration / i >= 2) { - options.push(i); - } - } - return options; - }; - const updateConstraintsAndExpectedLength = (duration, framesJump, frameDelay) => { const minDelay = calculateMinimumValidFrameDelay(duration, framesJump); const maxDelay = calculateMaximumDelay(duration, framesJump); @@ -63,18 +54,6 @@ const VideoUploadForm = ({ handleFileChange, handleSubmit, handleFramesJumpChang handleFileChange(event); }; - const handleFramesJumpInputChange = (event) => { - const value = parseInt(event.target.value); - handleFramesJumpChange({ target: { value } }); - updateConstraintsAndExpectedLength(videoDuration, value, frameDelay); - }; - - const handleFrameDelayInputChange = (event) => { - const value = Math.min(Math.max(parseInt(event.target.value), minFrameDelay), maxFrameDelay); - handleFrameDelayChange({ target: { value } }); - setExpectedLength(calculateExpectedLength(videoDuration, framesJump, value)); - }; - return ( <Form onSubmit={handleSubmit}> <Form.Group controlId="videoFile"> @@ -82,35 +61,18 @@ const VideoUploadForm = ({ handleFileChange, handleSubmit, handleFramesJumpChang <Form.Control type="file" accept="video/*" onChange={handleFileInputChange} disabled={isLoading} /> </Form.Group> {isVideoUploaded && ( - <> - <Form.Group controlId="framesJump"> - <Form.Label>Frames Jump Seconds</Form.Label> - <Form.Control - as="select" - value={framesJump} - onChange={handleFramesJumpInputChange} - disabled={isLoading} - > - {validFramesJumpOptions.map(option => ( - <option key={option} value={option}> - {option} - </option> - ))} - </Form.Control> - </Form.Group> - <Form.Group controlId="frameDelay"> - <Form.Label>Delay per Frame (seconds)</Form.Label> - <Form.Control - type="number" - value={frameDelay} - onChange={handleFrameDelayInputChange} - min={minFrameDelay} - max={maxFrameDelay} - step="1" - disabled={isLoading} - /> - </Form.Group> - </> + <VideoInputs + framesJump={framesJump} + frameDelay={frameDelay} + handleFramesJumpChange={handleFramesJumpChange} + handleFrameDelayChange={handleFrameDelayChange} + validFramesJumpOptions={validFramesJumpOptions} + minFrameDelay={minFrameDelay} + maxFrameDelay={maxFrameDelay} + isLoading={isLoading} + videoDuration={videoDuration} + updateConstraintsAndExpectedLength={updateConstraintsAndExpectedLength} + /> )} {isVideoUploaded && <p>Expected output video length: {expectedLength} seconds</p>} <Button variant="primary" type="submit" disabled={isLoading || expectedLength > 600 || expectedLength < 30 || !isVideoUploaded}> @@ -122,4 +84,4 @@ const VideoUploadForm = ({ handleFileChange, handleSubmit, handleFramesJumpChang ); }; -export default VideoUploadForm; \ No newline at end of file +export default VideoUploadForm; diff --git a/frontend/src/components/detection/video/videoUtils.js b/frontend/src/components/detection/video/videoUtils.js index 1efb7d67cfb53a90b241854dfd339810151c5870..0d73d5ebbcda5f60b56f0f4269fbcd3fc3755bd7 100644 --- a/frontend/src/components/detection/video/videoUtils.js +++ b/frontend/src/components/detection/video/videoUtils.js @@ -1,5 +1,3 @@ -// src/components/detection/video/videoUtils.js - export const getVideoDuration = (file) => { return new Promise((resolve) => { const video = document.createElement('video'); diff --git a/frontend/src/styles/input/AmazingDropdown.css b/frontend/src/styles/input/AmazingDropdown.css deleted file mode 100644 index 01449b4d80c72fd5e14b492e543d317607e3efc9..0000000000000000000000000000000000000000 --- a/frontend/src/styles/input/AmazingDropdown.css +++ /dev/null @@ -1,17 +0,0 @@ -.dropdown-menu { - max-height: 200px; - overflow-y: auto; - background-color: #f8f9fa; - border: 1px solid #ced4da; - border-radius: 0.25rem; -} - -.dropdown-item { - padding: 10px 20px; - transition: background-color 0.3s ease, color 0.3s ease; -} - -.dropdown-item:hover { - background-color: #007bff; - color: #fff; -} \ No newline at end of file diff --git a/frontend/src/styles/input/Dropdown.css b/frontend/src/styles/input/Dropdown.css new file mode 100644 index 0000000000000000000000000000000000000000..4119fe75918cca626621edd66ba4cc6e2eb93240 --- /dev/null +++ b/frontend/src/styles/input/Dropdown.css @@ -0,0 +1,155 @@ +/* Dropdown.css */ +.dropdown-container { + position: relative; + width: 100%; + /* Adjust to full width */ + margin: 0; + /* Remove margin to align with other form elements */ + font-family: 'Arial', sans-serif; + user-select: none; +} + +.dropdown-header { + background-color: #fff; + padding: 10px 15px; + /* Adjust padding for consistency */ + border-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + font-size: 16px; + position: relative; + z-index: 2; +} + +.dropdown-header.open { + background-color: #f0f0f0; +} + +.dropdown-header .icon { + font-size: 12px; + transition: transform 0.3s ease; +} + +.dropdown-header .icon.open { + transform: rotate(180deg); +} + +.dropdown-list { + position: absolute; + width: 100%; + background-color: #fff; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + z-index: 1; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease, padding 0.3s ease; + padding: 0; +} + +.dropdown-list.open { + max-height: 200px; + padding: 10px 0; +} + +.dropdown-item { + padding: 15px 20px; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; + font-size: 16px; + opacity: 0; + animation: slideIn 0.3s forwards; +} + +.dropdown-item:nth-child(1) { + animation-delay: 0.1s; +} + +.dropdown-item:nth-child(2) { + animation-delay: 0.2s; +} + +.dropdown-item:nth-child(3) { + animation-delay: 0.3s; +} + +.dropdown-item:nth-child(4) { + animation-delay: 0.4s; +} + +.dropdown-item:nth-child(5) { + animation-delay: 0.5s; +} + +.dropdown-item:nth-child(6) { + animation-delay: 0.6s; +} + +.dropdown-item:nth-child(7) { + animation-delay: 0.7s; +} + +.dropdown-item:nth-child(8) { + animation-delay: 0.8s; +} + +.dropdown-item:nth-child(9) { + animation-delay: 0.9s; +} + +.dropdown-item:nth-child(10) { + animation-delay: 1s; +} + +.dropdown-item:hover { + background-color: #f0f0f0; + color: #333; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Advanced styling and animations */ +.dropdown-container { + background: linear-gradient(to right, #f7f7f7, #eaeaea); + padding: 10px; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); +} + +.dropdown-header { + background: linear-gradient(to right, #fff, #f0f0f0); + padding: 20px; + font-size: 20px; + font-weight: bold; +} + +.dropdown-list { + border: 1px solid #ddd; + max-height: 0; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #ccc #fff; +} + +.dropdown-list::-webkit-scrollbar { + width: 8px; +} + +.dropdown-list::-webkit-scrollbar-thumb { + background-color: #ccc; + border-radius: 10px; +} \ No newline at end of file diff --git a/frontend/src/styles/input/FileInput.css b/frontend/src/styles/input/FileInput.css new file mode 100644 index 0000000000000000000000000000000000000000..213aae228461b7f6a32ce966ea869e852ddb53e6 --- /dev/null +++ b/frontend/src/styles/input/FileInput.css @@ -0,0 +1,80 @@ +.file-input-container { + display: flex; + flex-direction: column; + align-items: center; + margin: 20px 0; + font-family: 'Arial', sans-serif; + width: 100%; + max-width: 400px; +} + +.file-input-label { + font-size: 16px; + margin-bottom: 10px; + color: #333; +} + +.file-input-wrapper { + display: flex; + align-items: center; + position: relative; + width: 100%; + justify-content: center; +} + +.file-input-field { + display: none; +} + +.file-input-upload-button { + padding: 10px 20px; + background-color: #007bff; + color: #fff; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + gap: 10px; + transition: background-color 0.3s ease; +} + +.file-input-upload-button:hover { + background-color: #0056b3; +} + +.file-details { + display: flex; + align-items: center; + margin-top: 10px; +} + +.file-name { + font-size: 14px; + color: #333; + margin-right: 10px; +} + +.file-remove-button { + padding: 5px 10px; + background-color: #ff4d4d; + color: #fff; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + gap: 5px; + transition: background-color 0.3s ease; +} + +.file-remove-button:hover { + background-color: #ff1a1a; +} + +.upload-icon, +.remove-icon { + font-size: 18px; +} \ No newline at end of file diff --git a/frontend/src/styles/input/NumberInput.css b/frontend/src/styles/input/NumberInput.css new file mode 100644 index 0000000000000000000000000000000000000000..32155ea65f5b7e3bfa47f6d15fd06785cb4f32d9 --- /dev/null +++ b/frontend/src/styles/input/NumberInput.css @@ -0,0 +1,79 @@ +/* NumberInput.css */ + +.number-input-container { + display: flex; + flex-direction: column; + align-items: center; + margin: 20px 0; + font-family: 'Arial', sans-serif; + width: 100%; + max-width: 300px; +} + +.number-input-label { + font-size: 16px; + margin-bottom: 10px; + color: #333; +} + +.number-input-wrapper { + display: flex; + align-items: center; + position: relative; + background-color: #fff; + border: 1px solid #ccc; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.number-input-field { + width: 100px; + padding: 10px; + font-size: 16px; + border: none; + outline: none; + text-align: center; + border-right: 1px solid #ccc; + -moz-appearance: textfield; +} + +.number-input-field::-webkit-outer-spin-button, +.number-input-field::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.number-input-controls { + display: flex; + flex-direction: column; + align-items: center; + background-color: #f7f7f7; +} + +.number-input-button { + width: 40px; + height: 30px; + border: none; + background: none; + cursor: pointer; + font-size: 18px; + color: #333; + transition: background-color 0.3s ease; +} + +.number-input-button:hover { + background-color: #e0e0e0; +} + +.number-input-button.up { + border-bottom: 1px solid #ccc; +} + +.number-input-button.down { + border-top: 1px solid #ccc; +} + +.number-input-button:focus { + outline: none; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..bc2e46209c851b07798d491f03b75cd68f1be1b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "identification-of-misplaced-items", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "react-icons": "^5.2.1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT", + "peer": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-icons": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz", + "integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..1d298f0ff8e481ec0177b580b79d6673334df9a1 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "react-icons": "^5.2.1" + } +}