Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
530ba31
login/signup API draft
SallyMcGrath Mar 11, 2025
3b44419
rm my notes
SallyMcGrath Mar 11, 2025
2c37a4f
stopping this version here and switching to a plain module version
SallyMcGrath Mar 12, 2025
49b6d3c
staging point
SallyMcGrath Mar 13, 2025
347868d
logout component
SallyMcGrath Mar 13, 2025
e210737
running through tests and fixing up
SallyMcGrath Mar 13, 2025
272888f
rm tests
SallyMcGrath Mar 17, 2025
ea3a564
running through cleaning up
SallyMcGrath Mar 17, 2025
3abc510
running through cleaning up
SallyMcGrath Mar 17, 2025
bc28b75
resolving conflict
SallyMcGrath Mar 17, 2025
1ed8762
sigh errors
SallyMcGrath Mar 17, 2025
f85db5e
fix up errors a bit
SallyMcGrath Mar 17, 2025
657c94c
add a little dictionary, pick up a few stale states
SallyMcGrath Mar 18, 2025
0dc77f7
update follow count, hide button if already following
SallyMcGrath Mar 18, 2025
08318ee
some tests, just e2e
SallyMcGrath Mar 18, 2025
9e80fcd
just rilly need a second user
SallyMcGrath Mar 18, 2025
fc22b3d
hashtag view
SallyMcGrath Mar 18, 2025
f824846
typo
SallyMcGrath Mar 18, 2025
65c8cd9
note on useful infinite loop
SallyMcGrath Mar 18, 2025
847cb65
move components out of subdirs and insert heading
SallyMcGrath Mar 18, 2025
5c00921
Merge branch 'wip' of https://github.com/CodeYourFuture/Module-Legacy…
SallyMcGrath Mar 20, 2025
07f1cca
connect to hashtag endpoint
SallyMcGrath Mar 20, 2025
9fd5510
pop it in the layout too
SallyMcGrath Mar 20, 2025
abd0e03
rm tv show ref
SallyMcGrath Mar 20, 2025
5fa5936
persist hashtags now
SallyMcGrath Mar 20, 2025
7efb9e5
hm, not sure how that package wrote out like that
SallyMcGrath Mar 20, 2025
066cea5
cleaning up cors splodge
SallyMcGrath Mar 21, 2025
6fed1fd
too much text -let's give them remove
SallyMcGrath Mar 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
.env
.specstory/
.DS_Store
playwright-report/
.vscode/
test-results/
4 changes: 4 additions & 0 deletions backend/bidi_multidict.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ def get_inverse(self, value):
def add(self, key, value):
self.forward[key].add(value)
self.inverse[value].add(key)

def remove(self, key, value):
self.forward[key].remove(value)
self.inverse[value].remove(key)
6 changes: 6 additions & 0 deletions backend/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ def generate_salt() -> bytes:
password_salt=b"kala namak",
# Original password: sosecret
password_scrypt=b"~\xd7\xa8\xe7\x94\xf7\xfaJ\xe7\x9b\xd0\xb3\x96;\x01m\xfb\xca\xe5\xa6w\xa9\xf7\xc7\xf1=2\xc9\x03\x90)\xaeN~\xc3e\xac\xd9Tn\x9d\xccx\xae\xaa\x86\xf0\xb9\xae?\x9e\x1d\x85\xb1\xac0\xc5\xe5t\xd1\xc6rL\xee",
),
"sample2": User(
username="sample2",
password_salt=b"kala namak",
# Original password: sosecret
password_scrypt=b"~\xd7\xa8\xe7\x94\xf7\xfaJ\xe7\x9b\xd0\xb3\x96;\x01m\xfb\xca\xe5\xa6w\xa9\xf7\xc7\xf1=2\xc9\x03\x90)\xaeN~\xc3e\xac\xd9Tn\x9d\xccx\xae\xaa\x86\xf0\xb9\xae?\x9e\x1d\x85\xb1\xac0\xc5\xe5t\xd1\xc6rL\xee",
)
}

Expand Down
47 changes: 42 additions & 5 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,19 @@

app.json = CustomJsonProvider(app)

CORS(app)
# Configure CORS to handle preflight requests
# TODO Daniel not sure what I should have been doing so have just bunged this in for now
CORS(
app,
supports_credentials=True,
resources={
r"/*": {
"origins": "*",
"allow_headers": ["Content-Type", "Authorization"],
"methods": ["GET", "POST", "OPTIONS"]
}
}
)

MINIMUM_PASSWORD_LENGTH = 5

Expand Down Expand Up @@ -80,6 +92,13 @@ def register():
@jwt_required()
def self_profile():
username = get_current_user().username

# Check if the user exists
if username not in users:
return make_response(jsonify({
"success": False,
"reason": "User not found"
}), 404)

