Initial commit: Elm tutorial for JavaScript developers
This tutorial includes: - 7 progressive lessons covering Elm fundamentals - Exercises with starter code - Solutions for exercises - Final project: Task Manager app with localStorage persistence Topics covered: - Basic syntax and types - Functions and functional programming concepts - The Elm Architecture (Model-View-Update) - Lists and Maybe types - HTTP requests and JSON decoding - Ports for JavaScript interop
This commit is contained in:
197
projects/task-manager/index.html
Normal file
197
projects/task-manager/index.html
Normal file
@@ -0,0 +1,197 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Task Manager - Elm Tutorial Final Project</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-section input {
|
||||
flex: 1;
|
||||
padding: 12px 15px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-section input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.input-section button {
|
||||
padding: 12px 24px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.input-section button:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filters button {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filters button:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.filters button.active {
|
||||
background: #667eea;
|
||||
border-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.task-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.task-list li:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.task-list li.completed .task-description {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.task-list input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff6b6b;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.task-list li:hover .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer button:hover {
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="elm.js"></script>
|
||||
<script>
|
||||
// Load saved tasks from localStorage
|
||||
const savedTasks = localStorage.getItem('elm-tasks');
|
||||
const initialTasks = savedTasks ? JSON.parse(savedTasks) : [];
|
||||
|
||||
// Initialize Elm app with saved tasks
|
||||
const app = Elm.Main.init({
|
||||
node: document.getElementById('app'),
|
||||
flags: initialTasks
|
||||
});
|
||||
|
||||
// Listen for save commands from Elm
|
||||
app.ports.saveTasks.subscribe(function(tasks) {
|
||||
localStorage.setItem('elm-tasks', JSON.stringify(tasks));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
370
projects/task-manager/src/Main.elm
Normal file
370
projects/task-manager/src/Main.elm
Normal file
@@ -0,0 +1,370 @@
|
||||
port module Main exposing (main)
|
||||
|
||||
{-| Task Manager - Final Project
|
||||
|
||||
A complete task manager application demonstrating:
|
||||
- The Elm Architecture
|
||||
- Custom types for state management
|
||||
- Ports for localStorage persistence
|
||||
- List operations
|
||||
- JSON encoding/decoding
|
||||
|
||||
To run:
|
||||
1. elm make src/Main.elm --output=elm.js
|
||||
2. Open index.html in a browser
|
||||
|
||||
-}
|
||||
|
||||
import Browser
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Json.Decode as Decode exposing (Decoder)
|
||||
import Json.Encode as Encode
|
||||
|
||||
|
||||
|
||||
-- PORTS
|
||||
|
||||
|
||||
port saveTasks : Encode.Value -> Cmd msg
|
||||
|
||||
|
||||
port loadTasks : (Decode.Value -> msg) -> Sub msg
|
||||
|
||||
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
||||
type alias Task =
|
||||
{ id : Int
|
||||
, description : String
|
||||
, completed : Bool
|
||||
}
|
||||
|
||||
|
||||
type Filter
|
||||
= All
|
||||
| Active
|
||||
| Completed
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ tasks : List Task
|
||||
, newTask : String
|
||||
, nextId : Int
|
||||
, filter : Filter
|
||||
}
|
||||
|
||||
|
||||
init : Decode.Value -> ( Model, Cmd Msg )
|
||||
init flags =
|
||||
let
|
||||
loadedTasks =
|
||||
case Decode.decodeValue tasksDecoder flags of
|
||||
Ok tasks ->
|
||||
tasks
|
||||
|
||||
Err _ ->
|
||||
[]
|
||||
|
||||
nextId =
|
||||
case List.maximum (List.map .id loadedTasks) of
|
||||
Just maxId ->
|
||||
maxId + 1
|
||||
|
||||
Nothing ->
|
||||
1
|
||||
in
|
||||
( { tasks = loadedTasks
|
||||
, newTask = ""
|
||||
, nextId = nextId
|
||||
, filter = All
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- DECODERS & ENCODERS
|
||||
|
||||
|
||||
taskDecoder : Decoder Task
|
||||
taskDecoder =
|
||||
Decode.map3 Task
|
||||
(Decode.field "id" Decode.int)
|
||||
(Decode.field "description" Decode.string)
|
||||
(Decode.field "completed" Decode.bool)
|
||||
|
||||
|
||||
tasksDecoder : Decoder (List Task)
|
||||
tasksDecoder =
|
||||
Decode.list taskDecoder
|
||||
|
||||
|
||||
encodeTask : Task -> Encode.Value
|
||||
encodeTask task =
|
||||
Encode.object
|
||||
[ ( "id", Encode.int task.id )
|
||||
, ( "description", Encode.string task.description )
|
||||
, ( "completed", Encode.bool task.completed )
|
||||
]
|
||||
|
||||
|
||||
encodeTasks : List Task -> Encode.Value
|
||||
encodeTasks tasks =
|
||||
Encode.list encodeTask tasks
|
||||
|
||||
|
||||
|
||||
-- UPDATE
|
||||
|
||||
|
||||
type Msg
|
||||
= UpdateNewTask String
|
||||
| AddTask
|
||||
| ToggleTask Int
|
||||
| DeleteTask Int
|
||||
| SetFilter Filter
|
||||
| ClearCompleted
|
||||
| LoadedTasks Decode.Value
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
UpdateNewTask text ->
|
||||
( { model | newTask = text }, Cmd.none )
|
||||
|
||||
AddTask ->
|
||||
if String.isEmpty (String.trim model.newTask) then
|
||||
( model, Cmd.none )
|
||||
|
||||
else
|
||||
let
|
||||
newTask =
|
||||
{ id = model.nextId
|
||||
, description = String.trim model.newTask
|
||||
, completed = False
|
||||
}
|
||||
|
||||
newTasks =
|
||||
model.tasks ++ [ newTask ]
|
||||
in
|
||||
( { model
|
||||
| tasks = newTasks
|
||||
, newTask = ""
|
||||
, nextId = model.nextId + 1
|
||||
}
|
||||
, saveTasks (encodeTasks newTasks)
|
||||
)
|
||||
|
||||
ToggleTask id ->
|
||||
let
|
||||
toggleTask task =
|
||||
if task.id == id then
|
||||
{ task | completed = not task.completed }
|
||||
|
||||
else
|
||||
task
|
||||
|
||||
newTasks =
|
||||
List.map toggleTask model.tasks
|
||||
in
|
||||
( { model | tasks = newTasks }
|
||||
, saveTasks (encodeTasks newTasks)
|
||||
)
|
||||
|
||||
DeleteTask id ->
|
||||
let
|
||||
newTasks =
|
||||
List.filter (\task -> task.id /= id) model.tasks
|
||||
in
|
||||
( { model | tasks = newTasks }
|
||||
, saveTasks (encodeTasks newTasks)
|
||||
)
|
||||
|
||||
SetFilter filter ->
|
||||
( { model | filter = filter }, Cmd.none )
|
||||
|
||||
ClearCompleted ->
|
||||
let
|
||||
newTasks =
|
||||
List.filter (\task -> not task.completed) model.tasks
|
||||
in
|
||||
( { model | tasks = newTasks }
|
||||
, saveTasks (encodeTasks newTasks)
|
||||
)
|
||||
|
||||
LoadedTasks value ->
|
||||
case Decode.decodeValue tasksDecoder value of
|
||||
Ok tasks ->
|
||||
( { model | tasks = tasks }, Cmd.none )
|
||||
|
||||
Err _ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
|
||||
-- VIEW
|
||||
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div [ class "container" ]
|
||||
[ h1 [] [ text "Task Manager" ]
|
||||
, viewInput model.newTask
|
||||
, viewFilters model.filter
|
||||
, viewTasks model
|
||||
, viewFooter model
|
||||
]
|
||||
|
||||
|
||||
viewInput : String -> Html Msg
|
||||
viewInput newTask =
|
||||
div [ class "input-section" ]
|
||||
[ input
|
||||
[ placeholder "What needs to be done?"
|
||||
, value newTask
|
||||
, onInput UpdateNewTask
|
||||
, onEnter AddTask
|
||||
]
|
||||
[]
|
||||
, button [ onClick AddTask ] [ text "Add" ]
|
||||
]
|
||||
|
||||
|
||||
onEnter : Msg -> Attribute Msg
|
||||
onEnter msg =
|
||||
let
|
||||
isEnter code =
|
||||
if code == 13 then
|
||||
Decode.succeed msg
|
||||
|
||||
else
|
||||
Decode.fail "not ENTER"
|
||||
in
|
||||
on "keydown" (Decode.andThen isEnter (Decode.field "keyCode" Decode.int))
|
||||
|
||||
|
||||
viewFilters : Filter -> Html Msg
|
||||
viewFilters currentFilter =
|
||||
div [ class "filters" ]
|
||||
[ filterButton All currentFilter "All"
|
||||
, filterButton Active currentFilter "Active"
|
||||
, filterButton Completed currentFilter "Completed"
|
||||
]
|
||||
|
||||
|
||||
filterButton : Filter -> Filter -> String -> Html Msg
|
||||
filterButton filter currentFilter label =
|
||||
button
|
||||
[ classList [ ( "active", filter == currentFilter ) ]
|
||||
, onClick (SetFilter filter)
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
|
||||
viewTasks : Model -> Html Msg
|
||||
viewTasks model =
|
||||
let
|
||||
filteredTasks =
|
||||
filterTasks model.filter model.tasks
|
||||
in
|
||||
if List.isEmpty model.tasks then
|
||||
p [ class "empty-message" ] [ text "No tasks yet. Add one above!" ]
|
||||
|
||||
else if List.isEmpty filteredTasks then
|
||||
p [ class "empty-message" ] [ text "No tasks match this filter." ]
|
||||
|
||||
else
|
||||
ul [ class "task-list" ]
|
||||
(List.map viewTask filteredTasks)
|
||||
|
||||
|
||||
filterTasks : Filter -> List Task -> List Task
|
||||
filterTasks filter tasks =
|
||||
case filter of
|
||||
All ->
|
||||
tasks
|
||||
|
||||
Active ->
|
||||
List.filter (\t -> not t.completed) tasks
|
||||
|
||||
Completed ->
|
||||
List.filter .completed tasks
|
||||
|
||||
|
||||
viewTask : Task -> Html Msg
|
||||
viewTask task =
|
||||
li [ classList [ ( "completed", task.completed ) ] ]
|
||||
[ input
|
||||
[ type_ "checkbox"
|
||||
, checked task.completed
|
||||
, onClick (ToggleTask task.id)
|
||||
]
|
||||
[]
|
||||
, span [ class "task-description" ] [ text task.description ]
|
||||
, button
|
||||
[ class "delete-btn"
|
||||
, onClick (DeleteTask task.id)
|
||||
]
|
||||
[ text "x" ]
|
||||
]
|
||||
|
||||
|
||||
viewFooter : Model -> Html Msg
|
||||
viewFooter model =
|
||||
let
|
||||
activeCount =
|
||||
List.length (List.filter (\t -> not t.completed) model.tasks)
|
||||
|
||||
completedCount =
|
||||
List.length (List.filter .completed model.tasks)
|
||||
|
||||
itemWord =
|
||||
if activeCount == 1 then
|
||||
"item"
|
||||
|
||||
else
|
||||
"items"
|
||||
in
|
||||
if List.isEmpty model.tasks then
|
||||
text ""
|
||||
|
||||
else
|
||||
div [ class "footer" ]
|
||||
[ span []
|
||||
[ text (String.fromInt activeCount ++ " " ++ itemWord ++ " left")
|
||||
]
|
||||
, if completedCount > 0 then
|
||||
button [ onClick ClearCompleted ]
|
||||
[ text "Clear completed" ]
|
||||
|
||||
else
|
||||
text ""
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions _ =
|
||||
loadTasks LoadedTasks
|
||||
|
||||
|
||||
|
||||
-- MAIN
|
||||
|
||||
|
||||
main : Program Decode.Value Model Msg
|
||||
main =
|
||||
Browser.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, view = view
|
||||
, subscriptions = subscriptions
|
||||
}
|
||||
Reference in New Issue
Block a user