diff --git a/pages/dev/add-people/+Page.client.ts b/pages/dev/add-people/+Page.client.ts new file mode 100644 index 000000000..47aba492f --- /dev/null +++ b/pages/dev/add-people/+Page.client.ts @@ -0,0 +1,293 @@ +import h from "./main.module.sass"; + +import { BasePage } from "~/components/general"; +import { DataField } from "~/components/unit-details"; +import { fetchPGData } from "~/_utils"; + +import { SaveButton } from "@macrostrat/ui-components"; +import { postgrestPrefix } from "@macrostrat-web/settings"; +import { MultiSelect } from "@blueprintjs/select"; +import { MenuItem } from "@blueprintjs/core"; + +import { useEffect, useState } from "react"; + +export function Page() { + const [form, setForm] = useState({ + name: null, + email: null, + title: null, + website: null, + img_id: null, + active_start: null, + active_end: null, + roles: [], + }); + + const disabled = !form.name || !form.email || !form.title || !form.img_id || form.roles.length === 0; + + const handleChange = (field) => (value) => { + setForm({ ...form, [field]: value }); + }; + + console.log("form", form) + + return h(BasePage, { title: "Add people" }, [ + h("div.add-people-page", [ + h("p", "This page is meant to add people to the Macrostrat database. Please fill out the form below with the person's details."), + ]), + h('div.form', [ + h('div.inputs', [ + h(TextInput, { + label: "Name *", + value: form.name, + onChange: handleChange("name"), + required: true + }), + h(TextInput, { + label: "Email *", + value: form.email, + onChange: handleChange("email"), + required: true, + pattern: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$" + }), + h(TextInput, { + label: "Title *", + value: form.title, + onChange: handleChange("title"), + required: true + }), + h(RolesInput, { setForm }), + h(ImageInput, { + label: "Profile Image *", + value: form.img_id, + onChange: handleChange("img_id"), + required: true + }), + h(TextInput, { + label: "Website", + value: form.website, + onChange: handleChange("website"), + pattern: "https?://.+" + }), + h(DateInput, { + label: "Active Start Date", + value: form.active_start, + onChange: handleChange("active_start"), + required: true + }), + h(DateInput, { + label: "Active End Date", + value: form.active_end, + onChange: handleChange("active_end") + }), + ]), + h(SubmitButton, { disabled, form, setForm }), + h("p.note", h('em', "Fields marked with * are required")), + ]), + ]); +} + +// === Input Components === + +function TextInput({ label, value = "", onChange, required = false, pattern }) { + return h(DataField, { + label, + value: h("input.text-input", { + type: "text", + value, + required, + pattern, + onInput: (e) => onChange(e.target.value), + }) + }); +} + +function DateInput({ label, value = "", onChange, required = false }) { + return h(DataField, { + label, + value: h("input.date-input", { + type: "date", + value, + required, + onInput: (e) => onChange(e.target.value), + }) + }); +} + +function ImageInput({ label, value = null, onChange, required = false }) { + return h(DataField, { + label, + value: h("input.image-input", { + type: "file", + accept: "image/*", + required, + onChange: (e) => { + const file = e.target.files[0]; + if (file) { + onChange(file); + } + }, + }), + }); +} + + +function SubmitButton({ disabled, form, setForm }) { + const [inProgress, setInProgress] = useState(false); + const text = disabled ? "Please fill out all required fields" : "Add person"; + + const handleSubmit = () => { + if (disabled) return; + setInProgress(true); + + // Upload image + const APIURL = "http://localhost:8000/image_upload"; + const formData = new FormData(); + formData.append("file", form.img_id); + + return fetch(APIURL, { + method: "POST", + body: formData, + }) + .then(res => { + if (!res.ok) throw new Error(`Image upload failed: ${res.statusText}`); + return res.json(); + }) + .then(data => { + // Upload person + uploadPerson({ data, form }); + }) + .then(() => { + // Handle successful upload + alert("Person added successfully!"); + setForm({ + name: null, + email: null, + title: null, + website: null, + img_id: null, + active_start: null, + active_end: null, + roles: [], + }) + setInProgress(false); + }) + .catch(err => console.error("Image upload error:", err)); + }; + + return h(SaveButton, { disabled, onClick: handleSubmit, inProgress }, text); +} + +function RolesInput({ setForm }) { + const [roles, setRoles] = useState([]); + const [selectedRoles, setSelectedRoles] = useState([]); + + useEffect(() => { + fetchPGData("/roles", {}) + .then((data) => { + setRoles(data); + }) + .catch((err) => { + console.error("Failed to fetch roles:", err); + }); + }, []); + + const isItemSelected = (item) => + selectedRoles.some((r) => r.role_id === item.role_id); + + const handleItemSelect = (item) => { + if (!isItemSelected(item)) { + const next = [...selectedRoles, item]; + console.log('Selected roles updated:', next.map((r) => r.role_id)); + setSelectedRoles(next); + setForm((prev) => ({ + ...prev, + roles: next.map((r) => r.role_id), + })); + } + }; + + const handleItemDelete = (itemToDelete) => { + const next = selectedRoles.filter((item) => item.role_id !== itemToDelete.role_id); + setSelectedRoles(next); + setForm((prev) => ({ + ...prev, + roles: next.map((r) => r.role_id), + })); + }; + + const itemPredicate = (query, item) => + item.name.toLowerCase().includes(query.toLowerCase()); + + const itemRenderer = (item, { handleClick, modifiers }) => { + if (!modifiers.matchesPredicate) return null; + + return h(MenuItem, { + key: item.role_id, + text: item.name, + onClick: handleClick, + active: modifiers.active, + shouldDismissPopover: false, + }); + }; + + const items = roles.filter((role) => !isItemSelected(role)); + + return h(DataField, { + label: "Roles *", + value: h(MultiSelect, { + items, + itemRenderer, + itemPredicate, + selectedItems: selectedRoles, + onItemSelect: handleItemSelect, + onRemove: handleItemDelete, + tagRenderer: (item) => item.name, + popoverProps: { minimal: true }, + fill: true, + }), + }); +} + +function uploadPerson({ data, form }) { + const { roles, img_id, ...personData } = form; + const filteredPersonData = Object.fromEntries( + Object.entries(personData).filter(([_, v]) => v !== null && v !== undefined) + ); + + const fullData = { + ...filteredPersonData, + img_id: data.filename + } + + const body = new URLSearchParams(fullData).toString(); + + fetch(`${postgrestPrefix}/people`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Prefer": "return=representation", + }, + body, + }) + .then(r => r.json()) + .then(data => { + const personId = data[0].person_id; + + roles.forEach(roleId => { + console.log("Assigning role:", roleId, "to person:", personId); + const body = new URLSearchParams({ person_id: personId, role_id: roleId }).toString(); + + fetch(`${postgrestPrefix}/people_roles`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Prefer": "return=representation", + }, + body, + }) + .catch(e => console.error("Role assignment error:", e)); + }); + }) + .catch(e => console.error("Test submission error:", e)); +} \ No newline at end of file diff --git a/pages/dev/add-people/main.module.sass b/pages/dev/add-people/main.module.sass new file mode 100644 index 000000000..e629af01c --- /dev/null +++ b/pages/dev/add-people/main.module.sass @@ -0,0 +1,10 @@ +.form, .inputs + display: flex + flex-direction: column + gap: 1em + +.inputs + gap: .5em + +.add-people-page + margin: 1em 0 \ No newline at end of file diff --git a/pages/people/+Page.ts b/pages/people/+Page.ts index d447d2169..8754f4aaf 100644 --- a/pages/people/+Page.ts +++ b/pages/people/+Page.ts @@ -1,144 +1,52 @@ -import { Image, Navbar, Footer, SearchBar } from "~/components/general"; +import { PersonImage, Navbar, Footer, SearchBar } from "~/components/general"; import h from "./main.module.sass"; import { Card, Divider } from "@blueprintjs/core"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { ContentPage } from "~/layouts"; +import { fetchPGData } from "~/_utils"; export function Page() { const [input, setInput] = useState(""); const [tags, setTags] = useState([]); - const res = [ - { - name: "Shanan Peters", - role: "Professor, Database Developer", - email: "peters@geology.wisc.edu", - link: "http://strata.geology.wisc.edu", - image: "shanan.jpg", - }, - { - name: "Daven Quinn", - role: "Research Scientist, Developer", - email: "daven.quinn@wisc.edu", - link: "https://davenquinn.com", - image: "daven.jpg", - }, - { - name: "Evgeny Mazko", - role: "Graduate Student", - email: "mazko@wisc.edu", - link: null, - image: "evgeny.jpg", - }, - { - name: "Michael McClennen", - role: "Senior Programmer Analyst", - email: "mmcclenn@geology.wisc.edu", - link: "https://geoscience.wisc.edu/geoscience/people/staff/name/michael-mcclennen/", - image: "michael.jpg", - }, - { - name: "Casey Idzikowski", - role: "Research Specialist, Developer (former)", - email: null, - link: "https://idzikowski-casey.github.io/personal-site/", - image: "casey.jpg", - }, - { - name: "David Sklar", - role: "Undergrad Student", - email: "dsklar@wisc.edu", - link: null, - image: "david.jpg", - }, - { - name: "Amy Fromandi", - role: null, - email: "punkish@eidesis.org", - link: null, - image: "amy.jpg", - }, - { - name: "Daniel Segessenmen", - role: "Graduate Student (former)", - email: null, - link: "http://strata.geology.wisc.edu", - image: "daniel.jpg", - }, - { - name: "Shan Ye", - role: "Graduate Student (former)", - email: null, - link: "https://www.wisc.edu/directories/person.php?name=Victoria+Khoo&email=vkhoo%40wisc.edu&query=victoria%20khoo", - image: "shan.jpg", - }, - { - name: "Ben Linzmeier", - role: "Postdoctoral Scholar (former)", - email: null, - link: "http://strata.geology.wisc.edu", - image: "ben.jpg", - }, - { - name: "Afiqah Rafi", - role: "Undergrad Student (former)", - email: null, - link: "https://www.wisc.edu/directories/person.php?name=Victoria+Khoo&email=vkhoo%40wisc.edu&query=victoria%20khoo", - image: "afiqah.jpg", - }, - { - name: "Sharon McMullen", - role: "Researcher (former)", - email: null, - link: "http://geoscience.wisc.edu/geoscience/people/student/?id=1007", - image: "sharon.jpg", - }, - { - name: "Andrew Zaffos", - role: "Data Mobilization and Research Scientist", - email: "azaffos@email.arizona.edu", - link: "http://www.azstrata.org", - image: "andrew.jpg", - }, - { - name: "Jon Husson", - role: "Postdoctoral Researcher (former)", - email: "jhusson@uvic.ca", - link: "http://www.jonhusson.com", - image: "jon.jpg", - }, - ]; - console.log(tags); + const [res, setPeople] = useState([]); + const [tagList, setTagList] = useState([]); - const tagList = [ - "Student", - "Researcher", - "Developer", - "Postdoc", - "Research Scientist", - "Former", - ]; + useEffect(() => { + fetchPGData("/people_with_roles", { name: `ilike.*${input}*` }) + .then(setPeople) + .catch((err) => { + console.error("Failed to fetch people:", err); + }); + }, [input]); - const handleInputChange = (e) => { - const value = e.target.value; - setInput(value); - }; + useEffect(() => { + fetchPGData("/roles", {}) + .then(data => { + setTagList([...(data.map(role => role.name)), "Active", "Former"]); + }) + .catch((err) => { + console.error("Failed to fetch tags:", err); + }); + }, []); + + if(!res || !tagList) { + return h("div.loading", "Loading..."); + } const filteredPeople = res.filter((person) => { const name = person.name.toLowerCase(); - const role = person.role ? person.role.toLowerCase() : ""; + const role = person.roles.map(role => role.name).join(", ").toLowerCase(); const email = person.email ? person.email.toLowerCase() : ""; - const roleTags = tagList - .map((tag) => { - if (role.includes(tag.toLowerCase())) { - return tag; - } - return null; - }) - .filter((tag) => tag !== null); + const isActive = !person.active_end; + const personTags = [ + ...person.roles.map(role => role.name), + isActive ? "Active" : "Former", + ]; + const tagMatch = - tags.length === 0 || tags.every((tag) => roleTags.includes(tag)); + tags.length === 0 || tags.every((tag) => personTags.includes(tag)); return ( (name.includes(input) || role.includes(input) || email.includes(input)) && @@ -146,6 +54,7 @@ export function Page() { ); }); + return h("div", [ h(Navbar), h(ContentPage, { className: "people-page" }, [ @@ -156,7 +65,7 @@ export function Page() { ]), h(Card, { className: "search-bar" }, [ h(SearchBar, { - onChange: handleInputChange, + onChange: (e) => setInput(e), placeholder: "Search by name, role, or email", }), h("div.tags", [ @@ -192,13 +101,18 @@ export function Page() { ]); } -function PersonCard({ name, role, email, link, image }) { +function PersonCard({ name, roles, email, website, img_id, active_start, active_end }) { + const start = new Date(active_start).toLocaleDateString('en-US', { timeZone: 'UTC', year: 'numeric', month: 'long', day: 'numeric' }); + const end = new Date(active_end).toLocaleDateString('en-US', { timeZone: 'UTC', year: 'numeric', month: 'long', day: 'numeric' }); + return h("div.person-info", [ - h(Image, { src: image, className: "back-img" }), + h(PersonImage, { src: img_id, className: "back-img" }), h("div.description", [ - h("a.name", { href: link }, name), - role ? h("p.role", role) : null, - email ? h("a.email", { href: "mailto: " + email }, email) : null, + h("a.name", { href: website }, name), + h("p.role", roles.map(role => role.name).join(", ")), + h.if(active_start && !active_end)("p.start", `Active since ${start}`), + h.if(active_end)("p.dates", `Active from ${start} to ${end}`), + h.if(email)("a.email", { href: "mailto: " + email }, email), ]), ]); } diff --git a/scripts/upload-photo/Makefile b/scripts/upload-photo/Makefile new file mode 100644 index 000000000..24d3380a8 --- /dev/null +++ b/scripts/upload-photo/Makefile @@ -0,0 +1,5 @@ +run: + ./upload.sh + +list: + ./list.sh \ No newline at end of file diff --git a/scripts/upload-photo/list.sh b/scripts/upload-photo/list.sh new file mode 100755 index 000000000..620307434 --- /dev/null +++ b/scripts/upload-photo/list.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -euo pipefail + +# Load .env file from two directories up +ENV_PATH="$(dirname "$(dirname "$PWD")")/.env" +if [[ -f "$ENV_PATH" ]]; then + set -a + source "$ENV_PATH" + set +a +else + echo "❌ .env file not found at $ENV_PATH" + exit 1 +fi + +# Check required env vars +REQUIRED_VARS=(S3_ENDPOINT S3_BUCKET S3_PATH S3_ACCESS_KEY S3_SECRET_KEY) +missing=() +for var in "${REQUIRED_VARS[@]}"; do + if [[ -z "${!var:-}" ]]; then + missing+=("$var") + fi +done +if (( ${#missing[@]} > 0 )); then + echo "❌ Missing required environment variables: ${missing[*]}" + exit 1 +fi + +# Configure rclone using environment variables (no config file needed) +export RCLONE_CONFIG_S3_TYPE="s3" +export RCLONE_CONFIG_S3_PROVIDER="Minio" +export RCLONE_CONFIG_S3_ACCESS_KEY_ID="$S3_ACCESS_KEY" +export RCLONE_CONFIG_S3_SECRET_ACCESS_KEY="$S3_SECRET_KEY" +export RCLONE_CONFIG_S3_ENDPOINT="$S3_ENDPOINT" +export RCLONE_CONFIG_S3_ENV_AUTH="false" + +REMOTE_PATH="s3:${S3_BUCKET}/assets" + +echo "🔍 Listing files in '$REMOTE_PATH'..." + +rclone ls "$REMOTE_PATH" --log-level DEBUG + +rclone copy "s3:macrostrat-sites/assets" ./ diff --git a/scripts/upload-photo/upload.sh b/scripts/upload-photo/upload.sh new file mode 100755 index 000000000..c9734cec8 --- /dev/null +++ b/scripts/upload-photo/upload.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -euo pipefail + +# Load .env file from two directories up +ENV_PATH="$(dirname "$(dirname "$PWD")")/.env" +if [[ -f "$ENV_PATH" ]]; then + # Use `set -a` to automatically export all variables + set -a + source "$ENV_PATH" + set +a +else + echo "❌ .env file not found at $ENV_PATH" + exit 1 +fi + +# Check required env vars +REQUIRED_VARS=(S3_ENDPOINT S3_BUCKET S3_PATH S3_ACCESS_KEY S3_SECRET_KEY) +for var in "${REQUIRED_VARS[@]}"; do + if [[ -z "${!var:-}" ]]; then + echo "❌ Missing required environment variable: $var" + exit 1 + fi +done + +# File to upload +FILE="david.jpg" +if [[ ! -f "$FILE" ]]; then + echo "❌ File '$FILE' not found in current directory." + exit 1 +fi + +# Configure rclone using environment variables (no config file needed) +export RCLONE_CONFIG_S3_TYPE="s3" +export RCLONE_CONFIG_S3_PROVIDER="Minio" +export RCLONE_CONFIG_S3_ACCESS_KEY_ID="$S3_ACCESS_KEY" +export RCLONE_CONFIG_S3_SECRET_ACCESS_KEY="$S3_SECRET_KEY" +export RCLONE_CONFIG_S3_ENDPOINT="$S3_ENDPOINT" +export RCLONE_CONFIG_S3_ENV_AUTH="false" + +# Final destination path +DESTINATION="s3:/${S3_PATH}" +echo "⬆️ Uploading '$FILE' to '$DESTINATION'..." +rclone copy "$FILE" "$DESTINATION" --s3-no-check-bucket --s3-upload-concurrency=4 --progress + +echo "✅ Upload complete!" diff --git a/src/components/general/index.ts b/src/components/general/index.ts index 47d60844b..a54591c42 100644 --- a/src/components/general/index.ts +++ b/src/components/general/index.ts @@ -1,6 +1,8 @@ import h from "./layout.module.sass"; import { MacrostratIcon, StickyHeader } from "~/components"; import { Spinner, Icon, Card } from "@blueprintjs/core"; +import { ContentPage } from "~/layouts"; +import { PageBreadcrumbs } from "~/components"; import { useAPIResult } from "@macrostrat/ui-components"; import classNames from "classnames"; import { postgrestPrefix } from "@macrostrat-web/settings"; @@ -11,6 +13,12 @@ export function Image({ src, className, width, height }) { return h("img", { src: srcWithAddedPrefix, className, width, height }); } +export function PersonImage({ src, className, width, height }) { + const srcWithAddedPrefix = + "https://storage.macrostrat.org/macrostrat-sites/people/" + src; + return h("img", { src: srcWithAddedPrefix, className, width, height }); +} + export function NavListItem({ href, children }) { return h( "li.nav-list-item", @@ -167,6 +175,15 @@ export function IDTag({ id }) { return h("div.id-tag", "ID: #" + id); } +export function BasePage({title, className, children}) { + return h("div", [ + h(ContentPage, { className }, [ + h(PageBreadcrumbs, { title }), + children, + ]), + h(Footer), + ]); +} export function getPGData(url, filters) { return useAPIResult(postgrestPrefix + url, filters); } \ No newline at end of file