Compare commits

...

4 Commits

Author SHA1 Message Date
Chris Jean-Marie e3e7e4442b Add current fire rating to dashboard 2024-12-09 14:34:08 +00:00
Chris Jean-Marie 8b1cb60ef1 Add modal for new event details 2024-12-08 17:12:14 +00:00
Chris Jean-Marie ea0d26e4dd Make calendar general 2024-12-07 22:00:58 +00:00
Chris Jean-Marie 2ead3f79c3 Initial event load 2024-12-06 17:50:21 +00:00
7 changed files with 219 additions and 485 deletions

View File

@ -1,8 +1,8 @@
use askama::Template;
use askama_axum::{IntoResponse, Response};
use axum::{
extract::State,
response::{Html, Redirect},
extract::{Path, State},
response::{Html, Json, Redirect},
Extension,
};
use http::StatusCode;
@ -31,15 +31,21 @@ where
}
}
struct Calendar {
name: String,
colour: String,
}
#[derive(Template)]
#[template(path = "cottagecalendar.html")]
struct CottageCalendarTemplate {
#[template(path = "calendar.html")]
struct CalendarTemplate {
logged_in: bool,
user: UserData,
user_roles: Vec<crate::user::UserRolesDisplay>,
calendars: Vec<Calendar>,
}
pub async fn cottagecalendar(
pub async fn calendar(
Extension(user_data): Extension<Option<UserData>>,
State(db_pool): State<SqlitePool>,
) -> impl IntoResponse {
@ -51,14 +57,26 @@ pub async fn cottagecalendar(
let user = user_data.as_ref().unwrap().clone();
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
if is_authorized("/cottagecalendar", user_data, db_pool.clone()).await {
if is_authorized("/calendar", user_data, db_pool.clone()).await {
// Get user roles
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
let template = CottageCalendarTemplate {
let calendars: Vec<Calendar> = vec![
Calendar {
name: "Cottage".to_string(),
colour: "green".to_string(),
},
Calendar {
name: "Personal".to_string(),
colour: "blue".to_string(),
},
];
let template = CalendarTemplate {
logged_in,
user,
user_roles,
calendars,
};
HtmlTemplate(template).into_response()
} else {
@ -69,12 +87,28 @@ pub async fn cottagecalendar(
}
}
async fn get_next_event(db_pool: &SqlitePool) -> Option<String> {
let next_event = sqlx::query_as::<_, (String, String)>(
"SELECT date, title FROM events ORDER BY date ASC LIMIT 1",
)
.fetch_one(db_pool)
.await;
pub async fn get_events(
Path(calendar): Path<String>,
State(db_pool): State<SqlitePool>,
Extension(user_data): Extension<Option<UserData>>,
) -> String {
println!("Calendar: {}", calendar);
// Is the user logged in?
let logged_in = user_data.is_some();
next_event.map(|(date, title)| format!("{} - {}", date, title)).ok()
// Set default events
let mut events = "[]";
if logged_in {
// Extract the user data.
let user = user_data.as_ref().unwrap().clone();
let userid = user_data.as_ref().map(|s| s.id.clone()).unwrap_or_default();
if is_authorized("/calendar", user_data, db_pool.clone()).await {
// Get requested calendar events from database
events = "[{\"title\": \"Chris and Terri\", \"start\": \"2024-12-23T14:00:00\", \"end\": \"2024-12-27T10:00:00\", \"allDay\": false}, {\"title\": \"Stephen\", \"start\": \"2024-12-27T14:00:00\", \"end\": \"2024-12-31T10:00:00\", \"allDay\": false}]";
}
}
events.to_string()
}

View File

@ -4,7 +4,6 @@ use axum::{
Extension, Router,
};
use secret_gift_exchange::{giftexchange, giftexchange_save, giftexchanges};
use calendar::cottagecalendar;
use sqlx::migrate::Migrator;
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
use std::net::SocketAddr;
@ -25,6 +24,7 @@ use google_oauth::{google_auth_return, login, logout};
use middlewares::inject_user_data;
use routes::{about, contact, dashboard, index, profile, user_profile, useradmin};
use user::{add_user_role, delete_user_role, UserData};
use calendar::{calendar, get_events};
use wishlist::{user_wishlist, user_wishlist_add, user_wishlist_add_item, user_wishlist_bought_item, user_wishlist_delete_item, user_wishlist_edit_item, user_wishlist_received_item, user_wishlist_returned_item, user_wishlist_save_item, wishlists};
//use email::send_emails;
@ -68,7 +68,8 @@ async fn main() {
.route("/roles/:user_id/:user_role_id/delete", get(delete_user_role))
// Calendar
.route("/cottagecalendar", get(cottagecalendar))
.route("/calendar", get(calendar))
.route("/getevents/:calendar", get(get_events))
// Wishlist
.route("/wishlists", get(wishlists))

View File

@ -63,6 +63,7 @@ struct DashboardTemplate {
logged_in: bool,
user: UserData,
user_roles: Vec<crate::user::UserRolesDisplay>,
fire_rating: String,
}
pub async fn index(
@ -83,7 +84,10 @@ pub async fn index(
HtmlTemplate(template).into_response()
}
} else {
let template = IndexTemplate { logged_in, user: UserData::default() };
let template = IndexTemplate {
logged_in,
user: UserData::default(),
};
HtmlTemplate(template).into_response()
}
}
@ -104,10 +108,13 @@ pub async fn dashboard(
// Get user roles
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
let fire_rating = get_seguin_fire_rating().await;
let template = DashboardTemplate {
logged_in,
user,
user_roles,
fire_rating,
};
HtmlTemplate(template).into_response()
} else {
@ -223,7 +230,6 @@ pub async fn useradmin(
.await
.unwrap();
if is_authorized("/useradmin", user_data, db_pool.clone()).await {
// Get user roles
let user_roles = get_user_roles_display(userid, &db_pool.clone()).await;
@ -254,8 +260,23 @@ pub async fn about(Extension(user_data): Extension<Option<UserData>>) -> impl In
// Is the user logged in?
let logged_in = user_data.is_some();
// Set empty user
let mut user = UserData {
id: 0,
email: "".to_string(),
created_at: 0,
created_by: 0,
updated_at: 0,
updated_by: 0,
name: "".to_string(),
family_name: "".to_string(),
given_name: "".to_string(),
};
if logged_in {
// Extract the user data.
let user = user_data.as_ref().unwrap().clone();
user = user_data.as_ref().unwrap().clone();
}
let template = AboutTemplate { logged_in, user };
HtmlTemplate(template)
@ -272,9 +293,50 @@ pub async fn contact(Extension(user_data): Extension<Option<UserData>>) -> impl
// Is the user logged in?
let logged_in = user_data.is_some();
// Set empty user
let mut user = UserData {
id: 0,
email: "".to_string(),
created_at: 0,
created_by: 0,
updated_at: 0,
updated_by: 0,
name: "".to_string(),
family_name: "".to_string(),
given_name: "".to_string(),
};
if logged_in {
// Extract the user data.
let user = user_data.as_ref().unwrap().clone();
user = user_data.as_ref().unwrap().clone();
}
let template = ContactTemplate { logged_in, user };
HtmlTemplate(template)
}
pub async fn get_seguin_fire_rating() -> String {
let response = reqwest::get("https://www.seguin.ca/en/explore-play/firerating.aspx")
.await
.unwrap();
let fire_rating: String;
let body = response.text().await.unwrap();
let result = body.find(r#"<img title="#);
if let Some(result) = result {
let link = body[result..].to_string();
let link_end = link.find(r#">"#);
if let Some(link_end) = link_end {
fire_rating = link[..link_end +1].to_string();
} else {
println!("not found");
fire_rating = "0".to_string();
}
} else {
fire_rating = "0".to_string();
}
fire_rating
}

View File

@ -6,7 +6,7 @@
<h2>Menu</h2>
<ul>
<li><a href="/dashboard">Web links</a></li>
<li><a href="/cottagecalendar">Cottage Calendar</a></li>
<li><a href="/calendar">Calendar</a></li>
<li><a href="/wishlists">Wish lists</a></li>
</ul>
{% for user_role in user_roles %}

View File

@ -0,0 +1,78 @@
{% extends "authorized.html" %}
{% block links %}
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@fullcalendar/core@4.2.0/main.min.css'>
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@fullcalendar/daygrid@4.3.0/main.min.css'>
{% endblock links %}
{% block center %}
<h1>Calendar</h1>
<div class="mh-100">
<div id="calendar" class="fc"></div>
</div>
<!-- Modal -->
<div class="modal fade" id="eventDetailsModal" tabindex="-1" role="dialog" aria-labelledby="eventDetailsModalTitle"
aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="eventDetailsModalTitle">Event details</h5>
</div>
<div class="modal-body">
<p id="eventDetailsModalDateRange"></p>
<p id="eventDetailsModalBody">Cottage request</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary">Save changes</button>
</div>
</div>
</div>
</div>
{% endblock center %}
{% block scripts %}
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js'></script>
<script src='https://cdn.jsdelivr.net/npm/uuid@8.3.2/dist/umd/uuidv4.min.js'></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var calendarEl = document.getElementById('calendar');
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
themeSystem: 'bootstrap5',
selectable: true,
displayEventTime: true,
displayEventEnd: true,
slotDuration: { hours: 12 },
slotLabelInterval: { hours: 24 },
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,multiMonthYear'
},
eventSources: [
{% for calendar in calendars %}
{
url: '/getevents/{{calendar.name}}',
color: '{{calendar.colour}}',
},
{% endfor %}
],
select: function (info) {
$('#eventDetailsModal').modal('show');
$('#eventDetailsModalDateRange').text(info.startStr + ' to ' + info.endStr);
info.view.calendar.addEvent({
id: uuidv4(),
title: 'Cottage request',
start: info.startStr + 'T14:00:00',
end: info.endStr + 'T10:00:00',
allDay: false
});
}
});
calendar.render();
});
</script>
{% endblock scripts %}

View File

@ -1,452 +0,0 @@
{% extends "authorized.html" %}
{% block links %}
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@fullcalendar/core@4.2.0/main.min.css'>
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/@fullcalendar/daygrid@4.3.0/main.min.css'>
<style>
#calendar {
max-width: 800px;
margin: 40px auto;
background: #fff;
padding: 15px;
}
.fc-event {
border: 1px solid #eee !important;
}
.fc-content {
padding: 3px !important;
}
.fc-content .fc-title {
display: block !important;
overflow: hidden;
text-align: center;
font-size: 12px;
font-weight: 500;
text-align: center;
}
.fc-customButton-button {
font-size: 13px !important;
position: absolute;
top: 0px;
left: 50%;
transform: translateY(-50%);
}
.form-group {
margin-bottom: 1rem;
}
.form-group>label {
margin-bottom: 10px;
}
#delete-modal .modal-footer>.btn {
border-radius: 3px !important;
padding: 0px 8px !important;
font-size: 15px;
}
.fc-scroller {
overflow-y: hidden !important;
}
.context-menu {
position: absolute;
z-index: 1000;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.3);
padding: 5px;
}
/* .context-menu.show {
display: block;
} */
.context-menu ul {
list-style-type: none;
margin: 0;
padding: 0;
}
.context-menu ul>li {
display: block;
;
padding: 5px 15px;
list-style-type: none;
color: #333;
display: block;
cursor: pointer;
margin: 0 auto;
transition: 0.10s;
font-size: 13px;
}
.context-menu ul>li:hover {
color: #fff;
background-color: #007bff;
border-radius: 2px;
}
.fa,
.fas {
font-size: 13px;
margin-right: 4px;
}
</style>
{% endblock links %}
{% block center %}
<h1>Cottage Calendar</h1>
<div id='calendar'></div>
<!-- Add modal -->
<div class="modal fade edit-form" id="form" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header border-bottom-0">
<h5 class="modal-title" id="modal-title">Add Event</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="myForm">
<div class="modal-body">
<div class="alert alert-danger " role="alert" id="danger-alert" style="display: none;">
End date should be greater than start date.
</div>
<div class="form-group">
<label for="event-title">Event name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="event-title" placeholder="Enter event name"
required>
</div>
<div class="form-group">
<label for="start-date">Start date <span class="text-danger">*</span></label>
<input type="date" class="form-control" id="start-date" placeholder="start-date" required>
</div>
<div class="form-group">
<label for="end-date">End date - <small class="text-muted">Optional</small></label>
<input type="date" class="form-control" id="end-date" placeholder="end-date">
</div>
<div class="form-group">
<label for="event-color">Color</label>
<input type="color" class="form-control" id="event-color" value="#3788d8">
</div>
</div>
<div class="modal-footer border-top-0 d-flex justify-content-center">
<button type="submit" class="btn btn-success" id="submit-button">Submit</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Modal -->
<div class="modal fade" id="delete-modal" tabindex="-1" role="dialog" aria-labelledby="delete-modal-title"
aria-hidden="true">
<div class="modal-dialog modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="delete-modal-title">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center" id="delete-modal-body">
Are you sure you want to delete the event?
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary rounded-sm" data-dismiss="modal"
id="cancel-button">Cancel</button>
<button type="button" class="btn btn-danger rounded-lg" id="delete-button">Delete</button>
</div>
</div>
</div>
</div>
{% endblock center %}
{% block scripts %}
<script src='https://cdn.jsdelivr.net/npm/@fullcalendar/core@4.2.0/main.min.js'></script>
<script src='https://cdn.jsdelivr.net/npm/@fullcalendar/daygrid@4.2.0/main.js'></script>
<script src='https://cdn.jsdelivr.net/npm/@fullcalendar/interaction@4.2.0/main.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js'></script>
<script src='https://cdn.jsdelivr.net/npm/uuid@8.3.2/dist/umd/uuidv4.min.js'></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const calendarEl = document.getElementById('calendar');
const myModal = new bootstrap.Modal(document.getElementById('form'));
const dangerAlert = document.getElementById('danger-alert');
const close = document.querySelector('.btn-close');
const myEvents = JSON.parse(localStorage.getItem('events')) || [
{
id: uuidv4(),
title: `Edit Me`,
start: '2023-04-11',
backgroundColor: 'red',
allDay: false,
editable: false,
},
{
id: uuidv4(),
title: `Delete me`,
start: '2023-04-17',
end: '2023-04-21',
allDay: false,
editable: false,
},
];
const calendar = new FullCalendar.Calendar(calendarEl, {
customButtons: {
customButton: {
text: 'Add Event',
click: function () {
myModal.show();
const modalTitle = document.getElementById('modal-title');
const submitButton = document.getElementById('submit-button');
modalTitle.innerHTML = 'Add Event'
submitButton.innerHTML = 'Add Event'
submitButton.classList.remove('btn-primary');
submitButton.classList.add('btn-success');
close.addEventListener('click', () => {
myModal.hide()
})
}
}
},
header: {
center: 'customButton', // add your custom button here
right: 'today, prev,next '
},
plugins: ['dayGrid', 'interaction'],
allDay: false,
editable: true,
selectable: true,
unselectAuto: false,
displayEventTime: false,
events: myEvents,
eventRender: function (info) {
info.el.addEventListener('contextmenu', function (e) {
e.preventDefault();
let existingMenu = document.querySelector('.context-menu');
existingMenu && existingMenu.remove();
let menu = document.createElement('div');
menu.className = 'context-menu';
menu.innerHTML = `<ul>
<li><i class="fas fa-edit"></i>Edit</li>
<li><i class="fas fa-trash-alt"></i>Delete</li>
</ul>`;
const eventIndex = myEvents.findIndex(event => event.id === info.event.id);
document.body.appendChild(menu);
menu.style.top = e.pageY + 'px';
menu.style.left = e.pageX + 'px';
// Edit context menu
menu.querySelector('li:first-child').addEventListener('click', function () {
menu.remove();
const editModal = new bootstrap.Modal(document.getElementById('form'));
const modalTitle = document.getElementById('modal-title');
const titleInput = document.getElementById('event-title');
const startDateInput = document.getElementById('start-date');
const endDateInput = document.getElementById('end-date');
const colorInput = document.getElementById('event-color');
const submitButton = document.getElementById('submit-button');
const cancelButton = document.getElementById('cancel-button');
modalTitle.innerHTML = 'Edit Event';
titleInput.value = info.event.title;
startDateInput.value = moment(info.event.start).format('YYYY-MM-DD');
endDateInput.value = moment(info.event.end, 'YYYY-MM-DD').subtract(1, 'day').format('YYYY-MM-DD');
colorInput.value = info.event.backgroundColor;
submitButton.innerHTML = 'Save Changes';
editModal.show();
submitButton.classList.remove('btn-success')
submitButton.classList.add('btn-primary')
// Edit button
submitButton.addEventListener('click', function () {
const updatedEvents = {
id: info.event.id,
title: titleInput.value,
start: startDateInput.value,
end: moment(endDateInput.value, 'YYYY-MM-DD').add(1, 'day').format('YYYY-MM-DD'),
backgroundColor: colorInput.value
}
if (updatedEvents.end <= updatedEvents.start) { // add if statement to check end date
dangerAlert.style.display = 'block';
return;
}
const eventIndex = myEvents.findIndex(event => event.id === updatedEvents.id);
myEvents.splice(eventIndex, 1, updatedEvents);
localStorage.setItem('events', JSON.stringify(myEvents));
// Update the event in the calendar
const calendarEvent = calendar.getEventById(info.event.id);
calendarEvent.setProp('title', updatedEvents.title);
calendarEvent.setStart(updatedEvents.start);
calendarEvent.setEnd(updatedEvents.end);
calendarEvent.setProp('backgroundColor', updatedEvents.backgroundColor);
editModal.hide();
})
});
// Delete menu
menu.querySelector('li:last-child').addEventListener('click', function () {
const deleteModal = new bootstrap.Modal(document.getElementById('delete-modal'));
const modalBody = document.getElementById('delete-modal-body');
const cancelModal = document.getElementById('cancel-button');
modalBody.innerHTML = `Are you sure you want to delete <b>"${info.event.title}"</b>`
deleteModal.show();
const deleteButton = document.getElementById('delete-button');
deleteButton.addEventListener('click', function () {
myEvents.splice(eventIndex, 1);
localStorage.setItem('events', JSON.stringify(myEvents));
calendar.getEventById(info.event.id).remove();
deleteModal.hide();
menu.remove();
});
cancelModal.addEventListener('click', function () {
deleteModal.hide();
})
});
document.addEventListener('click', function () {
menu.remove();
});
});
},
eventDrop: function (info) {
let myEvents = JSON.parse(localStorage.getItem('events')) || [];
const eventIndex = myEvents.findIndex(event => event.id === info.event.id);
const updatedEvent = {
...myEvents[eventIndex],
id: info.event.id,
title: info.event.title,
start: moment(info.event.start).format('YYYY-MM-DD'),
end: moment(info.event.end).format('YYYY-MM-DD'),
backgroundColor: info.event.backgroundColor
};
myEvents.splice(eventIndex, 1, updatedEvent); // Replace old event data with updated event data
localStorage.setItem('events', JSON.stringify(myEvents));
console.log(updatedEvent);
}
});
calendar.on('select', function (info) {
const startDateInput = document.getElementById('start-date');
const endDateInput = document.getElementById('end-date');
startDateInput.value = info.startStr;
const endDate = moment(info.endStr, 'YYYY-MM-DD').subtract(1, 'day').format('YYYY-MM-DD');
endDateInput.value = endDate;
if (startDateInput.value === endDate) {
endDateInput.value = '';
}
});
calendar.render();
const form = document.querySelector('form');
form.addEventListener('submit', function (event) {
event.preventDefault(); // prevent default form submission
// retrieve the form input values
const title = document.querySelector('#event-title').value;
const startDate = document.querySelector('#start-date').value;
const endDate = document.querySelector('#end-date').value;
const color = document.querySelector('#event-color').value;
const endDateFormatted = moment(endDate, 'YYYY-MM-DD').add(1, 'day').format('YYYY-MM-DD');
const eventId = uuidv4();
console.log(eventId);
if (endDateFormatted <= startDate) { // add if statement to check end date
dangerAlert.style.display = 'block';
return;
}
const newEvent = {
id: eventId,
title: title,
start: startDate,
end: endDateFormatted,
allDay: false,
backgroundColor: color
};
// add the new event to the myEvents array
myEvents.push(newEvent);
// render the new event on the calendar
calendar.addEvent(newEvent);
// save events to local storage
localStorage.setItem('events', JSON.stringify(myEvents));
myModal.hide();
form.reset();
});
myModal._element.addEventListener('hide.bs.modal', function () {
dangerAlert.style.display = 'none';
form.reset();
});
});
</script>
{% endblock scripts %}

View File

@ -1,11 +1,21 @@
{% extends "authorized.html" %}
{% block center %}
<div class="row align-items-stretch">
<div class="col-md-6">
<h2>Points of Interest</h2>
<a href="https://www.tlccreations.ca" target="_blank" rel="noopener noreferrer"><img title="TLC Creations"
src="https://www.tlccreations.ca/assets/images/banner.png" class="img-fluid" alt="TLC Creations"></a>
<a href="https://www.seguin.ca" target="_blank" rel="noopener noreferrer"><img title="Seguin"
src="https://www.seguin.ca/en/images/structure/badge.svg" class="img-fluid" alt="Seguin Township"></a>
</div>
</div>
<div class="row align-items-stretch" id="firerating">
<a href="https://www.seguin.ca/en/explore-play/firerating.aspx" target="_blank" rel="noopener noreferrer"><img
title="Fire Rating: MODERATE" src="https://www.seguin.ca/en/resources/firemodseguin.jpg"
class="img-fluid" alt="Fire Rating: MODERATE"></a>
</div>
<div>
<h2>Web links</h2>
<h3>TLC Creations</h3>
<ul>
<li><a href="https://www.tlccreations.ca" target="_blank" rel="noopener noreferrer">TLC Creations</a></li>
</ul>
<h3>Fonts</h3>
<ul>
<li><a href="https://fonts.google.com" target="_blank" rel="noopener noreferrer">Google fonts</a></li>
@ -15,7 +25,8 @@
<ul>
<li><a href="https://www.ancestry.ca" target="_blank" rel="noopener noreferrer">Ancestry</a></li>
<li><a href="https://www.geni.com" target="_blank" rel="noopener noreferrer">Geni</a></li>
<li><a href="http://www.tracingroots.ca/" target="_blank" rel="noopener noreferrer">Tracing Roots - Forth Family Tree</a></li>
<li><a href="http://www.tracingroots.ca/" target="_blank" rel="noopener noreferrer">Tracing Roots - Forth Family
Tree</a></li>
</ul>
</div>
{% endblock center %}