feat(profiles): Add basic functionality for profile page

- Create ProfileCard and ProfileModal components
- Implement profile.py backend file for CRUD operations
- Update API file with profile-related functions
- Modify main application file to include profile blueprint
- Add profile directory initialization
This commit is contained in:
santiagosayshey
2024-08-24 00:55:39 +09:30
committed by Sam Chau
parent 330c162b0e
commit 8cb3d7a827
9 changed files with 547 additions and 6 deletions

View File

@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "run.py"]
CMD ["python", "run.py"]

View File

@@ -3,25 +3,29 @@ from flask_cors import CORS
from .regex import bp as regex_bp
from .format import bp as format_bp
from .settings import bp as settings_bp
from .profile import bp as profile_bp
import os
REGEX_DIR = os.path.join('data', 'db', 'regex_patterns')
FORMAT_DIR = os.path.join('data', 'db', 'custom_formats')
PROFILE_DIR = os.path.join('data', 'db', 'profiles')
def create_app():
app = Flask(__name__)
CORS(app, resources={r"/*": {"origins": "*"}})
# Initialize directories to avoid issues with non-existing directories
initialize_directories()
# Register Blueprints
app.register_blueprint(regex_bp)
app.register_blueprint(format_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(profile_bp)
return app
def initialize_directories():
os.makedirs(REGEX_DIR, exist_ok=True)
os.makedirs(FORMAT_DIR, exist_ok=True)
os.makedirs(PROFILE_DIR, exist_ok=True)

112
backend/app/profile.py Normal file
View File

@@ -0,0 +1,112 @@
from flask import Blueprint, request, jsonify
from collections import OrderedDict
import os
import yaml
import logging
from .utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input
bp = Blueprint('profile', __name__, url_prefix='/profile')
DATA_DIR = '/app/data'
PROFILE_DIR = os.path.join(DATA_DIR, 'db', 'profiles')
# Ensure the directory exists
os.makedirs(PROFILE_DIR, exist_ok=True)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@bp.route('', methods=['GET', 'POST'])
def handle_profiles():
if request.method == 'POST':
data = request.json
saved_data = save_profile(data)
return jsonify(saved_data), 201
else:
profiles = load_all_profiles()
return jsonify(profiles)
@bp.route('/<int:id>', methods=['GET', 'PUT', 'DELETE'])
def handle_profile(id):
if request.method == 'GET':
profile = load_profile(id)
if profile:
return jsonify(profile)
return jsonify({"error": "Profile not found"}), 404
elif request.method == 'PUT':
data = request.json
data['id'] = id
saved_data = save_profile(data)
return jsonify(saved_data)
elif request.method == 'DELETE':
if delete_profile(id):
return jsonify({"message": f"Profile with ID {id} deleted."}), 200
return jsonify({"error": f"Profile with ID {id} not found."}), 404
def save_profile(data):
logger.info("Received data for saving profile: %s", data)
# Sanitize and extract necessary fields
name = sanitize_input(data.get('name', ''))
description = sanitize_input(data.get('description', ''))
profile_id = data.get('id', None)
# Determine if this is a new profile or an existing one
if profile_id == 0 or not profile_id:
profile_id = get_next_id(PROFILE_DIR)
logger.info("Assigned new profile ID: %d", profile_id)
date_created = get_current_timestamp()
else:
existing_filename = os.path.join(PROFILE_DIR, f"{profile_id}.yml")
if os.path.exists(existing_filename):
existing_data = load_profile(profile_id)
date_created = existing_data.get('date_created', get_current_timestamp())
else:
raise FileNotFoundError(f"No existing file found for ID: {profile_id}")
date_modified = get_current_timestamp()
# Process tags
tags = [sanitize_input(tag) for tag in data.get('tags', [])]
# Construct the ordered data
ordered_data = OrderedDict([
('id', profile_id),
('name', name),
('description', description),
('date_created', str(date_created)),
('date_modified', str(date_modified)),
('tags', tags)
])
# Generate the filename using only the ID
filename = os.path.join(PROFILE_DIR, f"{profile_id}.yml")
# Write to the file
with open(filename, 'w') as file:
yaml.dump(ordered_data, file, default_flow_style=False, Dumper=yaml.SafeDumper)
return ordered_data
def load_profile(id):
filename = os.path.join(PROFILE_DIR, f"{id}.yml")
if os.path.exists(filename):
with open(filename, 'r') as file:
data = yaml.safe_load(file)
return data
return None
def load_all_profiles():
profiles = []
for filename in os.listdir(PROFILE_DIR):
if filename.endswith('.yml'):
with open(os.path.join(PROFILE_DIR, filename), 'r') as file:
data = yaml.safe_load(file)
profiles.append(data)
return profiles
def delete_profile(id):
filename = os.path.join(PROFILE_DIR, f"{id}.yml")
if os.path.exists(filename):
os.remove(filename)
return True
return False

View File

@@ -2,6 +2,7 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { useState, useEffect } from "react";
import RegexPage from "./components/regex/RegexPage";
import FormatPage from "./components/format/FormatPage";
import ProfilePage from "./components/profile/ProfilePage";
import SettingsPage from "./components/settings/SettingsPage";
import Navbar from "./components/ui/Navbar";
import { ToastContainer } from "react-toastify";
@@ -26,6 +27,7 @@ function App() {
<Routes>
<Route path="/regex" element={<RegexPage />} />
<Route path="/format" element={<FormatPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/" element={<SettingsPage />} />
</Routes>

View File

@@ -244,3 +244,43 @@ export const getDiff = async (filePath) => {
throw error;
}
};
export const getProfiles = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/profile`);
return response.data;
} catch (error) {
console.error('Error fetching profiles:', error);
throw error;
}
};
export const saveProfile = async (profile) => {
try {
const response = await axios.post(`${API_BASE_URL}/profile`, profile);
return response.data;
} catch (error) {
console.error('Error saving profile:', error);
throw error;
}
};
export const updateProfile = async (id, profile) => {
try {
const response = await axios.put(`${API_BASE_URL}/profile/${id}`, profile);
return response.data;
} catch (error) {
console.error('Error updating profile:', error);
throw error;
}
};
export const deleteProfile = async (id) => {
try {
const response = await axios.delete(`${API_BASE_URL}/profile/${id}`);
return response.data;
} catch (error) {
console.error('Error deleting profile:', error);
throw error;
}
};

View File

@@ -0,0 +1,69 @@
import PropTypes from "prop-types";
function unsanitize(text) {
return text.replace(/\\:/g, ":").replace(/\\n/g, "\n");
}
function ProfileCard({ profile, onEdit, onClone, showDate, formatDate }) {
return (
<div
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-sm cursor-pointer hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500 transition-shadow"
onClick={() => onEdit(profile)}
>
<div className="flex justify-between items-center">
<h3 className="font-bold text-lg text-gray-800 dark:text-gray-200">
{unsanitize(profile.name)}
</h3>
<button
onClick={(e) => {
e.stopPropagation();
onClone(profile);
}}
className="relative group"
>
<img
src="/clone.svg"
alt="Clone"
className="w-5 h-5 transition-transform transform group-hover:scale-125 group-hover:rotate-12 group-hover:-translate-y-1 group-hover:translate-x-1"
/>
<span className="absolute bottom-0 right-0 w-2 h-2 bg-green-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"></span>
</button>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm mt-2 mb-2">
{unsanitize(profile.description)}
</p>
{showDate && (
<p className="text-gray-500 dark:text-gray-400 text-xs mb-2">
Modified: {formatDate(profile.date_modified)}
</p>
)}
<div className="flex flex-wrap">
{profile.tags &&
profile.tags.map((tag) => (
<span
key={tag}
className="bg-blue-100 text-blue-800 text-xs font-medium mr-2 mb-1 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300"
>
{tag}
</span>
))}
</div>
</div>
);
}
ProfileCard.propTypes = {
profile: PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
date_modified: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.string),
}).isRequired,
onEdit: PropTypes.func.isRequired,
onClone: PropTypes.func.isRequired,
showDate: PropTypes.bool.isRequired,
formatDate: PropTypes.func.isRequired,
};
export default ProfileCard;

View File

@@ -0,0 +1,180 @@
import { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { saveProfile, deleteProfile } from "../../api/api";
import Modal from "../ui/Modal";
function unsanitize(text) {
return text.replace(/\\:/g, ":").replace(/\\n/g, "\n");
}
function ProfileModal({ profile = null, isOpen, onClose, onSave }) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [tags, setTags] = useState([]);
const [newTag, setNewTag] = useState("");
const [error, setError] = useState("");
const initialProfileRef = useRef(profile);
useEffect(() => {
if (isOpen) {
if (profile && profile.id !== 0) {
initialProfileRef.current = profile;
setName(unsanitize(profile.name));
setDescription(unsanitize(profile.description));
setTags(profile.tags ? profile.tags.map(unsanitize) : []);
} else {
initialProfileRef.current = null;
setName(profile ? unsanitize(profile.name) : "");
setDescription(profile ? unsanitize(profile.description) : "");
setTags(profile ? profile.tags.map(unsanitize) : []);
}
setError("");
setNewTag("");
}
}, [profile, isOpen]);
const handleSave = async () => {
if (!name.trim()) {
setError("Name is required.");
return;
}
try {
await saveProfile({
id: profile ? profile.id : 0,
name,
description,
tags,
});
onSave();
onClose();
} catch (error) {
console.error("Error saving profile:", error);
setError("Failed to save profile. Please try again.");
}
};
const handleDelete = async () => {
const confirmDeletion = window.confirm(
"Are you sure you want to delete this profile?"
);
if (!confirmDeletion) return;
try {
await deleteProfile(profile.id);
onSave();
onClose();
} catch (error) {
console.error("Error deleting profile:", error);
setError("Failed to delete profile. Please try again.");
}
};
const handleAddTag = () => {
if (newTag.trim() && !tags.includes(newTag.trim())) {
setTags([...tags, newTag.trim()]);
setNewTag("");
}
};
const handleRemoveTag = (tagToRemove) => {
setTags(tags.filter((tag) => tag !== tagToRemove));
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={initialProfileRef.current ? "Edit Profile" : "Add Profile"}
>
{error && <div className="text-red-500 mb-4">{error}</div>}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Profile Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter profile name"
className="w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description"
className="w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
rows="3"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tags
</label>
<div className="flex flex-wrap mb-2">
{tags.map((tag) => (
<span
key={tag}
className="bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300"
>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="ml-1 text-xs"
>
&times;
</button>
</span>
))}
</div>
<div className="flex">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="Add a tag"
className="flex-grow p-2 border rounded-l dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
/>
<button
onClick={handleAddTag}
className="bg-blue-500 text-white px-4 py-2 rounded-r hover:bg-blue-600 transition-colors"
>
Add
</button>
</div>
</div>
<div className="flex justify-between">
<button
onClick={handleDelete}
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition-colors"
>
Delete
</button>
<button
onClick={handleSave}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
>
Save
</button>
</div>
</Modal>
);
}
ProfileModal.propTypes = {
profile: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string.isRequired,
description: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
}),
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
};
export default ProfileModal;

View File

@@ -0,0 +1,122 @@
import { useState, useEffect } from "react";
import ProfileCard from "./ProfileCard";
import ProfileModal from "./ProfileModal";
import AddNewCard from "../ui/AddNewCard";
import { getProfiles } from "../../api/api";
import FilterMenu from "../ui/FilterMenu";
import SortMenu from "../ui/SortMenu";
function ProfilePage() {
const [profiles, setProfiles] = useState([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedProfile, setSelectedProfile] = useState(null);
const [sortBy, setSortBy] = useState("name");
const [filterType, setFilterType] = useState("none");
const [filterValue, setFilterValue] = useState("");
const [allTags, setAllTags] = useState([]);
useEffect(() => {
fetchProfiles();
}, []);
const fetchProfiles = async () => {
try {
const fetchedProfiles = await getProfiles();
setProfiles(fetchedProfiles);
const tags = [
...new Set(fetchedProfiles.flatMap((profile) => profile.tags || [])),
];
setAllTags(tags);
} catch (error) {
console.error("Error fetching profiles:", error);
}
};
const handleOpenModal = (profile = null) => {
setSelectedProfile(profile);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setSelectedProfile(null);
setIsModalOpen(false);
};
const handleSaveProfile = () => {
fetchProfiles();
handleCloseModal();
};
const handleCloneProfile = (profile) => {
const clonedProfile = {
...profile,
id: 0, // Ensure the ID is 0 for a new entry
name: `${profile.name} [COPY]`,
};
setSelectedProfile(clonedProfile);
setIsModalOpen(true);
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleString();
};
const sortedAndFilteredProfiles = profiles
.filter((profile) => {
if (filterType === "tag") {
return profile.tags && profile.tags.includes(filterValue);
}
if (filterType === "date") {
const profileDate = new Date(profile.date_modified);
const filterDate = new Date(filterValue);
return profileDate.toDateString() === filterDate.toDateString();
}
return true;
})
.sort((a, b) => {
if (sortBy === "name") return a.name.localeCompare(b.name);
if (sortBy === "dateCreated")
return new Date(b.date_created) - new Date(a.date_created);
if (sortBy === "dateModified")
return new Date(b.date_modified) - new Date(a.date_modified);
return 0;
});
return (
<div>
<h2 className="text-2xl font-bold mb-4">Manage Profiles</h2>
<div className="mb-4 flex items-center space-x-4">
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
<FilterMenu
filterType={filterType}
setFilterType={setFilterType}
filterValue={filterValue}
setFilterValue={setFilterValue}
allTags={allTags}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4">
{sortedAndFilteredProfiles.map((profile) => (
<ProfileCard
key={profile.id}
profile={profile}
onEdit={() => handleOpenModal(profile)}
onClone={handleCloneProfile}
showDate={sortBy !== "name"}
formatDate={formatDate}
/>
))}
<AddNewCard onAdd={() => handleOpenModal()} />
</div>
<ProfileModal
profile={selectedProfile}
isOpen={isModalOpen}
onClose={handleCloseModal}
onSave={handleSaveProfile}
allTags={allTags}
/>
</div>
);
}
export default ProfilePage;

View File

@@ -44,6 +44,7 @@ function Navbar({ darkMode, setDarkMode }) {
const getActiveTab = (pathname) => {
if (pathname.startsWith("/regex")) return "regex";
if (pathname.startsWith("/format")) return "format";
if (pathname.startsWith("/profile")) return "profile";
if (pathname.startsWith("/settings")) return "settings";
return "settings"; // default to settings if no match
};
@@ -78,7 +79,7 @@ function Navbar({ darkMode, setDarkMode }) {
: "text-gray-300 hover:bg-gray-700 hover:text-white"
}`}
>
Regex
Regex Patterns
</Link>
<Link
to="/format"
@@ -89,7 +90,18 @@ function Navbar({ darkMode, setDarkMode }) {
: "text-gray-300 hover:bg-gray-700 hover:text-white"
}`}
>
Custom Format
Custom Formats
</Link>
<Link
to="/profile"
ref={(el) => (tabsRef.current["profile"] = el)}
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
activeTab === "profile"
? "text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-white"
}`}
>
Profiles
</Link>
<Link
to="/settings"