Compare commits

..
22 Commits
Author SHA1 Message Date
Superredstone 83443e11cc fix: project_list margin 2026-09-16 18:09:10 +02:00
Superredstone c8cf7ea7cd feat(portfolio): add tag search to projects 2026-09-16 17:48:04 +02:00
Superredstone f0888c4168 feat(portfolio): add project search 2026-09-16 17:45:08 +02:00
Superredstone c2e5f9a3a2 feat(portfolio): add project tags 2026-09-16 17:31:04 +02:00
Superredstone 6a4e6ba9c5 feat(admin): improved list display for some models 2026-09-15 19:56:10 +02:00
Superredstone f54c4efb1d feat(portfolio): add company color 2026-09-15 19:50:36 +02:00
Superredstone 02568054f1 break(portfolio): remove redundant uploads folder since media is set 2026-09-15 19:45:27 +02:00
Superredstone 06d759e809 fix: set projects link to target _blank 2026-09-14 21:15:23 +02:00
Superredstone cc76207bb4 fix: move uploads away from static files & fix company logo 2026-09-14 21:12:33 +02:00
Superredstone 1d25e49966 fix: add whitenoise to enable static file serving on docker 2026-09-14 20:14:24 +02:00
Superredstone b10f0c65bb fix: add wholestatic folder 2026-09-14 19:53:12 +02:00
Superredstone 4be478ab33 ci: add collectstatic on container build 2026-09-14 19:46:58 +02:00
Superredstone fca325d2cc feat(settings): wrapped local_settings import between try catch 2026-09-14 19:29:00 +02:00
Patrick Canal 456dfa5793 ci: add docker container push to gh
Updated the Docker CI workflow to include tag support and improved structure.
2026-09-14 19:13:01 +02:00
Patrick Canal e48e524c0a ci: add docker image build 2026-09-14 19:05:54 +02:00
Superredstone 874764c3e3 chore: add dockerization 2026-09-14 19:04:27 +02:00
Superredstone 92c15da936 docs: add missing variables 2026-09-14 17:25:53 +02:00
Superredstone e4383cabc8 feat(portfolio): improve responsiveness 2026-09-14 17:21:02 +02:00
Superredstone 7b3de9c9da feat(base): add admin link to navbar 2026-09-14 17:12:45 +02:00
Superredstone 00f6633ecf feat(portfolio): add email contact 2026-09-14 17:06:01 +02:00
Superredstone 14118cf438 feat(navbar): add project tab 2026-09-10 16:24:19 +02:00
Superredstone cce932d171 chore: fix pre-commit hook 2026-09-10 13:58:49 +02:00
29 changed files with 584 additions and 91 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitignore
__pycache__
*.pyc
*.pyo
*.pyd
.env
venv
.venv
db.sqlite3
staticfiles
media
wholestatic
+43
View File
@@ -0,0 +1,43 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ["v*"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2 -3
View File
@@ -8,12 +8,11 @@ devenv.local.yaml
# direnv # direnv
.direnv .direnv
# pre-commit
.pre-commit-config.yaml
*.sqlite3 *.sqlite3
.ruff_cache
*.pyc *.pyc
__pycache__/ __pycache__/
local_settings.py local_settings.py
uploads/ uploads/
wholestatic/
+15
View File
@@ -0,0 +1,15 @@
repos:
- repo: https://github.com/djlint/djLint
rev: v1.46.1
hooks:
- id: djlint-reformat-django
language: system
- id: djlint-django
language: system
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.6
hooks:
- id: ruff-check
language: system
- id: ruff-format
language: system
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["sh", "-c", "python manage.py migrate && python manage.py collectstatic --noinput && gunicorn patrickcanal_it.wsgi:application --bind 0.0.0.0:8000"]
+18
View File
@@ -0,0 +1,18 @@
# patrickcanal.it
## Docker
Build container
`docker build . -t patrickcanal.it`
Run docker run
`docker run -p 8000:8000 patrickcanal.it -v "$(pwd)/patrickcanal.it/local_settings.py:/app/patrickcanal.it/local_settings.py" -v "$(pwd)/patrickcanal.it/db.sqlite:/app/db.sqlite"`
## Required variables
### Portfolio
Email variables will be replaced once `django-post_office` will be updated to support django 6.1 email system
- MAILER_HOST
- EMAIL_CONTACT
- EMAIL_HOST_USER
- EMAIL_HOST_PASSWORD
- EMAIL_PORT
- EMAIL_USE_TLS
+15 -15
View File
@@ -3,11 +3,11 @@
"devenv": { "devenv": {
"locked": { "locked": {
"dir": "src/modules", "dir": "src/modules",
"lastModified": 1786975632, "lastModified": 1789304775,
"narHash": "sha256-3N/pfn4/ADlkTzHJWgClmKD1mTLyVJNFxLbDMMHl/xw=", "narHash": "sha256-WPuZ7LbqlQ8n+kocw8oo4sNvHy//LXBTAHOqRDp5I5c=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "c972cb46d511e690c942e6ee3309a7e8b9a20475", "rev": "e6f5e1e6cda3a75bad3e0a4af97eb9e70bc9e127",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -55,11 +55,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1784288435, "lastModified": 1788267358,
"narHash": "sha256-ReRHaLgr/uVqdD8afFSn+myXIfpHeOhP0yYe0TJqAA8=", "narHash": "sha256-nt+lUqYVpc9Y6JeMd2WmXzCDojasdadKo0mWcluvY2Y=",
"owner": "cachix", "owner": "cachix",
"repo": "git-hooks.nix", "repo": "git-hooks.nix",
"rev": "43b3c1ab9d40fb1dbb008f451988a91e375825e9", "rev": "27555e2624241fb116b49095df4caaee85a25691",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -70,11 +70,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1782918843, "lastModified": 1787631388,
"narHash": "sha256-ETYnV9U7Sr+A45dohzZdfCZKOss4qrTkO+wgNZNvEc0=", "narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e8273b29fe1390ec8d4603f2477357555291432e", "rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -108,11 +108,11 @@
"nixpkgs-src": { "nixpkgs-src": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1786098110, "lastModified": 1787394516,
"narHash": "sha256-shi1tjDhCGGd0kIgVPNY03V8NBWSTzhMSFDb7IqSoec=", "narHash": "sha256-pRGOQSClnXNI2iLUG6DYpsGvYcuw0drOutVZFTJNw90=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "afb4584a80bbf779ce0f691509ff902d188c2b3d", "rev": "c8f90650c15282fa8656a041bfbbd2403997a9a7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -127,11 +127,11 @@
"nixpkgs-src": "nixpkgs-src" "nixpkgs-src": "nixpkgs-src"
}, },
"locked": { "locked": {
"lastModified": 1786472144, "lastModified": 1787753358,
"narHash": "sha256-W1wLGKZKGtnTWGRFgjbsVQleuO6jgMweaSAbFltLkN0=", "narHash": "sha256-Tl77VbWyAKrOfRNQhL6JbQtb/MLzbYa/1RG1gWWfICk=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv-nixpkgs", "repo": "devenv-nixpkgs",
"rev": "ea21d30f66a051c7cd750692a2af8d56e0ec4bfb", "rev": "256551e45f6303e142ab4a98be1bf243feb77dc0",
"type": "github" "type": "github"
}, },
"original": { "original": {
-16
View File
@@ -1,5 +1,4 @@
{ {
pkgs,
... ...
}: }:
@@ -11,20 +10,5 @@
enable = true; enable = true;
requirements = ./requirements.txt; requirements = ./requirements.txt;
}; };
};
git-hooks.hooks = {
ruff = {
enable = true;
package = pkgs.ruff;
excludes = [ "migrations/" ];
};
djlint = {
enable = true;
name = "djlint";
entry = "djlint --check .";
types = [ "html" ];
};
}; };
} }
+23 -6
View File
@@ -37,6 +37,7 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'post_office',
'portfolio' 'portfolio'
] ]
@@ -48,6 +49,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware",
] ]
ROOT_URLCONF = 'patrickcanal_it.urls' ROOT_URLCONF = 'patrickcanal_it.urls'
@@ -114,6 +116,12 @@ USE_I18N = True
USE_TZ = True USE_TZ = True
STATIC_ROOT = BASE_DIR / "wholestatic"
try:
from patrickcanal_it.local_settings import *
except Exception as e:
print(e)
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.1/howto/static-files/ # https://docs.djangoproject.com/en/6.1/howto/static-files/
@@ -121,14 +129,23 @@ USE_TZ = True
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / "static/", BASE_DIR / "static/",
BASE_DIR / "uploads/",
] ]
MEDIA_URL = "/uploads/"
MEDIA_ROOT = BASE_DIR / "uploads/"
# Email # Email
# https://docs.djangoproject.com/en/6.1/topics/email/#topic-email-configuration # https://docs.djangoproject.com/en/6.1/topics/email/#topic-email-configuration
EMAIL_BACKEND = 'post_office.EmailBackend'
# TODO: Replace old mail backend with new once post_office supports it
# MAILERS = {
# 'default': {
# 'BACKEND': 'post_office.EmailBackend',
# },
# 'smtp': {
# 'BACKEND': 'django.core.mail.backends.smtp.EmailBackend',
# 'OPTIONS': {'host': MAILER_HOST, 'port': 587},
# },
# }
# POST_OFFICE = {'DEFAULT_MAILER': 'smtp'}
MAILERS = {
'default': {
'BACKEND': 'django.core.mail.backends.console.EmailBackend',
},
}
+25 -3
View File
@@ -4,16 +4,18 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Patrick Canal developer website">
<title>Patrick Canal</title> <title>Patrick Canal</title>
<link rel="stylesheet" href="{% static 'css/style.css' %}"> <link rel="stylesheet" href="{% static 'css/style.css' %}">
<link rel="stylesheet" href="{% static 'tabler/tabler.min.css' %}"> <link rel="stylesheet" href="{% static 'tabler/tabler.min.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome-7/css/all.min.css' %}"> <link rel="stylesheet" href="{% static 'fontawesome-7/css/all.min.css' %}">
<script src="{% static 'tabler/tabler.min.js' %}"></script> <script src="{% static 'tabler/tabler.min.js' %}"></script>
<script src="{% static 'tailwindcss/tailwindcss.min.js' %}"></script> <script src="{% static 'tailwindcss/tailwindcss.min.js' %}"></script>
<script src="{% static 'htmx/htmx.min.js' %}"></script>
</head> </head>
<body class="d-flex flex-column"> <body class="d-flex flex-column">
<header class="navbar navbar-expand-md d-print-none"> <header class="navbar navbar-expand-md d-print-none">
<div class="container-xl"> <div class="container-xl d-flex justify-content-between">
<button class="navbar-toggler" <button class="navbar-toggler"
type="button" type="button"
data-bs-toggle="collapse" data-bs-toggle="collapse"
@@ -24,15 +26,35 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="nav-item active"> <li class="nav-item {% if request.path == "/portfolio/" %}active{% endif %}">
<a class="nav-link" href="{% url 'index' %}"> <a class="nav-link" href="{% url 'index' %}">
<span class="nav-link-icon"> <span class="nav-link-icon">
<i class="fa-solid fa-house"></i> <i class="fa-solid fa-house"></i>
</span> </span>
<span class="nav-link-title">Home</span> <span class="nav-link-title">{% trans "Home" %}</span>
</a>
</li>
<li class="nav-item {% if "/portfolio/projects" in request.path %}active{% endif %}">
<a class="nav-link" href="{% url 'projects' %}">
<span class="nav-link-icon">
<i class="fa-solid fa-folder-open"></i>
</span>
<span class="nav-link-title">{% trans "Projects" %}</span>
</a> </a>
</li> </li>
</ul> </ul>
{% if request.user.is_superuser %}
<ul class="navbar-nav">
<li class="nav-item">
<a href="{% url 'admin:index' %}" class="nav-link">
<span class="nav-link-icon">
<i class="fa-solid fa-screwdriver-wrench"></i>
</span>
<span class="nav-link-title">{% trans "Admin" %}</span>
</a>
</li>
</ul>
{% endif %}
</div> </div>
</header> </header>
<main class="mb-2"> <main class="mb-2">
+3 -2
View File
@@ -18,13 +18,14 @@ Including another URLconf
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import include, path
from portfolio.views import IndexView from patrickcanal_it.views import IndexView
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("", IndexView.as_view(), name="index"), path("", IndexView.as_view(), name="index"),
path("portfolio/", include("portfolio.urls")),
] ]
if settings.DEBUG: if settings.DEBUG:
+7
View File
@@ -0,0 +1,7 @@
from django.shortcuts import redirect
from django.views.generic import View
class IndexView(View):
def get(self, request, *args, **kwargs):
return redirect("portfolio")
+15 -3
View File
@@ -5,12 +5,24 @@ from portfolio import models
@admin.register(models.JobExperience) @admin.register(models.JobExperience)
class JobExperienceAdmin(admin.ModelAdmin): class JobExperienceAdmin(admin.ModelAdmin):
pass list_display = ["company", "position", "start", "end"]
@admin.register(models.JobTask) @admin.register(models.JobTask)
class JobTaskAdmin(admin.ModelAdmin): class JobTaskAdmin(admin.ModelAdmin):
pass list_display = ["job__company", "description", "job__start", "job__end"]
@admin.register(models.Company) @admin.register(models.Company)
class CompanyAdmin(admin.ModelAdmin): class CompanyAdmin(admin.ModelAdmin):
pass list_display = ["name", "location"]
@admin.register(models.Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ["name", "short_description"]
@admin.register(models.ProjectTag)
class ProjectTagAdmin(admin.ModelAdmin):
list_display = ["name", "color"]
+54
View File
@@ -0,0 +1,54 @@
from django import forms
from django.utils.translation import gettext as _
class TablerEmailInput(forms.EmailInput):
def __init__(self, *args, **kwargs):
attrs = kwargs.setdefault("attrs", {})
attrs.update(
{
"class": "form-control",
"placeholder": "example@mail.com",
"autocomplete": "off",
"minlength": "4",
}
)
super().__init__(*args, **kwargs)
class TablerTextInput(forms.TextInput):
def __init__(self, placeholder=None, *args, **kwargs):
attrs = kwargs.setdefault("attrs", {})
attrs.update(
{
"class": "form-control",
"placeholder": placeholder,
"autocomplete": "off",
"minlength": "4",
}
)
super().__init__(*args, **kwargs)
class TablerTextArea(forms.Textarea):
def __init__(self, placeholder=None, *args, **kwargs):
attrs = kwargs.setdefault("attrs", {})
attrs.update(
{
"class": "form-control",
"placeholder": placeholder,
"autocomplete": "off",
"minlength": "4",
}
)
super().__init__(*args, **kwargs)
class ContactForm(forms.Form):
email = forms.EmailField(max_length=50, min_length=4, widget=TablerEmailInput())
subject = forms.CharField(
max_length=200,
min_length=4,
widget=TablerTextInput(placeholder=_("Your message")),
)
message = forms.CharField(max_length=5000, min_length=10, widget=TablerTextArea())
@@ -0,0 +1,28 @@
# Generated by Django 6.1 on 2026-08-30 18:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portfolio', '0002_alter_company_options_jobexperience_position'),
]
operations = [
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=256, verbose_name='Name')),
('short_description', models.CharField(max_length=512, verbose_name='Short description')),
('url', models.URLField(blank=True, verbose_name='URL')),
],
),
migrations.AlterField(
model_name='jobtask',
name='job',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_tasks', to='portfolio.jobexperience'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.1.1 on 2026-09-15 17:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portfolio', '0003_project_alter_jobtask_job'),
]
operations = [
migrations.AddField(
model_name='company',
name='color',
field=models.CharField(default='000000', max_length=6, verbose_name='Color (hex)'),
),
migrations.AlterField(
model_name='company',
name='logo',
field=models.FileField(upload_to='portfolio/companies_logo/'),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 6.1.1 on 2026-09-15 18:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('portfolio', '0004_company_color_alter_company_logo'),
]
operations = [
migrations.CreateModel(
name='ProjectTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=25, verbose_name='Name')),
('color', models.CharField(default='000000', max_length=6, verbose_name='Color (hex)')),
],
),
migrations.AddField(
model_name='project',
name='tags',
field=models.ManyToManyField(blank=True, related_name='projects', to='portfolio.projecttag', verbose_name='Tags'),
),
]
+48 -2
View File
@@ -9,7 +9,14 @@ class Company(models.Model):
location = models.CharField( location = models.CharField(
verbose_name=_("Location"), max_length=255, blank=False, null=False verbose_name=_("Location"), max_length=255, blank=False, null=False
) )
logo = models.FileField(upload_to="uploads/portfolio/companies_logo/") color = models.CharField(
verbose_name=_("Color (hex)"),
max_length=6,
null=False,
blank=False,
default="000000",
)
logo = models.FileField(upload_to="portfolio/companies_logo/")
def __str__(self): def __str__(self):
return self.name return self.name
@@ -38,9 +45,48 @@ class JobExperience(models.Model):
class JobTask(models.Model): class JobTask(models.Model):
job = models.ForeignKey( job = models.ForeignKey(
to=JobExperience, null=False, blank=False, on_delete=models.CASCADE, related_name="job_tasks" to=JobExperience,
null=False,
blank=False,
on_delete=models.CASCADE,
related_name="job_tasks",
) )
description = models.TextField(verbose_name=_("Description"), max_length=4192) description = models.TextField(verbose_name=_("Description"), max_length=4192)
def __str__(self): def __str__(self):
return self.job.__str__() return self.job.__str__()
class ProjectTag(models.Model):
name = models.CharField(
verbose_name=_("Name"), max_length=25, blank=False, null=False
)
color = models.CharField(
verbose_name=_("Color (hex)"),
max_length=6,
null=False,
blank=False,
default="000000",
)
def __str__(self):
return self.name
class Project(models.Model):
name = models.CharField(
verbose_name=_("Name"), max_length=256, null=False, blank=False
)
short_description = models.CharField(
verbose_name=_("Short description"), max_length=512, blank=False, null=False
)
url = models.URLField(verbose_name=_("URL"), blank=True, null=False)
tags = models.ManyToManyField(
ProjectTag,
verbose_name=_("Tags"),
blank=True,
related_name="projects",
)
def __str__(self):
return self.name
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% load i18n static %}
{% block content %}
<div class="container mt-4 fs-2">
<div>{% trans "Thanks for contacting me!" %}</div>
<a href="{% url 'index' %}" class="btn btn-primary mt-4">{% trans "Go back" %}</a>
</div>
{% endblock %}
+49 -19
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n static %} {% load i18n static %}
{% block content %} {% block content %}
<div class="container"> <div class="container px-4 px-md-0">
<div class="row mb-4"> <div class="row mb-4">
<div class="col"> <div class="col">
<div class="text-center mt-4"> <div class="text-center mt-4">
@@ -16,26 +16,26 @@
</div> </div>
<div class="mb-4 text-center"> <div class="mb-4 text-center">
<div class="mb-2"> <div class="mb-2">
<span class="badge bg-blue-lt fs-4 me-2">Python</span> <span class="badge bg-blue-lt fs-4 m-1">Python</span>
<span class="badge bg-blue-lt fs-4 me-2">Django</span> <span class="badge bg-blue-lt fs-4 m-1">Django</span>
<span class="badge bg-blue-lt fs-4 me-2">JavaScript/TypeScript</span> <span class="badge bg-blue-lt fs-4 m-1">JavaScript/TypeScript</span>
<span class="badge bg-blue-lt fs-4 me-2">HTML/CSS</span> <span class="badge bg-blue-lt fs-4 m-1">HTML/CSS</span>
</div> </div>
<div> <div>
<span class="badge bg-blue-lt fs-4 me-2">Docker</span> <span class="badge bg-blue-lt fs-4 m-1">Docker</span>
<span class="badge bg-blue-lt fs-4 me-2">Git</span> <span class="badge bg-blue-lt fs-4 m-1">Git</span>
<span class="badge bg-blue-lt fs-4 me-2">Linux</span> <span class="badge bg-blue-lt fs-4 m-1">Linux</span>
<span class="badge bg-blue-lt fs-4">NixOS</span> <span class="badge bg-blue-lt fs-4 m-1">NixOS</span>
</div> </div>
</div> </div>
<div class="text-center"> <div class="text-center">
<a href="" class="btn btn-primary btn-lg me-2">My projects</a> <a href="{% url 'projects' %}" class="btn btn-primary btn-lg me-2">My projects</a>
<a href="" class="btn btn-outline-secondary btn-lg">Contact me</a> <a href="#contact-me" class="btn btn-outline-secondary btn-lg">Contact me</a>
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row row-deck row-cards">
<div class="col"> <div class="col-md-4">
<div class="card h-full"> <div class="card h-full">
<div class="card-body d-flex align-items-center flex-column"> <div class="card-body d-flex align-items-center flex-column">
<div class="w-24 h-30"> <div class="w-24 h-30">
@@ -50,7 +50,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col"> <div class="col-md-4">
<div class="card h-full"> <div class="card h-full">
<div class="card-body d-flex align-items-center flex-column"> <div class="card-body d-flex align-items-center flex-column">
<div class="w-24 h-30"> <div class="w-24 h-30">
@@ -66,8 +66,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col"> <div class="col-md-4">
<div class="card"> <div class="card h-full">
<div class="card-body d-flex align-items-center flex-column"> <div class="card-body d-flex align-items-center flex-column">
<div class="w-24 h-30 text-5xl pt-4"> <div class="w-24 h-30 text-5xl pt-4">
<i class="fa-solid fa-server fa-2xl"></i> <i class="fa-solid fa-server fa-2xl"></i>
@@ -90,14 +90,14 @@
{% for je in job_experiences %} {% for je in job_experiences %}
<li class="timeline-event"> <li class="timeline-event">
<div class="timeline-event-icon bg-primary"> <div class="timeline-event-icon bg-primary">
<img src="{{ je.company.logo.url }}" alt=""> <img src="{% url 'company-logo' pk=je.company.pk %}" alt="">
</div> </div>
<div class="timeline-event-card card"> <div class="timeline-event-card card">
<div class="card-body"> <div class="card-body">
<p class="text-primary mb-0">{{ je.start }} - {% if je.end %}{{ je.end }}{% else %}{% trans "today" %}{% endif %}</p> <p class="text-primary mb-0">{{ je.start }} - {% if je.end %}{{ je.end }}{% else %}{% trans "today" %}{% endif %}</p>
<h2 class="fs-2">{{ je.position }}</h2> <h2 class="fs-2">{{ je.position }}</h2>
<div class=""> <div>
<div class="text-danger">{{ je.company.name }}</div> <div style="color: #{{ je.company.color }};">{{ je.company.name }}</div>
<div class="text-secondary">{{ je.company.location }}</div> <div class="text-secondary">{{ je.company.location }}</div>
<div class="mt-2 text-white"> <div class="mt-2 text-white">
{% for jt in je.job_tasks.all %} {% for jt in je.job_tasks.all %}
@@ -111,5 +111,35 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
<div class="hr"></div>
<div class="row d-flex justify-content-center mb-md-4">
<div class="card col-md-6" id="contact-me">
<div class="card-body">
<h3 class="card-title fs-2">
<b>
<i class="fa-solid fa-envelope"></i>
{% trans "Contact me" %}
</b>
</h3>
<form action="{% url 'contact' %}" method="post" id="contact-form">
{% csrf_token %}
{% for field in contact_form %}
<div class="form-floating mb-2">
{{ field }}
<label for="{{ field.id }}">{{ field.label }}</label>
</div>
{% endfor %}
<div class="d-flex justify-content-end">
<button class="btn btn-primary" type="submit">{% trans "Send" %}</button>
</div>
</form>
</div>
</div>
</div>
</div> </div>
<style>
textarea {
height: 10rem !important;
}
</style>
{% endblock %} {% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="container mt-2 mt-md-4">
<form class="mb-2 mb-md-4">
<div class="form-floating">
<input type="text"
class="form-control"
id="floating-name"
placeholder=""
name="query"
autocomplete="off"
hx-target="#projects"
hx-get="{% url 'projects-query' %}"
hx-trigger="load, input, delay:200ms" />
<label for="floating-name">{% trans "Search" %}</label>
</div>
</form>
<div id="projects"></div>
</div>
{% endblock %}
@@ -0,0 +1,35 @@
<div class="row row-deck row-cards">
{% for project in object_list %}
<div class="col-md-4">
{% if project.url %}
<a class="card card-link card-link-pop"
href="{{ project.url }}"
target="_blank">
<div class="card-body">
<h4>{{ project.name }}</h4>
<p>{{ project.short_description }}</p>
<div>
{% for t in project.tags.all %}
<div class="badge text-white fs-5"
style="background-color: #{{ t.color }}">{{ t }}</div>
{% endfor %}
</div>
</div>
</a>
{% else %}
<div class="card">
<div class="card-body">
<h4>{{ project.name }}</h4>
<p>{{ project.short_description }}</p>
<div>
{% for t in project.tags.all %}
<div class="badge text-white fs-5"
style="background-color: #{{ t.color }}">{{ t }}</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from portfolio import views
urlpatterns = [
path("", views.PortfolioView.as_view(), name="portfolio"),
path("projects/", views.ProjectList.as_view(), name="projects"),
path("projects/query/", views.ProjectListQuery.as_view(), name="projects-query"),
path("contact/", views.ContactSend.as_view(), name="contact"),
path("company/<int:pk>/logo/", views.CompanyLogo.as_view(), name="company-logo"),
]
+62 -4
View File
@@ -1,10 +1,68 @@
from django.views.generic import TemplateView from django.conf import settings
from django.db.models import Q
from django.http.response import FileResponse, HttpResponseBadRequest
from django.utils.translation import gettext as _
from django.views.generic import DetailView, ListView, TemplateView
from post_office import mail
from portfolio import models from portfolio import forms, models
class IndexView(TemplateView): class PortfolioView(TemplateView):
template_name = "index.html" template_name = "index.html"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return {"job_experiences": models.JobExperience.objects.all().order_by("-start")} contact_form = forms.ContactForm()
return {
"job_experiences": models.JobExperience.objects.all().order_by("-start"),
"contact_form": contact_form,
}
class ProjectList(TemplateView):
template_name = "project_list.html"
class ContactSend(TemplateView):
template_name = "contact_thanks.html"
def post(self, request, *args, **kwargs):
form = forms.ContactForm(request.POST)
if not form.is_valid():
return HttpResponseBadRequest(_("Invalid request"))
mail.send(
subject=form.cleaned_data["subject"],
message=form.cleaned_data["message"],
sender=settings.EMAIL_HOST_USER,
cc=form.cleaned_data["email"],
recipients=[settings.EMAIL_CONTACT],
)
return super().get(request, *args, **kwargs)
class CompanyLogo(DetailView):
model = models.Company
def get(self, request, *args, **kwargs):
company = self.get_object()
return FileResponse(company.logo)
class ProjectListQuery(ListView):
template_name = "project_list_query.html"
model = models.Project
def get_queryset(self):
query = self.request.GET.get("query")
if not query:
return super().get_queryset()
return (
super()
.get_queryset()
.filter(Q(name__contains=query) | Q(tags__name__contains=query))
.distinct()
)
+3 -1
View File
@@ -19,4 +19,6 @@ line-ending = "auto"
[tool.ruff.lint] [tool.ruff.lint]
extend-select = ["I"] extend-select = ["I"]
ignore = [
"RUF012"
]
+3
View File
@@ -1,2 +1,5 @@
django django
djlint djlint
django-post_office
gunicorn
whitenoise
+1
View File
File diff suppressed because one or more lines are too long
+6 -6
View File
File diff suppressed because one or more lines are too long
+7 -11
View File
File diff suppressed because one or more lines are too long