return jsonify(
{
Expand All @@ -93,6 +112,13 @@ def self_profile():
@app.route("/profile/<profile_username>")
@jwt_required(optional=True)
def other_profile(profile_username):
# Check if the user exists
if profile_username not in users:
return make_response(jsonify({
"success": False,
"reason": f"User {profile_username} not found"
}), 404)

current_user = get_current_user()

followers = follows.get_inverse(profile_username)
Expand Down Expand Up @@ -171,17 +197,28 @@ def get_bloom(id_str):
def home_timeline():
current_user = get_current_user().username

# Get blooms from followed users
followed_users = follows.get(current_user)
nested_user_blooms = [
blooms.get_blooms_for_user(followed_user, limit=50)
for followed_user in followed_users
]
user_blooms = [bloom for blooms in nested_user_blooms for bloom in blooms]
sorted_user_blooms = list(
sorted(user_blooms, key=lambda bloom: bloom.sent_timestamp, reverse=True)

# Flatten list of blooms from followed users
followed_blooms = [bloom for blooms in nested_user_blooms for bloom in blooms]

# Get the current user's own blooms
own_blooms = blooms.get_blooms_for_user(current_user, limit=50)

# Combine own blooms with followed blooms
all_blooms = followed_blooms + own_blooms

# Sort by timestamp (newest first)
sorted_blooms = list(
sorted(all_blooms, key=lambda bloom: bloom.sent_timestamp, reverse=True)
)

return jsonify(sorted_user_blooms)
return jsonify(sorted_blooms)


@app.route("/blooms/<profile_username>")
Expand Down
11 changes: 11 additions & 0 deletions front-end/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Purple Forest front end

Vanilla JS es6 modules. Vanilla CSS. Playwright tests.

## Running the Application

1. Clone the repository
2. Open `index.html` in your browser - make sure the backend is running
3. Login with the demo account 'sample' password 'sosecret'

This front end doesn't have a build step. It's just a collection of js modules that interact with the backend api via an apiService module. The entry point is `index.mjs` and this is SPA style, so there's just one html file.
198 changes: 198 additions & 0 deletions front-end/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# Purple Forest Application Architecture

This document describes the architecture of the Purple Forest application, including its design patterns, component structure, and data flow.

## Overview

Purple Forest is a social media application built with vanilla JavaScript using ES6 modules. It follows a component-based architecture with a central state management system. The application is designed as a Single Page Application (SPA) with client-side routing on the hash.

## Core Design Patterns

### Single Source of Truth (SSOT)

The application uses a single state object as the source of truth for all application data.

### Unidirectional Data Flow

Data flows in one direction through the application:

1. User actions trigger component handlers
2. Component handlers call API service methods
3. API service updates the central state
4. State changes trigger UI updates through event listeners
5. Views render components based on the current state

This simple flow makes the application behavior predictable and easier to reason about.

### Component-Based Architecture

The UI is composed of reusable components, each responsible for a specific part of the interface. Components are pure functions of state, meaning they render based solely on the data passed to them.

### Centralised Error Handling

The application implements a centralised error handling system that captures errors at the API service level and displays them in a single top level error dialog.

## Directory Structure

```
front-end/
├── components/ # UI components
│ ├── bloom/ # Individual post component
│ ├── bloom-form/ # Form for creating new posts
│ ├── error/ # Error dialog component
│ ├── login/ # Login component
│ ├── logout/ # Logout component
│ ├── profile/ # User profile component
│ ├── signup/ # Signup component
│ └── timeline/ # Timeline component for displaying posts
├── lib/ # Core application modules
│ ├── api.mjs # API service for backend communication
│ ├── render.mjs # Rendering utilities
│ ├── router.mjs # Client-side routing
│ ├── state.mjs # State management
│ └── views.mjs # View composition (legacy)
├── views/ # Application views
│ ├── home.mjs # Home view
│ ├── login.mjs # Login view
│ ├── profile.mjs # Profile view
│ └── signup.mjs # Signup view
├── index.html # Main HTML file
├── index.mjs # Application entry point
└── index.css # Global styles
```

## Core Modules

### State Management (state.mjs)

The state module manages the application's data and provides methods for updating it. Key features:

- **Central State Object**: Stores all application data
- **updateState()**: Updates state properties and notifies listeners
- **destroyState()**: Resets state to initial values
- **Event Dispatching**: Triggers 'state-change' events when state is updated
- **Local Storage**: Persists state to localStorage, and can restore from it on page load

### API Service (api.mjs)

The API service handles all communication with the backend. It updates the state when successful, destroys session state on authentication errors, and passes errors up to the error handler.

### Router (router.mjs)

The router handles client-side navigation and view rendering.

### Error Component (components/error/error.mjs)

The error component provides centralized error management. Key features:

- **Error Dialog**: Displays errors in a consistent UI dialog
- **Component-Based Structure**: Creates error dialogs following the same pattern as other components
- **Event Handlers**: Manages dialog interaction (close buttons, backdrop clicks)
- **Consistent Error Display**: Ensures all errors are displayed in a standardized way

### Render Utilities (render.mjs)

The render module provides two functions:

- **render()**: Renders components into DOM containers
- **destroy()**: Clears containers for view changes

## Component Architecture

Components in Purple Forest follow a consistent pattern:

1. **Creator Function**: A pure function that creates a component based on data

```javascript
function createComponent(template, data) {
// Clone template
// Populate with data
// Return fragment
}
```

2. **Event Handlers**: Functions that handle user interactions, exported to the view
```javascript
function handleEvent(event) {
// Call API service
// State changes trigger UI updates
}
```

Components are attached to the DOM using the render utility:

```javascript
render(
[data], // Data to pass to the component
getContainer(), // DOM container
"template-id", // Template ID
createComponent // Creator function
);
```

## View Composition

Views compose multiple components to create complete screens or types of pages. Each view:

1. Clears previous content using the destroy utility
2. Renders components based on the current state
3. Attaches event handlers to components once they are attached to the DOM

## Error Handling Flow

The application handles errors like this:

1. **Error Origin**: Error occurs (usually in API service)
2. **Error Capture**: API service captures the error
- For auth errors (401/403), it destroys session state
- For all errors, it passes the error to the error handler
3. **Error Display**: Error handler displays the error in a page level dialog
4. **Component Recovery**: Components use try/finally to reset UI

This approach ensures errors don't leave the application in an inconsistent state and provides clear feedback to users.

## Data Flow Example: Login Process

1. **User Action**: User submits login form
2. **Component Handler**: `handleLogin()` calls `apiService.login()`
3. **API Service**: Makes API request and updates state on success
```javascript
state.updateState({
token: data.token,
currentUser: username,
isLoggedIn: true,
});
```
4. **State Change**: Triggers 'state-change' event
5. **Event Listener**: In index.mjs, calls `handleRouteChange()`
6. **Router**: Renders the home view based on the updated state
7. **View**: Home view renders profile, timeline, and bloom form components

## State-Driven UI Updates

UI updates are driven by state changes, not direct DOM manipulation:

1. State is updated through the API service
2. State change triggers the 'state-change' event
3. Event listener calls the router
4. Router renders the appropriate view
5. View renders components based on the current state

This approach ensures the UI always reflects the current application state.

## Some design patterns in this architecture

1. **Single Source of Truth**: The state object is the single source of truth for all application data
1. **Component Purity**: UI Components are pure functions of state
1. **Separation of Concerns**:
- Components handle rendering and user interactions
- API service handles data operations
- State manages application data
- Router handles navigation and view rendering
1. **State-Driven UI Updates**: UI updates are driven by state changes
1. **Event-Driven Updates**: UI updates are driven by state changes
1. **Centralised Error Management**: All errors are handled through a central system
1. **BEM** (Block Element Modifier) CSS Naming Convention: Used for consistent and maintainable CSS
1. **ES6 Modules**: Used for modular code organization and encapsulation
1. **Client-Side Routing**: Enables SPA behavior with hash-based routing
1. **Native**: No external libraries or frameworks are used, HTML5 and ES6 features are used directly
58 changes: 58 additions & 0 deletions front-end/components/bloom-form.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {apiService} from "../index.mjs";

/**
* Create a bloom form component
* @param {string} template - The ID of the template to clone
* @param {Object} isLoggedIn - only logged in users see the bloom form
* @returns {DocumentFragment} - The bloom form fragment
*/
function createBloomForm(template, isLoggedIn) {
if (!isLoggedIn) return;
const bloomFormElement = document
.getElementById(template)
.content.cloneNode(true);

return bloomFormElement;
}

/**
* Handle bloom form submission
* @param {Event} event - The form submission event
*/
async function handleBloomSubmit(event) {
event.preventDefault();
const form = event.target;
const submitButton = form.querySelector("[data-submit]");
const originalText = submitButton.textContent;
const textarea = form.querySelector("textarea");
const content = textarea.value.trim();

try {
// Make form inert while we call the back end
form.inert = true;
submitButton.textContent = "Posting...";
await apiService.postBloom(content);
textarea.value = "";
} catch (error) {
throw error;
} finally {
// Restore form
submitButton.textContent = originalText;
form.inert = false;
}
}

/**
* Handle textarea input for bloom form
* @param {Event} event - The input event from textarea drives the character counter
*/
function handleTyping(event) {
const textarea = event.target;
const counter = textarea
.closest("[data-form]")
?.querySelector("[data-counter]");
const maxLength = parseInt(textarea.getAttribute("maxlength"), 10);
counter.textContent = `${textarea.value.length} / ${maxLength}`;
}

export {createBloomForm, handleBloomSubmit, handleTyping};
Loading