Autosave: 20260217-184408

This commit is contained in:
Flatlogic Bot 2026-02-17 18:44:08 +00:00
parent a08f77aa00
commit 79a273568c
67 changed files with 2890 additions and 588 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

@ -56,6 +56,7 @@ INSTALLED_APPS = [
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'core', 'core',
'groups',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@ -21,6 +21,7 @@ from django.conf.urls.static import static
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("groups/", include("groups.urls")),
path("", include("core.urls")), path("", include("core.urls")),
] ]

Binary file not shown.

35
core/forms.py Normal file
View File

@ -0,0 +1,35 @@
from django import forms
from .models import Event, EventTag
class EventForm(forms.ModelForm):
class Meta:
model = Event
fields = [
'title', 'description', 'start_datetime', 'end_datetime',
'timezone', 'location_name', 'location_address', 'city', 'state',
'is_online', 'online_url', 'visibility', 'group', 'capacity',
'cover_image', 'tags'
]
labels = {
'title': 'Session Title',
'description': 'Session Description',
'is_online': 'This is an online session',
'group': 'Squad',
}
widgets = {
'start_datetime': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
'end_datetime': forms.DateTimeInput(attrs={'type': 'datetime-local', 'class': 'form-control'}),
'description': forms.Textarea(attrs={'rows': 4, 'class': 'form-control'}),
'title': forms.TextInput(attrs={'class': 'form-control'}),
'timezone': forms.TextInput(attrs={'class': 'form-control'}),
'location_name': forms.TextInput(attrs={'class': 'form-control'}),
'location_address': forms.TextInput(attrs={'class': 'form-control'}),
'city': forms.TextInput(attrs={'class': 'form-control'}),
'state': forms.TextInput(attrs={'class': 'form-control'}),
'online_url': forms.URLInput(attrs={'class': 'form-control'}),
'visibility': forms.Select(attrs={'class': 'form-select'}),
'group': forms.Select(attrs={'class': 'form-select'}),
'capacity': forms.NumberInput(attrs={'class': 'form-control'}),
'cover_image': forms.FileInput(attrs={'class': 'form-control'}),
'tags': forms.SelectMultiple(attrs={'class': 'form-select'}),
}

View File

@ -0,0 +1,86 @@
# Generated by Django 5.2.7 on 2026-02-17 17:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_follow'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='message',
name='recipient',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='Thread',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('participants', models.ManyToManyField(related_name='threads', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='message',
name='thread',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='core.thread'),
),
migrations.CreateModel(
name='Block',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('blocked', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks_received', to=settings.AUTH_USER_MODEL)),
('blocker', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks_given', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('blocker', 'blocked')},
},
),
migrations.CreateModel(
name='ConnectionRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('declined', 'Declined'), ('canceled', 'Canceled'), ('blocked', 'Blocked')], default='pending', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('responded_at', models.DateTimeField(blank=True, null=True)),
('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests_sent', to=settings.AUTH_USER_MODEL)),
('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests_received', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('from_user', 'to_user')},
},
),
migrations.CreateModel(
name='Like',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes_given', to=settings.AUTH_USER_MODEL)),
('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes_received', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('from_user', 'to_user')},
},
),
migrations.CreateModel(
name='Match',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(choices=[('active', 'Active'), ('archived', 'Archived')], default='active', max_length=20)),
('user_a', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='matches_a', to=settings.AUTH_USER_MODEL)),
('user_b', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='matches_b', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user_a', 'user_b')},
},
),
]

View File

@ -0,0 +1,157 @@
# Generated by Django 5.2.7 on 2026-02-17 17:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_alter_message_recipient_thread_message_thread_block_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EventTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
],
),
migrations.RemoveField(
model_name='event',
name='attendees',
),
migrations.RemoveField(
model_name='event',
name='end_time',
),
migrations.RemoveField(
model_name='event',
name='location',
),
migrations.RemoveField(
model_name='event',
name='organization_id',
),
migrations.RemoveField(
model_name='event',
name='organizer',
),
migrations.RemoveField(
model_name='event',
name='start_time',
),
migrations.AddField(
model_name='event',
name='capacity',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='event',
name='city',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='event',
name='cover_image',
field=models.ImageField(blank=True, null=True, upload_to='events/'),
),
migrations.AddField(
model_name='event',
name='created_at',
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='event',
name='creator',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='created_events', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='event',
name='end_datetime',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='event',
name='is_online',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='event',
name='location_address',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='event',
name='location_name',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='event',
name='online_url',
field=models.URLField(blank=True),
),
migrations.AddField(
model_name='event',
name='start_datetime',
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name='event',
name='state',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='event',
name='timezone',
field=models.CharField(default='UTC', max_length=50),
),
migrations.AddField(
model_name='event',
name='updated_at',
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name='event',
name='visibility',
field=models.CharField(choices=[('public', 'Public (Members Only)'), ('group', 'Group Only'), ('invite', 'Invite Only')], default='public', max_length=20),
),
migrations.AlterField(
model_name='event',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='group_events', to='core.group'),
),
migrations.CreateModel(
name='EventInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('declined', 'Declined')], default='pending', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='core.event')),
('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites_sent', to=settings.AUTH_USER_MODEL)),
('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites_received', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='event',
name='tags',
field=models.ManyToManyField(blank=True, to='core.eventtag'),
),
migrations.CreateModel(
name='RSVP',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('going', 'Going'), ('maybe', 'Maybe'), ('not_going', 'Not Going')], max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rsvps', to='core.event')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rsvps', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('event', 'user')},
},
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2026-02-17 17:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0010_eventtag_remove_event_attendees_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='event',
name='creator',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_events', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='event',
name='location_name',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='event',
name='start_datetime',
field=models.DateTimeField(),
),
]

View File

@ -0,0 +1,57 @@
# Generated by Django 5.2.7 on 2026-02-17 18:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_alter_event_creator_alter_event_location_name_and_more'),
]
operations = [
migrations.CreateModel(
name='Game',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('slug', models.SlugField(max_length=255, unique=True)),
('genre', models.CharField(choices=[('fps', 'FPS'), ('moba', 'MOBA'), ('battle_royale', 'Battle Royale'), ('sports', 'Sports'), ('mmo', 'MMO'), ('fighting', 'Fighting'), ('rpg', 'RPG'), ('strategy', 'Strategy'), ('other', 'Other')], max_length=50)),
('team_size', models.PositiveIntegerField(blank=True, null=True)),
('has_roles', models.BooleanField(default=False)),
('roles_json', models.JSONField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.AddField(
model_name='profile',
name='gamer_tag',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='profile',
name='platform',
field=models.CharField(blank=True, choices=[('pc', 'PC'), ('playstation', 'PlayStation'), ('xbox', 'Xbox'), ('nintendo', 'Nintendo Switch'), ('mobile', 'Mobile')], max_length=20),
),
migrations.AddField(
model_name='profile',
name='preferred_role',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='profile',
name='rank',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='profile',
name='primary_game',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_players', to='core.game'),
),
migrations.AddField(
model_name='profile',
name='secondary_games',
field=models.ManyToManyField(blank=True, related_name='secondary_players', to='core.game'),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 5.2.7 on 2026-02-17 18:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_game_profile_gamer_tag_profile_platform_and_more'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='gamer_tag',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='profile',
name='platform',
field=models.CharField(choices=[('pc', 'PC'), ('playstation', 'PlayStation'), ('xbox', 'Xbox'), ('nintendo', 'Nintendo Switch'), ('mobile', 'Mobile')], max_length=20),
),
migrations.AlterField(
model_name='profile',
name='primary_game',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_players', to='core.game'),
),
]

View File

@ -14,7 +14,37 @@ class ValueTag(models.Model):
def __str__(self): def __str__(self):
return str(self.name) return str(self.name)
class Game(models.Model):
GENRE_CHOICES = [
('fps', 'FPS'),
('moba', 'MOBA'),
('battle_royale', 'Battle Royale'),
('sports', 'Sports'),
('mmo', 'MMO'),
('fighting', 'Fighting'),
('rpg', 'RPG'),
('strategy', 'Strategy'),
('other', 'Other'),
]
name = models.CharField(max_length=255, unique=True)
slug = models.SlugField(max_length=255, unique=True)
genre = models.CharField(max_length=50, choices=GENRE_CHOICES)
team_size = models.PositiveIntegerField(null=True, blank=True)
has_roles = models.BooleanField(default=False)
roles_json = models.JSONField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return str(self.name)
class Profile(models.Model): class Profile(models.Model):
PLATFORM_CHOICES = [
('pc', 'PC'),
('playstation', 'PlayStation'),
('xbox', 'Xbox'),
('nintendo', 'Nintendo Switch'),
('mobile', 'Mobile'),
]
TRANSITION_CHOICES = [ TRANSITION_CHOICES = [
('none', 'Stable'), ('none', 'Stable'),
('post-divorce', 'Post-Divorce'), ('post-divorce', 'Post-Divorce'),
@ -32,6 +62,14 @@ class Profile(models.Model):
value_tags = models.ManyToManyField(ValueTag, blank=True) value_tags = models.ManyToManyField(ValueTag, blank=True)
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
# Gaming Fields
primary_game = models.ForeignKey(Game, on_delete=models.SET_NULL, null=True, blank=False, related_name='primary_players')
secondary_games = models.ManyToManyField(Game, blank=True, related_name='secondary_players')
platform = models.CharField(max_length=20, choices=PLATFORM_CHOICES, blank=False)
gamer_tag = models.CharField(max_length=100, blank=False)
rank = models.CharField(max_length=100, blank=True)
preferred_role = models.CharField(max_length=100, blank=True)
# Momentum & Engagement # Momentum & Engagement
accountability_streak = models.IntegerField(default=0) accountability_streak = models.IntegerField(default=0)
@ -51,7 +89,7 @@ class Profile(models.Model):
@property @property
def connection_count(self): def connection_count(self):
return Connection.objects.filter(models.Q(user1=self.user) | models.Q(user2=self.user)).count() return Match.objects.filter(models.Q(user_a=self.user) | models.Q(user_b=self.user)).count()
@property @property
def following_count(self): def following_count(self):
@ -63,17 +101,18 @@ class Profile(models.Model):
@property @property
def events_attended_count(self): def events_attended_count(self):
return self.user.attending_events.count() return self.user.rsvps.filter(status='going').count()
@property @property
def profile_completion_percentage(self): def profile_completion_percentage(self):
steps = 0 steps = 0
total_steps = 5 total_steps = 6
if self.professional_headline: steps += 1 if self.gamer_tag: steps += 1
if self.bio: steps += 1 if self.bio: steps += 1
if self.location_city: steps += 1 if self.location_city: steps += 1
if self.intents.exists(): steps += 1 if self.intents.exists(): steps += 1
if self.avatar: steps += 1 if self.avatar: steps += 1
if self.primary_game: steps += 1
return int((steps / total_steps) * 100) return int((steps / total_steps) * 100)
def __str__(self): def __str__(self):
@ -112,20 +151,78 @@ class Group(models.Model):
def __str__(self): def __str__(self):
return str(self.name) return str(self.name)
class EventTag(models.Model):
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return str(self.name)
class Event(models.Model): class Event(models.Model):
VISIBILITY_CHOICES = [
('public', 'Public (Members Only)'),
('group', 'Group Only'),
('invite', 'Invite Only'),
]
creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name='created_events')
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
description = models.TextField() description = models.TextField()
start_time = models.DateTimeField() start_datetime = models.DateTimeField()
end_time = models.DateTimeField() end_datetime = models.DateTimeField(null=True, blank=True)
location = models.CharField(max_length=255) timezone = models.CharField(max_length=50, default='UTC')
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='events', null=True, blank=True) location_name = models.CharField(max_length=255)
organizer = models.ForeignKey(User, on_delete=models.CASCADE) location_address = models.CharField(max_length=255, blank=True)
attendees = models.ManyToManyField(User, related_name='attending_events') city = models.CharField(max_length=100, blank=True)
organization_id = models.IntegerField(null=True, blank=True) state = models.CharField(max_length=100, blank=True)
is_online = models.BooleanField(default=False)
online_url = models.URLField(blank=True)
visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default='public')
group = models.ForeignKey(Group, on_delete=models.SET_NULL, null=True, blank=True, related_name='group_events')
capacity = models.PositiveIntegerField(null=True, blank=True)
cover_image = models.ImageField(upload_to='events/', blank=True, null=True)
tags = models.ManyToManyField(EventTag, blank=True)
created_at = models.DateTimeField(auto_now_add=True, null=True)
updated_at = models.DateTimeField(auto_now=True, null=True)
def __str__(self): def __str__(self):
return str(self.title) return str(self.title)
@property
def rsvp_count(self):
return self.rsvps.filter(status='going').count()
class RSVP(models.Model):
STATUS_CHOICES = [
('going', 'Going'),
('maybe', 'Maybe'),
('not_going', 'Not Going'),
]
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='rsvps')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='rsvps')
status = models.CharField(max_length=20, choices=STATUS_CHOICES)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('event', 'user')
def __str__(self):
return f"{self.user.username} - {self.event.title} ({self.status})"
class EventInvite(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('accepted', 'Accepted'),
('declined', 'Declined'),
]
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='invites')
from_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='invites_sent')
to_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='invites_received')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Invite: {self.event.title} to {self.to_user.username}"
class Report(models.Model): class Report(models.Model):
reporter = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reports_made') reporter = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reports_made')
reported_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reports_received', null=True, blank=True) reported_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reports_received', null=True, blank=True)
@ -137,9 +234,18 @@ class Report(models.Model):
def __str__(self): def __str__(self):
return f"Report by {self.reporter.username} at {self.timestamp}" return f"Report by {self.reporter.username} at {self.timestamp}"
class Thread(models.Model):
participants = models.ManyToManyField(User, related_name='threads')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Thread with {self.participants.count()} participants"
class Message(models.Model): class Message(models.Model):
thread = models.ForeignKey(Thread, on_delete=models.CASCADE, related_name='messages', null=True, blank=True)
sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_messages') sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_messages')
recipient = models.ForeignKey(User, on_delete=models.CASCADE, related_name='received_messages') recipient = models.ForeignKey(User, on_delete=models.CASCADE, related_name='received_messages', null=True, blank=True)
body = models.TextField() body = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True) timestamp = models.DateTimeField(auto_now_add=True)
is_read = models.BooleanField(default=False) is_read = models.BooleanField(default=False)
@ -148,7 +254,53 @@ class Message(models.Model):
ordering = ['timestamp'] ordering = ['timestamp']
def __str__(self): def __str__(self):
return f"From {self.sender.username} to {self.recipient.username} at {self.timestamp}" return f"From {self.sender.username} at {self.timestamp}"
class Like(models.Model):
from_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='likes_given')
to_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='likes_received')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('from_user', 'to_user')
class ConnectionRequest(models.Model):
STATUS_CHOICES = [
('pending', 'Pending'),
('accepted', 'Accepted'),
('declined', 'Declined'),
('canceled', 'Canceled'),
('blocked', 'Blocked'),
]
from_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='requests_sent')
to_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='requests_received')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
created_at = models.DateTimeField(auto_now_add=True)
responded_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ('from_user', 'to_user')
class Match(models.Model):
STATUS_CHOICES = [
('active', 'Active'),
('archived', 'Archived'),
]
user_a = models.ForeignKey(User, on_delete=models.CASCADE, related_name='matches_a')
user_b = models.ForeignKey(User, on_delete=models.CASCADE, related_name='matches_b')
created_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
class Meta:
unique_together = ('user_a', 'user_b')
class Block(models.Model):
blocker = models.ForeignKey(User, on_delete=models.CASCADE, related_name='blocks_given')
blocked = models.ForeignKey(User, on_delete=models.CASCADE, related_name='blocks_received')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('blocker', 'blocked')
class Post(models.Model): class Post(models.Model):
POST_TYPE_CHOICES = [ POST_TYPE_CHOICES = [

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

@ -19,21 +19,33 @@
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-lg sticky-top"> <nav class="navbar navbar-expand-lg sticky-top py-2">
<div class="container"> <div class="container-fluid px-lg-5">
<a class="navbar-brand brand-font fw-bold fs-4" href="/">CommonGround</a> <a class="navbar-brand brand-font fs-4 d-flex align-items-center" href="/">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <img src="{% static 'core/images/logo.png' %}" alt="CommonGround" class="me-2" style="height: 24px;">
<span class="fw-semibold ls-1" style="font-size: 1.25rem;">CommonGround</span>
</a>
<button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto align-items-center"> <ul class="navbar-nav ms-auto align-items-center">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link px-3" href="{% url 'home' %}">Discover</a> <a class="nav-link px-3" href="{% url 'home' %}">Teammates</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link px-3" href="{% url 'about' %}">About</a> <a class="nav-link px-3" href="{% url 'about' %}">About</a>
</li> </li>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link px-3 {% if 'matches' in request.path %}active{% endif %}" href="{% url 'matches' %}">My Teammates</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 {% if 'events' in request.path %}active{% endif %}" href="{% url 'events' %}">Sessions</a>
</li>
<li class="nav-item">
<a class="nav-link px-3 {% if 'groups' in request.path %}active{% endif %}" href="{% url 'groups:hub' %}">Squads</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link px-3" href="{% url 'inbox' %}">Messages</a> <a class="nav-link px-3" href="{% url 'inbox' %}">Messages</a>
</li> </li>
@ -72,19 +84,23 @@
<div class="bottom-nav d-lg-none"> <div class="bottom-nav d-lg-none">
<a href="{% url 'home' %}" class="bottom-nav-item {% if request.resolver_match.url_name == 'home' %}active{% endif %}"> <a href="{% url 'home' %}" class="bottom-nav-item {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
<i class="bi bi-compass"></i> <i class="bi bi-compass"></i>
Discover Teammates
</a> </a>
<a href="{% url 'home' %}" class="bottom-nav-item"> <a href="{% url 'matches' %}" class="bottom-nav-item {% if 'matches' in request.path %}active{% endif %}">
<i class="bi bi-view-stacked"></i> <i class="bi bi-people"></i>
Feed Teammates
</a> </a>
<a href="{% url 'inbox' %}" class="bottom-nav-item {% if request.resolver_match.url_name == 'inbox' or request.resolver_match.url_name == 'chat_detail' %}active{% endif %}"> <a href="{% url 'inbox' %}" class="bottom-nav-item {% if request.resolver_match.url_name == 'inbox' or request.resolver_match.url_name == 'chat_detail' %}active{% endif %}">
<i class="bi bi-chat-dots"></i> <i class="bi bi-chat-dots"></i>
Messages Messages
</a> </a>
<a href="#" class="bottom-nav-item"> <a href="{% url 'events' %}" class="bottom-nav-item {% if request.resolver_match.url_name == 'events' %}active{% endif %}">
<i class="bi bi-calendar-event"></i> <i class="bi bi-calendar-event"></i>
Events Sessions
</a>
<a href="{% url 'groups:hub' %}" class="bottom-nav-item {% if 'groups' in request.path %}active{% endif %}">
<i class="bi bi-grid"></i>
Squads
</a> </a>
<a href="{% url 'my_profile' %}" class="bottom-nav-item {% if request.resolver_match.url_name == 'my_profile' or request.resolver_match.url_name == 'profile_detail' or request.resolver_match.url_name == 'edit_profile' %}active{% endif %}"> <a href="{% url 'my_profile' %}" class="bottom-nav-item {% if request.resolver_match.url_name == 'my_profile' or request.resolver_match.url_name == 'profile_detail' or request.resolver_match.url_name == 'edit_profile' %}active{% endif %}">
<i class="bi bi-person"></i> <i class="bi bi-person"></i>

View File

@ -29,8 +29,44 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="headline" class="form-label fw-semibold">Professional Headline</label> <label for="headline" class="form-label fw-semibold">Gamer Headline</label>
<input type="text" class="form-control" id="headline" name="headline" value="{{ profile.professional_headline }}" placeholder="e.g. Software Engineer | Yoga Enthusiast"> <input type="text" class="form-control" id="headline" name="headline" value="{{ profile.professional_headline }}" placeholder="e.g. Competitive Apex Player | Support Main">
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="primary_game" class="form-label fw-semibold">Primary Game</label>
<select name="primary_game" id="primary_game" class="form-select">
<option value="">Select a game</option>
{% for game in games %}
<option value="{{ game.id }}" {% if profile.primary_game_id == game.id %}selected{% endif %}>{{ game.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="platform" class="form-label fw-semibold">Platform</label>
<select name="platform" id="platform" class="form-select">
<option value="">Select a platform</option>
{% for code, label in platforms %}
<option value="{{ code }}" {% if profile.platform == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<label for="gamer_tag" class="form-label fw-semibold">Gamer Tag</label>
<input type="text" class="form-control" id="gamer_tag" name="gamer_tag" value="{{ profile.gamer_tag }}" placeholder="e.g. Ninja#1234">
</div>
<div class="col-md-4">
<label for="rank" class="form-label fw-semibold">Current Rank</label>
<input type="text" class="form-control" id="rank" name="rank" value="{{ profile.rank }}" placeholder="e.g. Diamond II">
</div>
<div class="col-md-4">
<label for="preferred_role" class="form-label fw-semibold">Preferred Role</label>
<input type="text" class="form-control" id="preferred_role" name="preferred_role" value="{{ profile.preferred_role }}" placeholder="e.g. Tank / Support">
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -39,8 +75,8 @@
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="bio" class="form-label fw-semibold">Bio</label> <label for="bio" class="form-label fw-semibold">Gamer Bio</label>
<textarea class="form-control" id="bio" name="bio" rows="4" placeholder="Tell others about yourself, your interests, and what you're looking for...">{{ profile.bio }}</textarea> <textarea class="form-control" id="bio" name="bio" rows="4" placeholder="Tell others about your playstyle, schedule, and what kind of teammates you're looking for...">{{ profile.bio }}</textarea>
</div> </div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">

View File

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Delete Event | CommonGround{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-0 shadow-sm rounded-4">
<div class="card-body p-4 text-center">
<div class="bg-danger-subtle rounded-circle d-inline-flex p-3 mb-3">
<i class="bi bi-exclamation-triangle text-danger h3 mb-0"></i>
</div>
<h2 class="fw-bold h4 mb-3">Delete Event?</h2>
<p class="text-muted small mb-4">Are you sure you want to delete "<strong>{{ event.title }}</strong>"? This action cannot be undone and all RSVPs will be lost.</p>
<form method="POST">
{% csrf_token %}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-danger rounded-pill">Yes, Delete Event</button>
<a href="{% url 'event_detail' event.id %}" class="btn btn-light rounded-pill">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,149 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ event.title }} | CommonGround{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row">
<div class="col-lg-8">
<!-- Event Header -->
<div class="card border-0 shadow-sm rounded-4 overflow-hidden mb-4">
{% if event.cover_image %}
<img src="{{ event.cover_image.url }}" class="w-100" style="max-height: 400px; object-fit: cover;">
{% endif %}
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<span class="badge rounded-pill bg-dark extra-small mb-2">{{ event.get_visibility_display }}</span>
<h1 class="h2 fw-bold mb-1">{{ event.title }}</h1>
<p class="text-muted"><i class="bi bi-geo-alt me-1"></i> {{ event.location_name }}{% if event.city %}, {{ event.city }}{% endif %}</p>
</div>
{% if event.creator == user %}
<div class="dropdown">
<button class="btn btn-light rounded-circle" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow border-0">
<li><a class="dropdown-item small" href="{% url 'event_edit' event.id %}"><i class="bi bi-pencil me-2"></i> Edit Session</a></li>
...
<div class="mb-4">
<h5 class="fw-bold h6 mb-3">About this session</h5>
<div class="small text-secondary" style="white-space: pre-line;">
{{ event.description }}
</div>
</div>
{% if event.is_online and event.online_url %}
<div class="alert alert-primary border-0 rounded-4 d-flex align-items-center gap-3">
<i class="bi bi-laptop h4 mb-0"></i>
<div>
<p class="small fw-bold mb-0">This is an online session</p>
<a href="{{ event.online_url }}" class="extra-small text-decoration-none" target="_blank">{{ event.online_url }}</a>
</div>
</div>
{% endif %}
</div>
<div class="row g-4 mb-4">
<div class="col-sm-6">
<div class="d-flex align-items-center gap-3 p-3 bg-light rounded-3">
<div class="bg-white rounded-3 p-2 shadow-sm text-center" style="min-width: 60px;">
<span class="d-block extra-small text-uppercase fw-bold text-primary">{{ event.start_datetime|date:"M" }}</span>
<span class="h4 fw-bold mb-0">{{ event.start_datetime|date:"d" }}</span>
</div>
<div>
<p class="small fw-bold mb-0">{{ event.start_datetime|date:"l, P" }}</p>
<p class="extra-small text-muted mb-0">{{ event.timezone }}</p>
</div>
</div>
</div>
<div class="col-sm-6">
<div class="d-flex align-items-center gap-3 p-3 bg-light rounded-3 h-100">
<div class="bg-white rounded-3 p-2 shadow-sm d-flex align-items-center justify-content-center" style="min-width: 60px; height: 60px;">
<i class="bi bi-people h4 mb-0 text-primary"></i>
</div>
<div>
<p class="small fw-bold mb-0">{{ going_count }} Going</p>
<p class="extra-small text-muted mb-0">{% if event.capacity %}{{ event.capacity }} capacity{% else %}Unlimited space{% endif %}</p>
</div>
</div>
</div>
</div>
<div class="mb-4">
<h5 class="fw-bold h6 mb-3">About this event</h5>
<div class="small text-secondary" style="white-space: pre-line;">
{{ event.description }}
</div>
</div>
{% if event.is_online and event.online_url %}
<div class="alert alert-primary border-0 rounded-4 d-flex align-items-center gap-3">
<i class="bi bi-laptop h4 mb-0"></i>
<div>
<p class="small fw-bold mb-0">This is an online event</p>
<a href="{{ event.online_url }}" class="extra-small text-decoration-none" target="_blank">{{ event.online_url }}</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<!-- RSVP Widget -->
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-bold h6 mb-3">RSVP</h5>
{% if user_rsvp %}
<div class="alert alert-{% if user_rsvp.status == 'going' %}success{% elif user_rsvp.status == 'maybe' %}info{% else %}secondary{% endif %} border-0 small mb-3">
You are marked as: <strong>{{ user_rsvp.get_status_display }}</strong>
</div>
{% endif %}
<form action="{% url 'event_rsvp' event.id %}" method="POST" class="d-grid gap-2">
{% csrf_token %}
<button type="submit" name="status" value="going" class="btn {% if user_rsvp.status == 'going' %}btn-primary-cg{% else %}btn-outline-primary-cg{% endif %} rounded-pill btn-sm">Going</button>
<button type="submit" name="status" value="maybe" class="btn {% if user_rsvp.status == 'maybe' %}btn-primary-cg{% else %}btn-outline-primary-cg{% endif %} rounded-pill btn-sm">Maybe</button>
<button type="submit" name="status" value="not_going" class="btn {% if user_rsvp.status == 'not_going' %}btn-primary-cg{% else %}btn-outline-primary-cg{% endif %} rounded-pill btn-sm">Not Going</button>
{% if user_rsvp %}
<button type="submit" name="status" value="cancel" class="btn btn-link text-muted extra-small text-decoration-none">Cancel RSVP</button>
{% endif %}
</form>
</div>
</div>
<!-- Organizer Widget -->
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4 text-center">
<h5 class="fw-bold h6 mb-3 text-start">Organizer</h5>
<img src="{{ event.creator.profile.get_avatar_url }}" class="rounded-circle mb-2" width="64" height="64">
<h6 class="fw-bold mb-1">{{ event.creator.get_full_name|default:event.creator.username }}</h6>
<p class="extra-small text-muted mb-3">{{ event.creator.profile.professional_headline }}</p>
<a href="{% url 'profile_detail' event.creator.username %}" class="btn btn-outline-dark btn-sm rounded-pill w-100 extra-small">View Profile</a>
</div>
</div>
<!-- Attendees Widget -->
<div class="card border-0 shadow-sm rounded-4">
<div class="card-body p-4">
<h5 class="fw-bold h6 mb-3">Attendees ({{ going_count }})</h5>
<div class="d-flex flex-wrap gap-2">
{% for rsvp in rsvps %}
{% if rsvp.status == 'going' %}
<a href="{% url 'profile_detail' rsvp.user.username %}" title="{{ rsvp.user.username }}">
<img src="{{ rsvp.user.profile.get_avatar_url }}" class="rounded-circle" width="32" height="32" style="object-fit: cover;">
</a>
{% endif %}
{% empty %}
<p class="extra-small text-muted mb-0">No one yet. Be the first!</p>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,126 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }} | CommonGround{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-0 shadow-sm rounded-4">
<div class="card-body p-4 p-md-5">
<h2 class="fw-bold mb-4">{{ title }}</h2>
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div class="row g-3">
<!-- Basic Info -->
<div class="col-12">
<label class="form-label small fw-bold">Session Title*</label>
{{ form.title }}
{{ form.title.errors }}
</div>
<div class="col-12">
<label class="form-label small fw-bold">Description*</label>
{{ form.description }}
{{ form.description.errors }}
</div>
<!-- Date & Time -->
<div class="col-md-6">
<label class="form-label small fw-bold">Start Date & Time*</label>
{{ form.start_datetime }}
{{ form.start_datetime.errors }}
</div>
<div class="col-md-6">
<label class="form-label small fw-bold">End Date & Time</label>
{{ form.end_datetime }}
{{ form.end_datetime.errors }}
</div>
<div class="col-md-6">
<label class="form-label small fw-bold">Timezone</label>
{{ form.timezone }}
{{ form.timezone.errors }}
</div>
<!-- Location -->
<div class="col-md-6">
<label class="form-label small fw-bold">Location Name*</label>
{{ form.location_name }}
{{ form.location_name.errors }}
</div>
<div class="col-12">
<label class="form-label small fw-bold">Address</label>
{{ form.location_address }}
{{ form.location_address.errors }}
</div>
<div class="col-md-6">
<label class="form-label small fw-bold">City</label>
{{ form.city }}
{{ form.city.errors }}
</div>
<div class="col-md-6">
<label class="form-label small fw-bold">State</label>
{{ form.state }}
{{ form.state.errors }}
</div>
<!-- Online Info -->
<div class="col-12 mt-4">
<div class="form-check form-switch">
{{ form.is_online }}
<label class="form-check-label small fw-bold" for="{{ form.is_online.id_for_label }}">This is an online session</label>
</div>
</div>
...
<div class="d-flex justify-content-between align-items-center mt-5">
<a href="{% if event %}{% url 'event_detail' event.id %}{% else %}{% url 'events' %}{% endif %}" class="btn btn-link text-muted text-decoration-none">Cancel</a>
<button type="submit" class="btn btn-primary-cg rounded-pill px-5">Save Session</button>
</div>
</div>
<div class="col-12">
<label class="form-label small fw-bold">Online Meeting URL</label>
{{ form.online_url }}
{{ form.online_url.errors }}
</div>
<!-- Settings -->
<div class="col-md-6 mt-4">
<label class="form-label small fw-bold">Visibility</label>
{{ form.visibility }}
{{ form.visibility.errors }}
</div>
<div class="col-md-6 mt-4">
<label class="form-label small fw-bold">Capacity</label>
{{ form.capacity }}
{{ form.capacity.errors }}
</div>
<div class="col-12 mt-4">
<label class="form-label small fw-bold">Cover Image</label>
{{ form.cover_image }}
{{ form.cover_image.errors }}
</div>
<div class="col-12 mt-4">
<label class="form-label small fw-bold">Tags</label>
{{ form.tags }}
{{ form.tags.errors }}
<p class="extra-small text-muted mt-1">Hold Ctrl (or Cmd) to select multiple tags.</p>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-5">
<a href="{% if event %}{% url 'event_detail' event.id %}{% else %}{% url 'events' %}{% endif %}" class="btn btn-link text-muted text-decoration-none">Cancel</a>
<button type="submit" class="btn btn-primary-cg rounded-pill px-5">Save Event</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,106 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Sessions | CommonGround{% endblock %}
{% block content %}
<div class="container-fluid dashboard-container px-lg-5">
<div class="row">
<!-- LEFT SIDEBAR -->
<div class="col-lg-2 d-none d-lg-block">
<div class="sticky-sidebar">
<nav class="sidebar-nav">
<a href="{% url 'home' %}" class="nav-link"><i class="bi bi-compass"></i> Teammates</a>
<a href="{% url 'matches' %}" class="nav-link"><i class="bi bi-people"></i> My Teammates</a>
<a href="{% url 'inbox' %}" class="nav-link"><i class="bi bi-chat-dots"></i> Messages</a>
<a href="{% url 'events' %}" class="nav-link active"><i class="bi bi-calendar-event"></i> Sessions</a>
<a href="{% url 'groups:hub' %}" class="nav-link"><i class="bi bi-grid"></i> Squads</a>
<a href="{% url 'my_posts' %}" class="nav-link"><i class="bi bi-journal-text"></i> My Posts</a>
<a href="{% url 'my_profile' %}" class="nav-link"><i class="bi bi-person"></i> Profile</a>
</nav>
</div>
</div>
<!-- MAIN CONTENT -->
<div class="col-lg-10">
<div class="d-flex justify-content-between align-items-end mb-5 pt-2">
<div>
<h1 class="h2 mb-2">Sessions</h1>
<p class="text-muted mb-0">Coordinate and play with your teammates.</p>
</div>
<a href="{% url 'event_create' %}" class="btn btn-primary-cg">
<i class="bi bi-plus-lg me-2"></i> Create Session
</a>
</div>
<!-- Tabs & Search -->
<div class="card border-0 shadow-soft rounded-cg mb-5">
<div class="card-body p-4">
<div class="row align-items-center g-4">
<div class="col-md-7">
<div class="d-flex gap-3 overflow-auto pb-1">
<a href="?tab=upcoming" class="btn btn-sm {% if tab == 'upcoming' %}btn-primary-cg{% else %}btn-outline-cg border-0 bg-secondary-section{% endif %} rounded-pill px-4">Upcoming</a>
<a href="?tab=calendar" class="btn btn-sm {% if tab == 'calendar' %}btn-primary-cg{% else %}btn-outline-cg border-0 bg-secondary-section{% endif %} rounded-pill px-4">Calendar</a>
<a href="?tab=attending" class="btn btn-sm {% if tab == 'attending' %}btn-primary-cg{% else %}btn-outline-cg border-0 bg-secondary-section{% endif %} rounded-pill px-4">Attending</a>
<a href="?tab=mine" class="btn btn-sm {% if tab == 'mine' %}btn-primary-cg{% else %}btn-outline-cg border-0 bg-secondary-section{% endif %} rounded-pill px-4">My Sessions</a>
<a href="?tab=past" class="btn btn-sm {% if tab == 'past' %}btn-primary-cg{% else %}btn-outline-cg border-0 bg-secondary-section{% endif %} rounded-pill px-4">Past</a>
</div>
</div>
<div class="col-md-5">
<form action="" method="GET" class="input-group">
<input type="hidden" name="tab" value="{{ tab }}">
<input type="text" name="q" class="form-control border-0 bg-secondary-section rounded-start-cg px-4" placeholder="Search sessions..." value="{{ query }}">
<button class="btn btn-outline-cg border-0 bg-secondary-section rounded-end-cg px-4" type="submit"><i class="bi bi-search text-muted"></i></button>
</form>
</div>
</div>
</div>
</div>
<!-- Events Grid -->
<div class="row g-5">
{% if tab == 'calendar' %}
{% for date, day_events in calendar_events.items %}
<div class="col-12 mb-5">
<div class="d-flex align-items-center gap-4 mb-4">
<h4 class="mb-0 text-slate opacity-75" style="min-width: 140px;">{{ date|date:"D, M d" }}</h4>
<div class="flex-grow-1 border-top" style="border-color: var(--cg-divider) !important;"></div>
</div>
<div class="row g-5">
{% for event in day_events %}
<div class="col-md-6 col-xl-4">
{% include "core/includes/event_card.html" with event=event %}
</div>
{% endfor %}
</div>
</div>
{% empty %}
<div class="col-12">
<div class="text-center py-5 bg-white rounded-cg shadow-soft">
<i class="bi bi-calendar-x display-1 text-muted opacity-25 mb-4 d-block"></i>
<h4 class="fw-bold">No sessions scheduled</h4>
<p class="text-muted">Stay tuned for upcoming squad sessions.</p>
</div>
</div>
{% endfor %}
{% else %}
{% for event in events %}
<div class="col-md-6 col-xl-4">
{% include "core/includes/event_card.html" with event=event %}
</div>
{% empty %}
<div class="col-12">
<div class="text-center py-5 bg-white rounded-cg shadow-soft">
<i class="bi bi-calendar-x display-1 text-muted opacity-25 mb-4 d-block"></i>
<h4 class="fw-bold">No sessions found</h4>
<p class="text-muted">Try adjusting your filters or search query.</p>
<a href="{% url 'event_create' %}" class="btn btn-primary-cg mt-4">Create a session!</a>
</div>
</div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,40 @@
<div class="card border-0 shadow-soft rounded-cg h-100 overflow-hidden event-card">
{% if event.cover_image %}
<img src="{{ event.cover_image.url }}" class="card-img-top" style="height: 180px; object-fit: cover;">
{% else %}
<div class="bg-secondary-section d-flex align-items-center justify-content-center" style="height: 180px;">
<i class="bi bi-calendar-event text-muted opacity-25" style="font-size: 3rem;"></i>
</div>
{% endif %}
<div class="card-body p-4 d-flex flex-column">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="intent-badge border-0" style="background: var(--cg-sand-primary); font-size: 0.7rem;">
{{ event.get_visibility_display }}
</span>
<p class="small fw-bold text-slate mb-0 opacity-75">
{{ event.start_datetime|date:"M d" }}
</p>
</div>
<h5 class="fw-bold h6 mb-2">
<a href="{% url 'event_detail' event.id %}" class="text-slate text-decoration-none stretched-link">{{ event.title }}</a>
</h5>
<p class="text-muted small mb-4 line-clamp-2">{{ event.description }}</p>
<div class="mt-auto">
<div class="d-flex align-items-center gap-2 mb-4">
<i class="bi bi-geo-alt text-muted small"></i>
<span class="small text-muted text-truncate">{{ event.location_name }}{% if event.city %}, {{ event.city }}{% endif %}</span>
</div>
<div class="d-flex justify-content-between align-items-center pt-3 border-top">
<div class="d-flex align-items-center">
<img src="{{ event.creator.profile.get_avatar_url }}" class="rounded-circle me-2" width="28" height="28" style="object-fit: cover;">
<span class="small text-muted">By {{ event.creator.first_name }}</span>
</div>
<div class="small text-muted">
<i class="bi bi-people me-1"></i> {{ event.rsvp_count }}{% if event.capacity %}/{{ event.capacity }}{% endif %}
</div>
</div>
</div>
</div>
</div>

View File

@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% load social_filters %} {% load social_filters %}
{% block title %}Dashboard | CommonGround{% endblock %} {% block title %}Teammates | CommonGround{% endblock %}
{% block content %} {% block content %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
@ -12,14 +12,14 @@
<div class="col-lg-2 d-none d-lg-block"> <div class="col-lg-2 d-none d-lg-block">
<div class="sticky-sidebar"> <div class="sticky-sidebar">
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="{% url 'home' %}" class="nav-link active"><i class="bi bi-compass"></i> Discover</a> <a href="{% url 'home' %}" class="nav-link {% if request.resolver_match.url_name == 'home' %}active{% endif %}"><i class="bi bi-compass"></i> Teammates</a>
<a href="#" class="nav-link"><i class="bi bi-people"></i> My Matches</a> <a href="{% url 'matches' %}" class="nav-link {% if request.resolver_match.url_name == 'matches' %}active{% endif %}"><i class="bi bi-people"></i> My Teammates</a>
<a href="{% url 'inbox' %}" class="nav-link"><i class="bi bi-chat-dots"></i> Messages</a> <a href="{% url 'inbox' %}" class="nav-link {% if request.resolver_match.url_name == 'inbox' or request.resolver_match.url_name == 'chat_detail' %}active{% endif %}"><i class="bi bi-chat-dots"></i> Messages</a>
<a href="#" class="nav-link"><i class="bi bi-calendar-event"></i> Events</a> <a href="{% url 'events' %}" class="nav-link {% if request.resolver_match.url_name == 'events' %}active{% endif %}"><i class="bi bi-calendar-event"></i> Sessions</a>
<a href="#" class="nav-link"><i class="bi bi-grid"></i> Groups</a> <a href="{% url 'groups:hub' %}" class="nav-link {% if 'groups' in request.path %}active{% endif %}"><i class="bi bi-grid"></i> Squads</a>
<a href="{% url 'my_profile' %}" class="nav-link"><i class="bi bi-journal-text"></i> My Posts</a> <a href="{% url 'my_posts' %}" class="nav-link {% if request.resolver_match.url_name == 'my_posts' %}active{% endif %}"><i class="bi bi-journal-text"></i> My Posts</a>
<a href="{% url 'my_profile' %}" class="nav-link"><i class="bi bi-person"></i> Profile</a> <a href="{% url 'my_profile' %}" class="nav-link {% if request.resolver_match.url_name == 'my_profile' or request.resolver_match.url_name == 'profile_detail' %}active{% endif %}"><i class="bi bi-person"></i> Profile</a>
<a href="{% url 'settings' %}" class="nav-link"><i class="bi bi-gear"></i> Settings</a> <a href="{% url 'settings' %}" class="nav-link {% if request.resolver_match.url_name == 'settings' %}active{% endif %}"><i class="bi bi-gear"></i> Settings</a>
</nav> </nav>
</div> </div>
</div> </div>
@ -27,78 +27,81 @@
<!-- CENTER COLUMN --> <!-- CENTER COLUMN -->
<div class="col-lg-7 col-md-12"> <div class="col-lg-7 col-md-12">
<!-- Welcome Header --> <!-- Welcome Header -->
<div class="mb-4 pt-2"> <div class="dashboard-section pt-2">
<h2 class="h3 fw-bold mb-1">Welcome back, {{ user.first_name|default:user.username }}</h2> <h1 class="welcome-header mb-1">Welcome back, {{ user.first_name|default:user.username }}</h1>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-3">
<span class="badge rounded-pill bg-dark extra-small">{{ user.profile.get_transition_status_display }}</span> <span class="text-muted small"><i class="bi bi-geo-alt me-1"></i> {{ user.profile.location_city|default:"Location not set" }}</span>
<span class="text-muted extra-small"><i class="bi bi-geo-alt me-1"></i> {{ user.profile.location_city|default:"Location not set" }}</span> {% if user.profile.primary_game %}
<span class="intent-badge">{{ user.profile.primary_game.name }}</span>
{% endif %}
</div> </div>
</div> </div>
<!-- Post Composer --> <!-- Post Composer -->
<div class="card border-0 shadow-sm mb-4 rounded-4"> <div class="dashboard-section">
<div class="card-body p-4"> <div class="card p-4">
<form action="{% url 'create_post' %}" method="POST" enctype="multipart/form-data"> <form action="{% url 'create_post' %}" method="POST" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="d-flex gap-3 mb-3"> <div class="d-flex gap-4 mb-4">
<img src="{{ user.profile.get_avatar_url }}" class="rounded-circle" width="48" height="48" style="object-fit: cover;"> <img src="{{ user.profile.get_avatar_url }}" class="rounded-circle" width="48" height="48" style="object-fit: cover; opacity: 0.9;">
<div class="flex-grow-1"> <div class="flex-grow-1">
<label class="small fw-bold text-muted mb-1">What are you building this week?</label> <label class="small fw-bold text-muted mb-2">Looking for a squad or sharing a highlight?</label>
<textarea name="content" class="form-control border-0 bg-light rounded-3" placeholder="Share a progress update, ask for help, or offer a skill..." rows="2"></textarea> <textarea name="content" class="form-control border-0 bg-light" placeholder="Looking for ranked, casual duo, or sharing a play..." rows="2" style="resize: none;"></textarea>
</div> </div>
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center pt-2">
<div class="d-flex gap-2"> <div class="d-flex gap-3">
<select name="post_type" class="form-select form-select-sm border-0 bg-light rounded-pill px-3" style="width: auto; font-size: 0.75rem;"> <select name="post_type" class="form-select form-select-sm border-0 bg-light px-3" style="width: auto; font-size: 0.8rem;">
{% for code, label in post_types %} {% for code, label in post_types %}
<option value="{{ code }}">{{ label }}</option> <option value="{{ code }}">{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="upload-btn-wrapper"> <div class="upload-btn-wrapper">
<button type="button" class="btn btn-light btn-sm rounded-pill text-muted"> <button type="button" class="btn btn-outline-cg border-0 bg-light btn-sm px-3">
<i class="bi bi-image"></i> <i class="bi bi-image text-muted"></i>
</button> </button>
<input type="file" name="image" accept="image/*" /> <input type="file" name="image" accept="image/*" />
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary-cg rounded-pill px-4 btn-sm">Post</button> <button type="submit" class="btn btn-primary-cg btn-sm px-4">Post</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Suggested Members (Horizontal Scroll) --> <!-- Suggested Teammates -->
<div class="mb-4"> <div class="dashboard-section">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0 h6">Suggested Members</h5> <h2 class="section-header mb-0">Suggested Teammates</h2>
<a href="{% url 'home' %}" class="extra-small text-decoration-none fw-bold">View All</a> <a href="{% url 'home' %}" class="small text-decoration-none fw-bold text-muted">View All</a>
</div> </div>
<div class="horizontal-scroll"> <div class="horizontal-scroll">
{% for profile in suggested_members %} {% for profile in suggested_members %}
<div class="suggestion-card shadow-sm"> <div class="suggestion-card">
<a href="{% url 'profile_detail' profile.user.username %}" class="text-decoration-none text-dark"> <a href="{% url 'profile_detail' profile.user.username %}" class="text-decoration-none text-dark">
<img src="{{ profile.get_avatar_url }}" class="rounded-circle mb-2" width="50" height="50" style="object-fit: cover;"> <img src="{{ profile.get_avatar_url }}" class="rounded-circle mb-3" width="56" height="56" style="object-fit: cover;">
<h6 class="mb-0 extra-small fw-bold text-truncate">{{ profile.user.first_name }} {{ profile.user.last_name|slice:":1" }}.</h6> <h6 class="mb-1 small fw-bold text-truncate">{{ profile.user.first_name }} {{ profile.user.last_name|slice:":1" }}.</h6>
<p class="extra-small text-muted mb-2 text-truncate">{{ profile.professional_headline|default:"Member" }}</p> <p class="small text-muted mb-3 text-truncate">{{ profile.primary_game.name|default:"Gamer" }}</p>
</a> </a>
{% if profile.user.id in following_ids %} <div class="d-grid">
<a href="{% url 'toggle_follow' profile.user.username %}" class="btn btn-outline-primary btn-sm py-1 w-100 extra-small">Following</a> {% if profile.user.id in following_ids %}
{% else %} <a href="{% url 'toggle_follow' profile.user.username %}" class="btn btn-outline-cg btn-sm py-1">Following</a>
<a href="{% url 'toggle_follow' profile.user.username %}" class="btn btn-primary-cg btn-sm py-1 w-100 extra-small">Follow</a> {% else %}
{% endif %} <a href="{% url 'toggle_follow' profile.user.username %}" class="btn btn-primary-cg btn-sm py-1">Follow</a>
{% endif %}
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<!-- Activity Feed --> <!-- Activity Feed -->
<div class="feed mt-4"> <div class="feed dashboard-section">
<h5 class="fw-bold mb-3 h6">Activity Feed</h5> <h2 class="section-header mb-4">Activity Feed</h2>
{% for post in posts %} {% for post in posts %}
<div class="card border-0 shadow-sm mb-4 rounded-4 overflow-hidden"> <div class="card mb-5">
<div class="card-body p-4"> <div class="card-body p-4">
<span class="post-badge badge-{{ post.post_type }}">{{ post.get_post_type_display }}</span> <div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex justify-content-between align-items-start mb-3">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<img src="{{ post.author.profile.get_avatar_url }}" class="rounded-circle me-3" width="44" height="44" style="object-fit: cover;"> <img src="{{ post.author.profile.get_avatar_url }}" class="rounded-circle me-3" width="44" height="44" style="object-fit: cover;">
<div> <div>
@ -106,54 +109,43 @@
<p class="text-muted extra-small mb-0">{{ post.timestamp|timesince }} ago</p> <p class="text-muted extra-small mb-0">{{ post.timestamp|timesince }} ago</p>
</div> </div>
</div> </div>
<div class="dropdown"> <span class="post-badge">{{ post.get_post_type_display }}</span>
<button class="btn btn-link text-muted p-0" data-bs-toggle="dropdown">
<i class="bi bi-three-dots"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li><a class="dropdown-item text-danger extra-small" href="{% url 'hide_post' post.id %}"><i class="bi bi-eye-slash me-2"></i> Hide post</a></li>
{% if post.author == user %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger extra-small" href="{% url 'delete_post' post.id %}"><i class="bi bi-trash me-2"></i> Delete</a></li>
{% endif %}
</ul>
</div>
</div> </div>
<p class="mb-3 small">{{ post.content }}</p> <p class="mb-4">{{ post.content }}</p>
{% if post.image %} {% if post.image %}
<img src="{{ post.image.url }}" class="img-fluid rounded-3 mb-3 w-100" style="max-height: 350px; object-fit: cover;"> <img src="{{ post.image.url }}" class="img-fluid rounded-cg mb-4 w-100" style="max-height: 400px; object-fit: cover;">
{% endif %} {% endif %}
<!-- Post Actions --> <!-- Post Actions -->
<div class="d-flex gap-4 border-top pt-3 mt-3"> <div class="d-flex gap-4 border-top pt-4">
<a href="{% url 'toggle_reaction' post.id %}?type=heart" class="text-decoration-none {% if post|reacted_by:user %}text-danger{% else %}text-muted{% endif %} extra-small fw-bold"> <a href="{% url 'toggle_reaction' post.id %}?type=heart" class="text-decoration-none {% if post|reacted_by:user %}text-danger{% else %}text-muted{% endif %} small fw-bold">
<i class="bi bi-heart{% if post|reacted_by:user %}-fill text-danger{% endif %} me-1"></i> {{ post.reactions.count }} <i class="bi bi-heart{% if post|reacted_by:user %}-fill{% endif %} me-2"></i> {{ post.reactions.count }}
</a> </a>
<button class="btn btn-link p-0 text-muted text-decoration-none extra-small fw-bold" type="button" data-bs-toggle="collapse" data-bs-target="#comments-{{ post.id }}"> <button class="btn btn-link p-0 text-muted text-decoration-none small fw-bold" type="button" data-bs-toggle="collapse" data-bs-target="#comments-{{ post.id }}">
<i class="bi bi-chat me-1"></i> {{ post.comments.count }} <i class="bi bi-chat me-2"></i> {{ post.comments.count }}
</button> </button>
</div> </div>
<!-- Comments Section --> <!-- Comments Section -->
<div class="collapse mt-3" id="comments-{{ post.id }}"> <div class="collapse mt-4" id="comments-{{ post.id }}">
<div class="bg-light p-3 rounded-3"> <div class="bg-secondary-section p-4 rounded-cg">
{% for comment in post.comments.all %} {% for comment in post.comments.all %}
<div class="d-flex gap-2 mb-2"> <div class="d-flex gap-3 mb-3">
<img src="{{ comment.author.profile.get_avatar_url }}" class="rounded-circle" width="28" height="28" style="object-fit: cover;"> <img src="{{ comment.author.profile.get_avatar_url }}" class="rounded-circle" width="32" height="32" style="object-fit: cover;">
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="bg-white p-2 rounded-3 shadow-sm"> <div class="bg-white p-3 rounded-cg shadow-soft">
<p class="extra-small fw-bold mb-0">{{ comment.author.username }}</p> <p class="small fw-bold mb-1">{{ comment.author.username }}</p>
<p class="extra-small mb-0">{{ comment.content }}</p> <p class="small mb-0 text-muted">{{ comment.content }}</p>
</div> </div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
<form action="{% url 'add_comment' post.id %}" method="POST" class="mt-2"> <form action="{% url 'add_comment' post.id %}" method="POST" class="mt-3">
{% csrf_token %} {% csrf_token %}
<div class="input-group input-group-sm"> <div class="input-group">
<input type="text" name="content" class="form-control border-0 rounded-start-pill px-3 bg-white" placeholder="Write a comment..."> <input type="text" name="content" class="form-control border-0 rounded-start-cg px-4 bg-white" placeholder="Write a comment...">
<button class="btn btn-primary-cg rounded-end-pill px-3" type="submit">Send</button> <button class="btn btn-primary-cg rounded-end-cg px-4" type="submit">Send</button>
</div> </div>
</form> </form>
</div> </div>
@ -161,8 +153,8 @@
</div> </div>
</div> </div>
{% empty %} {% empty %}
<div class="text-center py-5 bg-white rounded-4 shadow-sm"> <div class="text-center py-5 bg-white rounded-cg shadow-soft">
<p class="text-muted small">No posts yet. Start the conversation!</p> <p class="text-muted">No posts yet. Start the conversation!</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -170,62 +162,60 @@
<!-- RIGHT SIDEBAR (Sticky) --> <!-- RIGHT SIDEBAR (Sticky) -->
<div class="col-lg-3 d-none d-lg-block"> <div class="col-lg-3 d-none d-lg-block">
<div class="sticky-sidebar"> <div class="sticky-sidebar pt-2">
<!-- Quick Stats --> <!-- Quick Stats -->
<div class="stat-widget shadow-sm mb-4"> <div class="dashboard-section">
<h6 class="fw-bold mb-3 border-bottom pb-2 h6">Your Momentum</h6> <div class="stat-widget">
<div class="stat-item"> <h2 class="section-header mb-4" style="font-size: 1.1rem;">Your Momentum</h2>
<span class="extra-small text-muted">Unread Messages</span> <div class="stat-item border-0 mb-2 d-flex justify-content-between">
<span class="badge bg-danger rounded-pill extra-small">{{ stats.unread_messages }}</span> <span class="small text-muted">Unread Messages</span>
</div> <span class="badge bg-light text-dark rounded-pill small">{{ stats.unread_messages }}</span>
<div class="stat-item">
<span class="extra-small text-muted">Accountability Streak</span>
<span class="fw-bold text-success extra-small">{{ stats.streak }} 🔥</span>
</div>
<div class="stat-item">
<span class="extra-small text-muted">Followers</span>
<span class="fw-bold extra-small text-info">{{ stats.followers_count }}</span>
</div>
<div class="stat-item">
<span class="extra-small text-muted">Following</span>
<span class="fw-bold extra-small text-primary">{{ stats.following_count }}</span>
</div>
<div class="stat-item">
<span class="extra-small text-muted">Upcoming Events</span>
<span class="fw-bold extra-small">{{ stats.upcoming_events_count }}</span>
</div>
<div class="mt-3">
<div class="d-flex justify-content-between extra-small mb-1">
<span class="text-muted">Profile Completion</span>
<span class="fw-bold">{{ stats.completion_percentage }}%</span>
</div> </div>
<div class="progress" style="height: 6px;"> <div class="stat-item border-0 mb-2 d-flex justify-content-between">
<div class="progress-bar bg-primary" style="width: {{ stats.completion_percentage }}%"></div> <span class="small text-muted">Accountability Streak</span>
<span class="fw-bold text-sage small">{{ stats.streak }} 🔥</span>
</div>
<div class="stat-item border-0 mb-3 d-flex justify-content-between">
<span class="small text-muted">Upcoming Events</span>
<span class="fw-bold small">{{ stats.upcoming_events_count }}</span>
</div>
<div class="mt-4 pt-3 border-top">
<div class="d-flex justify-content-between small mb-2">
<span class="text-muted">Profile Completion</span>
<span class="fw-bold">{{ stats.completion_percentage }}%</span>
</div>
<div class="progress" style="height: 4px; background-color: rgba(0,0,0,0.05);">
<div class="progress-bar" style="width: {{ stats.completion_percentage }}%; background-color: var(--cg-slate);"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="stat-widget shadow-sm mb-4"> <div class="dashboard-section">
<h6 class="fw-bold mb-3 border-bottom pb-2 h6">Quick Actions</h6> <div class="stat-widget">
<div class="d-grid gap-2"> <h2 class="section-header mb-4" style="font-size: 1.1rem;">Quick Actions</h2>
<button class="btn btn-outline-cg btn-sm text-start extra-small"><i class="bi bi-calendar-plus me-2"></i> Create Event</button> <div class="d-grid gap-3">
<button class="btn btn-outline-cg btn-sm text-start extra-small"><i class="bi bi-person-plus me-2"></i> Find Partner</button> <a href="{% url 'event_create' %}" class="btn btn-outline-cg btn-sm text-start py-2"><i class="bi bi-calendar-plus me-3"></i> Create Session</a>
<button class="btn btn-outline-cg btn-sm text-start extra-small"><i class="bi bi-plus-circle me-2"></i> Start Group</button> <a href="{% url 'matches' %}" class="btn btn-outline-cg btn-sm text-start py-2"><i class="bi bi-person-plus me-3"></i> Find Teammate</a>
<a href="{% url 'groups:create' %}" class="btn btn-outline-cg btn-sm text-start py-2"><i class="bi bi-plus-circle me-3"></i> Start Squad</a>
</div>
</div> </div>
</div> </div>
<!-- Suggested Events --> <!-- Suggested Events -->
<div class="stat-widget shadow-sm"> <div class="dashboard-section">
<h6 class="fw-bold mb-3 border-bottom pb-2 h6">Upcoming Events</h6> <div class="stat-widget">
{% for event in suggested_events %} <h2 class="section-header mb-3 border-bottom pb-2" style="font-size: 1.1rem;">Upcoming Sessions</h2>
<div class="mb-3 pb-2 border-bottom"> {% for event in suggested_events %}
<p class="extra-small fw-bold mb-0 text-truncate">{{ event.title }}</p> <div class="mb-3 pb-2 border-bottom">
<p class="extra-small text-muted mb-0">{{ event.start_time|date:"M d, P" }}</p> <p class="small fw-bold mb-0 text-truncate">{{ event.title }}</p>
<p class="extra-small text-muted mb-0">{{ event.start_datetime|date:"M d, P" }}</p>
</div>
{% empty %}
<p class="extra-small text-muted text-center py-2">No upcoming sessions found.</p>
{% endfor %}
</div> </div>
{% empty %}
<p class="extra-small text-muted text-center py-2">No upcoming events found.</p>
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
@ -234,59 +224,61 @@
{% else %} {% else %}
<!-- Anonymous Landing Page --> <!-- Anonymous Landing Page -->
<section class="hero-section"> <section class="hero-section py-5">
<div class="container"> <div class="container py-5">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-8"> <div class="col-lg-8 text-center">
<h1 class="display-4 fw-bold mb-3">Intentional connections for life's transitions.</h1> <h1 class="display-3 mb-4">Find your squad.</h1>
<p class="lead text-muted mb-4">CommonGround helps professionals build high-trust, platonic relationships based on shared values and life goals.</p> <p class="lead text-muted mb-5 px-lg-5">Match by game, rank, role, schedule, and vibe. The intentional network for gamers.</p>
<div class="d-flex justify-content-center gap-3"> <div class="d-flex justify-content-center gap-4">
<a href="{% url 'get_started' %}" class="btn btn-primary-cg px-4 py-2">Get Started</a> <a href="{% url 'signup' %}" class="btn btn-primary-cg px-5">Get Started</a>
<a href="{% url 'about' %}" class="btn btn-outline-secondary rounded-3 px-4 py-2">Learn More</a> <a href="{% url 'about' %}" class="btn btn-outline-cg px-5">Learn More</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="py-5 bg-white"> <section class="py-5 bg-secondary-section">
<div class="container"> <div class="container py-5">
<div class="d-flex justify-content-between align-items-end mb-4 flex-wrap gap-3"> <div class="d-flex justify-content-between align-items-end mb-5 flex-wrap gap-4">
<div> <div>
<h2 class="h3 mb-1">Discovery Feed</h2> <h2 class="mb-2">Teammate Discovery</h2>
<p class="text-muted small mb-0">Members aligned with current goals and locations.</p> <p class="text-muted mb-0">Players aligned with your games and rank.</p>
</div> </div>
<div class="d-flex overflow-auto pb-2"> <div class="d-flex overflow-auto pb-2 gap-3">
<a href="{% url 'home' %}" class="filter-pill {% if not current_intent %}active{% endif %} text-decoration-none small">All Aligned</a> <a href="{% url 'home' %}" class="btn btn-sm {% if not current_intent %}btn-primary-cg{% else %}btn-outline-cg bg-white border-0{% endif %} rounded-pill px-4">All Aligned</a>
{% for intent in intents %} {% for intent in intents %}
<a href="?intent={{ intent.name }}" class="filter-pill {% if current_intent == intent.name %}active{% endif %} text-decoration-none small">{{ intent.name }}</a> <a href="?intent={{ intent.name }}" class="btn btn-sm {% if current_intent == intent.name %}btn-primary-cg{% else %}btn-outline-cg bg-white border-0{% endif %} rounded-pill px-4">{{ intent.name }}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="row g-4"> <div class="row g-5">
{% for profile in profiles %} {% for profile in profiles %}
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="profile-card p-4 h-100 d-flex flex-column shadow-sm border-0"> <div class="profile-card p-5 h-100 d-flex flex-column border-0 shadow-soft">
<div class="d-flex align-items-start mb-3"> <div class="d-flex align-items-start mb-4">
<img src="{{ profile.get_avatar_url }}" alt="{{ profile.user.get_full_name }}" class="profile-avatar me-3" style="width: 60px; height: 60px;"> <img src="{{ profile.get_avatar_url }}" alt="{{ profile.user.get_full_name }}" class="profile-avatar me-4" style="width: 64px; height: 64px; border-width: 0;">
<div class="pt-1"> <div class="pt-1">
<span class="transition-tag extra-small">{{ profile.get_transition_status_display }}</span> {% if profile.primary_game %}
<h3 class="h6 mb-1 fw-bold">{{ profile.user.first_name }} {{ profile.user.last_name|slice:":1" }}.</h3> <span class="intent-badge mb-2 d-inline-block">{{ profile.primary_game.name }}</span>
<p class="text-muted extra-small mb-0"><i class="bi bi-geo-alt me-1"></i> {{ profile.location_city }}</p> {% endif %}
<h3 class="h5 mb-1 fw-bold">{{ profile.user.first_name }} {{ profile.user.last_name|slice:":1" }}.</h3>
<p class="text-muted small mb-0"><i class="bi bi-geo-alt me-2"></i> {{ profile.location_city }}</p>
</div> </div>
</div> </div>
<p class="fw-bold small text-cg-slate mb-2">{{ profile.professional_headline }}</p> <p class="fw-bold text-slate mb-3">{{ profile.platform|upper }} • {{ profile.rank|default:"Casual Player" }}</p>
<p class="text-muted small mb-3 line-clamp-3">{{ profile.bio }}</p> <p class="text-muted small mb-4 line-clamp-3">{{ profile.bio }}</p>
<div class="mb-4 d-flex flex-wrap gap-2 mt-auto"> <div class="mb-5 d-flex flex-wrap gap-2 mt-auto">
{% for intent in profile.intents.all %} {% for intent in profile.intents.all %}
<span class="intent-badge extra-small">{{ intent.name }}</span> <span class="intent-badge">{{ intent.name }}</span>
{% endfor %} {% endfor %}
</div> </div>
<div class="d-grid"> <div class="d-grid mt-2">
<a href="{% url 'signup' %}" class="btn btn-outline-dark rounded-3 btn-sm py-2">Connect to View</a> <a href="{% url 'signup' %}" class="btn btn-outline-cg">Connect to View</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,134 @@
{% extends "base.html" %}
{% load static %}
{% load social_filters %}
{% block title %}Teammates | CommonGround{% endblock %}
{% block content %}
<div class="container-fluid dashboard-container px-lg-5">
<div class="row">
<!-- LEFT SIDEBAR -->
<div class="col-lg-2 d-none d-lg-block">
<div class="sticky-sidebar">
<nav class="sidebar-nav">
<a href="{% url 'home' %}" class="nav-link {% if request.resolver_match.url_name == 'home' %}active{% endif %}"><i class="bi bi-compass"></i> Teammates</a>
<a href="{% url 'matches' %}" class="nav-link {% if 'matches' in request.path %}active{% endif %}"><i class="bi bi-people"></i> My Teammates</a>
<a href="{% url 'inbox' %}" class="nav-link"><i class="bi bi-chat-dots"></i> Messages</a>
<a href="{% url 'events' %}" class="nav-link"><i class="bi bi-calendar-event"></i> Sessions</a>
<a href="{% url 'groups:hub' %}" class="nav-link"><i class="bi bi-grid"></i> Squads</a>
<a href="{% url 'my_posts' %}" class="nav-link"><i class="bi bi-journal-text"></i> My Posts</a>
<a href="{% url 'my_profile' %}" class="nav-link"><i class="bi bi-person"></i> Profile</a>
<a href="{% url 'settings' %}" class="nav-link"><i class="bi bi-gear"></i> Settings</a>
</nav>
</div>
</div>
<!-- CENTER COLUMN -->
<div class="col-lg-10 col-md-12">
<div class="mb-5 pt-2">
<h1 class="h2 mb-2">My Teammates</h1>
<p class="text-muted mb-0">Manage your gaming teammates and squads.</p>
</div>
<!-- Tab Navigation & Filters -->
<div class="card border-0 shadow-soft rounded-cg mb-5">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-4">
<div class="d-flex overflow-auto pb-1 gap-3">
<a href="{% url 'match_mutual' %}" class="btn btn-sm {% if current_tab == 'mutual' %}btn-primary-cg{% else %}btn-outline-cg border-0 bg-secondary-section{% endif %} rounded-pill px-4">Mutual</a>
<a href="{% url 'match_requests' %}" class="btn btn-sm {% if current_tab == 'requests' %}btn-primary-cg{% else %}btn-outline-cg border-0 bg-secondary-section{% endif %} rounded-pill px-4">
Requests
{% if stats.pending_connections > 0 %}
<span class="badge bg-white text-slate rounded-pill ms-2" style="font-size: 0.7rem;">{{ stats.pending_connections }}</span>
{% endif %}
</a>
<a href="{% url 'matches' %}?tab=sent" class="btn btn-sm {% if current_tab == 'sent' %}btn-primary-cg{% else %}btn-outline-cg border-0 bg-secondary-section{% endif %} rounded-pill px-4">Sent</a>
<a href="{% url 'match_liked' %}" class="btn btn-sm {% if current_tab == 'liked' %}btn-primary-cg{% else %}btn-outline-cg border-0 bg-secondary-section{% endif %} rounded-pill px-4">Liked</a>
</div>
<!-- Search & Filter -->
<form class="d-flex gap-3 flex-grow-1 flex-lg-grow-0" method="GET" style="min-width: 300px;">
<input type="hidden" name="tab" value="{{ current_tab }}">
<div class="input-group">
<input type="text" name="q" class="form-control border-0 bg-secondary-section rounded-start-cg px-4" placeholder="Search..." value="{{ search_query }}">
<button class="btn btn-outline-cg border-0 bg-secondary-section rounded-end-cg px-3" type="submit"><i class="bi bi-search text-muted"></i></button>
</div>
<select name="intent" class="form-select border-0 bg-secondary-section rounded-cg px-4" style="width: auto;" onchange="this.form.submit()">
<option value="">Intents</option>
{% for intent in intents %}
<option value="{{ intent.name }}" {% if current_intent == intent.name %}selected{% endif %}>{{ intent.name }}</option>
{% endfor %}
</select>
</form>
</div>
</div>
</div>
<!-- Match Grid -->
<div class="row g-5">
{% for match_user in matches_list %}
<div class="col-md-6 col-lg-4 col-xl-3">
<div class="profile-card p-5 border-0 shadow-soft d-flex flex-column h-100">
<div class="d-flex align-items-start mb-4">
<img src="{{ match_user.profile.get_avatar_url }}" alt="{{ match_user.get_full_name }}" class="rounded-circle me-3" style="width: 56px; height: 56px; object-fit: cover;">
<div class="pt-1 overflow-hidden">
{% if match_user.profile.primary_game %}
<span class="intent-badge mb-1 d-inline-block" style="font-size: 0.65rem;">{{ match_user.profile.primary_game.name }}</span>
{% endif %}
<h3 class="h6 mb-0 fw-bold text-truncate">{{ match_user.get_full_name|default:match_user.username }}</h3>
<p class="text-muted small mb-0 text-truncate"><i class="bi bi-geo-alt me-1"></i> {{ match_user.profile.location_city|default:"No Location" }}</p>
</div>
</div>
<p class="fw-bold small text-slate mb-2 text-truncate">{{ match_user.profile.platform|upper|default:"GAMER" }} • {{ match_user.profile.rank|default:"Casual" }}</p>
<p class="text-muted small mb-4 line-clamp-3" style="font-size: 0.8rem;">{{ match_user.profile.bio|default:"No bio yet." }}</p>
<div class="mb-5 d-flex flex-wrap gap-2 mt-auto">
{% for intent in match_user.profile.intents.all %}
<span class="intent-badge" style="font-size: 0.65rem; padding: 2px 10px;">{{ intent.name }}</span>
{% endfor %}
</div>
<div class="d-grid gap-3">
{% if current_tab == 'mutual' %}
<a href="{% url 'chat_detail' match_user.username %}" class="btn btn-primary-cg btn-sm">Message</a>
<div class="dropdown d-grid">
<button class="btn btn-outline-cg btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">Manage</button>
<ul class="dropdown-menu dropdown-menu-end shadow-soft border-0 rounded-cg p-2">
<li><a class="dropdown-item small rounded-cg py-2" href="{% url 'profile_detail' match_user.username %}">View Profile</a></li>
<li><hr class="dropdown-divider opacity-50"></li>
<li><a class="dropdown-item text-danger small rounded-cg py-2" href="{% url 'block_user' match_user.username %}" onclick="return confirm('Block this user?')">Block & Report</a></li>
</ul>
</div>
{% elif current_tab == 'requests' %}
{% with request_id=request_map|get_item:match_user.id %}
<div class="d-flex gap-3">
<a href="{% url 'handle_match_request' request_id %}?action=accept" class="btn btn-primary-cg btn-sm flex-grow-1">Accept</a>
<a href="{% url 'handle_match_request' request_id %}?action=decline" class="btn btn-outline-cg btn-sm flex-grow-1">Decline</a>
</div>
{% endwith %}
{% elif current_tab == 'sent' %}
<a href="{% url 'cancel_match_request' match_user.username %}" class="btn btn-outline-cg btn-sm border-0 bg-secondary-section">Cancel Request</a>
{% elif current_tab == 'liked' %}
<a href="{% url 'send_match_request' match_user.username %}" class="btn btn-primary-cg btn-sm">Send Request</a>
<a href="{% url 'toggle_like' match_user.username %}" class="btn btn-outline-cg btn-sm">Unlike</a>
{% elif current_tab == 'blocked' %}
<a href="#" class="btn btn-outline-cg btn-sm disabled opacity-50">Blocked</a>
{% endif %}
</div>
</div>
</div>
{% empty %}
<div class="col-12 text-center py-5 bg-white rounded-cg shadow-soft">
<i class="bi bi-people text-muted opacity-25 display-1 mb-4 d-block"></i>
<h4 class="fw-bold">No {{ current_tab }} found.</h4>
<p class="text-muted">Try adjusting your filters or search query.</p>
{% if current_tab == 'mutual' %}
<a href="{% url 'home' %}" class="btn btn-primary-cg mt-4">Find Teammates</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,33 +1,51 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Onboarding - CommonGround{% endblock %} {% block title %}Player Setup - CommonGround{% endblock %}
{% block content %} {% block content %}
<div class="container py-5"> <div class="container py-5">
<div class="row justify-content-center text-center mb-5"> <div class="row justify-content-center text-center mb-5">
<div class="col-lg-6"> <div class="col-lg-6">
<h1 class="brand-font display-5 mb-3">Welcome to CommonGround</h1> <h1 class="brand-font display-5 mb-3">Player Setup</h1>
<p class="text-muted">Let's set up your profile to help you find the right connections.</p> <p class="text-muted">Set up your profile to find your perfect squad.</p>
</div> </div>
</div> </div>
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-8 col-lg-6"> <div class="col-md-8 col-lg-6">
<div class="card border-0 shadow-sm p-4"> <div class="card border-0 shadow-soft p-4 rounded-cg">
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-bold">Professional Headline</label> <label class="form-label fw-bold">Gamer Tag</label>
<input type="text" name="headline" class="form-control" placeholder="e.g. Architect & Urban Planner" required> <input type="text" name="gamer_tag" class="form-control" placeholder="e.g. MasterGamer2026" required>
<div class="form-text">What do you do? (Keep it professional and clear)</div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-bold">Your Bio</label> <label class="form-label fw-bold">Primary Game</label>
<textarea name="bio" class="form-control" rows="4" placeholder="Tell us a bit about yourself and why you're here..." required></textarea> <select name="primary_game" class="form-select" required>
<option value="">Select a game</option>
{% for game in games %}
<option value="{{ game.id }}">{{ game.name }}</option>
{% endfor %}
</select>
</div> </div>
<button type="submit" class="btn btn-primary-cg w-100 py-2">Complete Profile</button> <div class="mb-4">
<label class="form-label fw-bold">Platform</label>
<select name="platform" class="form-select" required>
{% for code, name in platforms %}
<option value="{{ code }}">{{ name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="form-label fw-bold">About You</label>
<textarea name="bio" class="form-control" rows="4" placeholder="Tell us about your playstyle, availability, and vibe..." required></textarea>
</div>
<button type="submit" class="btn btn-primary-cg w-100 py-2">Start Matching</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}{{ title }} | CommonGround{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-8 text-center">
<div class="card border-0 shadow-sm rounded-4 p-5">
<div class="mb-4">
<i class="bi bi-rocket-takeoff text-primary-cg" style="font-size: 4rem;"></i>
</div>
<h1 class="fw-bold mb-3">{{ title }}</h1>
<p class="lead text-muted mb-4">We're currently building the {{ title }} feature to help you connect even better with your community. Stay tuned!</p>
<a href="{% url 'home' %}" class="btn btn-primary-cg btn-lg rounded-pill px-5">Back to Discover</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -4,171 +4,158 @@
{% block content %} {% block content %}
<div class="container py-5"> <div class="container py-5">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-8"> <div class="col-md-9">
<div class="card shadow-sm border-0 overflow-hidden"> <!-- Profile Header Card -->
<div class="bg-primary py-5 text-center position-relative"> <div class="card border-0 shadow-soft rounded-cg overflow-hidden mb-5">
<div class="bg-secondary-section py-5 text-center position-relative" style="height: 160px;">
<div class="position-absolute top-100 start-50 translate-middle"> <div class="position-absolute top-100 start-50 translate-middle">
<img src="{{ target_user.profile.get_avatar_url }}" <img src="{{ target_user.profile.get_avatar_url }}"
class="rounded-circle border border-4 border-white shadow" class="rounded-circle border border-4 border-white shadow-soft"
style="width: 150px; height: 150px; object-fit: cover;" style="width: 160px; height: 160px; object-fit: cover;"
alt="{{ target_user.username }}"> alt="{{ target_user.username }}">
</div> </div>
</div> </div>
<div class="card-body pt-5 mt-4 text-center"> <div class="card-body pt-5 mt-5 text-center px-lg-5">
<h2 class="fw-bold mb-0">{{ target_user.first_name }} {{ target_user.last_name }}</h2> <h1 class="mb-1">{{ target_user.first_name }} {{ target_user.last_name }}</h1>
<p class="text-muted mb-3">@{{ target_user.username }}</p> <p class="text-muted mb-4 small">@{{ target_user.username }}</p>
{% if target_user.profile.professional_headline %} {% if target_user.profile.professional_headline %}
<h5 class="text-primary mb-3">{{ target_user.profile.professional_headline }}</h5> <h5 class="text-slate mb-4 opacity-75">{{ target_user.profile.professional_headline }}</h5>
{% endif %} {% endif %}
<div class="d-flex justify-content-center gap-2 mb-4"> <div class="d-flex justify-content-center gap-3 mb-5">
<span class="badge bg-light text-dark border"> <span class="intent-badge bg-secondary-section border-0 px-3">
<i class="bi bi-geo-alt me-1"></i> {{ target_user.profile.location_city|default:"Location not set" }} <i class="bi bi-geo-alt me-2"></i> {{ target_user.profile.location_city|default:"Location not set" }}
</span> </span>
<span class="badge bg-info-subtle text-info border border-info-subtle"> {% if target_user.profile.primary_game %}
{{ target_user.profile.get_transition_status_display }} <span class="intent-badge px-3">
<i class="bi bi-controller me-2"></i> {{ target_user.profile.primary_game.name }}
</span> </span>
{% endif %}
{% if target_user.profile.platform %}
<span class="intent-badge px-3">
<i class="bi bi-display me-2"></i> {{ target_user.profile.get_platform_display }}
</span>
{% endif %}
</div> </div>
<div class="px-md-5 mb-4 text-secondary"> <div class="px-md-5 mb-5 text-slate opacity-75 lead" style="font-size: 1.1rem; line-height: 1.8;">
{{ target_user.profile.bio|linebreaks }} {{ target_user.profile.bio|linebreaks }}
</div> </div>
{% if request.user == target_user %} {% if request.user == target_user %}
<div class="d-grid gap-2 d-md-flex justify-content-md-center"> <div class="d-flex justify-content-center gap-3 mb-4">
<a href="{% url 'edit_profile' %}" class="btn btn-primary px-4"> <a href="{% url 'edit_profile' %}" class="btn btn-primary-cg px-5">
<i class="bi bi-pencil me-2"></i>Edit Profile <i class="bi bi-pencil me-2"></i>Edit Profile
</a> </a>
<a href="{% url 'settings' %}" class="btn btn-outline-secondary px-4"> <a href="{% url 'settings' %}" class="btn btn-outline-cg px-5">
<i class="bi bi-gear me-2"></i>Account Settings <i class="bi bi-gear me-2"></i>Settings
</a> </a>
</div> </div>
{% else %} {% else %}
<div class="d-grid gap-2 d-md-flex justify-content-md-center"> <div class="d-flex justify-content-center gap-3 mb-4">
{% if is_following %} {% if is_following %}
<a href="{% url 'toggle_follow' target_user.username %}" class="btn btn-outline-primary px-4"> <a href="{% url 'toggle_follow' target_user.username %}" class="btn btn-outline-cg px-5">
<i class="bi bi-person-check-fill me-2"></i>Following <i class="bi bi-person-check-fill me-2"></i>Following
</a> </a>
{% else %} {% else %}
<a href="{% url 'toggle_follow' target_user.username %}" class="btn btn-primary px-4"> <a href="{% url 'toggle_follow' target_user.username %}" class="btn btn-primary-cg px-5">
<i class="bi bi-person-plus me-2"></i>Follow <i class="bi bi-person-plus me-2"></i>Follow
</a> </a>
{% endif %} {% endif %}
<a href="{% url 'chat_detail' target_user.username %}" class="btn btn-outline-secondary px-4"> <a href="{% url 'chat_detail' target_user.username %}" class="btn btn-outline-cg px-5">
<i class="bi bi-chat-dots me-2"></i>Message <i class="bi bi-chat-dots me-2"></i>Message
</a> </a>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="card-footer bg-white border-top-0 py-4 px-md-5"> <div class="card-footer bg-white border-top-0 py-5 px-lg-5 border-top">
<div class="row text-center"> <div class="row g-4 text-center">
<div class="col border-end"> <div class="col-md-4 border-end border-light">
<h6 class="text-muted text-uppercase small fw-bold">Intents</h6> <h6 class="text-muted text-uppercase extra-small fw-bold mb-3 ls-1">Looking For</h6>
<div class="mt-2"> <div class="d-flex flex-wrap justify-content-center gap-2">
{% for intent in target_user.profile.intents.all %} {% for intent in target_user.profile.intents.all %}
<span class="badge bg-secondary-subtle text-secondary rounded-pill px-3 mb-1 extra-small"> <span class="intent-badge border-0" style="background: var(--cg-sand-primary);">
<i class="bi {{ intent.icon }} me-1"></i>{{ intent.name }} {{ intent.name }}
</span> </span>
{% empty %} {% empty %}
<small class="text-muted extra-small">No intents shared</small> <small class="text-muted small">No intents shared</small>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="col border-end"> <div class="col-md-4 border-end border-light">
<h6 class="text-muted text-uppercase small fw-bold">Values</h6> <h6 class="text-muted text-uppercase extra-small fw-bold mb-3 ls-1">Values</h6>
<div class="mt-2"> <div class="d-flex flex-wrap justify-content-center gap-2">
{% for tag in target_user.profile.value_tags.all %} {% for tag in target_user.profile.value_tags.all %}
<span class="badge bg-dark-subtle text-dark rounded-pill px-3 mb-1 extra-small"> <span class="intent-badge border-0" style="background: var(--cg-sand-secondary);">
#{{ tag.name }} #{{ tag.name }}
</span> </span>
{% empty %} {% empty %}
<small class="text-muted extra-small">No values shared</small> <small class="text-muted small">No values shared</small>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="col"> <div class="col-md-4">
<h6 class="text-muted text-uppercase small fw-bold">Momentum</h6> <h6 class="text-muted text-uppercase extra-small fw-bold mb-3 ls-1">Gamer Stats</h6>
<div class="mt-2"> <div class="d-flex flex-wrap justify-content-center gap-2">
<span class="badge bg-success-subtle text-success rounded-pill px-3 mb-1 extra-small"> {% if target_user.profile.rank %}
<span class="intent-badge border-0" style="background: var(--cg-sand-primary);">
Rank: {{ target_user.profile.rank }}
</span>
{% endif %}
{% if target_user.profile.preferred_role %}
<span class="intent-badge border-0" style="background: var(--cg-sand-secondary);">
Role: {{ target_user.profile.preferred_role }}
</span>
{% endif %}
<span class="intent-badge border-0 text-sage" style="background: var(--cg-sand-primary);">
{{ target_user.profile.accountability_streak }} 🔥 Streak {{ target_user.profile.accountability_streak }} 🔥 Streak
</span> </span>
<span class="badge bg-info-subtle text-info rounded-pill px-3 mb-1 extra-small">
{{ target_user.profile.followers_count }} Followers
</span>
<span class="badge bg-primary-subtle text-primary rounded-pill px-3 mb-1 extra-small">
{{ target_user.profile.following_count }} Following
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="row justify-content-center mt-4">
<div class="col-md-8">
<h3 class="h5 mb-4 px-2 fw-bold">Recent Activity</h3>
{% for post in target_user.posts.all %}
<div class="card border-0 shadow-sm mb-4 rounded-4 overflow-hidden">
<div class="card-body p-4">
<span class="post-badge badge-{{ post.post_type }}">{{ post.get_post_type_display }}</span>
<div class="d-flex justify-content-between align-items-start mb-3">
<div class="d-flex align-items-center">
<img src="{{ post.author.profile.get_avatar_url }}" class="rounded-circle me-3" width="48" height="48" style="object-fit: cover;">
<div>
<h4 class="h6 mb-0 fw-bold">{{ post.author.get_full_name|default:post.author.username }}</h4>
<p class="text-muted extra-small mb-0">{{ post.timestamp|timesince }} ago</p>
</div>
</div>
{% if post.author == user %}
<div class="dropdown">
<button class="btn btn-link text-muted p-0" data-bs-toggle="dropdown">
<i class="bi bi-three-dots"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li><a class="dropdown-item text-danger small" href="{% url 'delete_post' post.id %}"><i class="bi bi-trash me-2"></i> Delete post</a></li>
</ul>
</div>
{% endif %}
</div>
<p class="mb-3">{{ post.content }}</p>
{% if post.image %}
<img src="{{ post.image.url }}" class="img-fluid rounded-3 mb-3 w-100" style="max-height: 400px; object-fit: cover;">
{% endif %}
<div class="d-flex gap-4 border-top pt-3 mt-3"> <!-- Activity Feed -->
<a href="{% url 'toggle_reaction' post.id %}?type=heart" class="text-decoration-none {% if post|reacted_by:user %}text-danger{% else %}text-muted{% endif %} small fw-bold"> <div class="feed-section">
<i class="bi bi-heart{% if post|reacted_by:user %}-fill text-danger{% endif %} me-1"></i> {{ post.reactions.count }} <h3 class="mb-4">Recent Activity</h3>
</a> {% for post in target_user.posts.all %}
<button class="btn btn-link p-0 text-muted text-decoration-none small fw-bold" type="button" data-bs-toggle="collapse" data-bs-target="#comments-{{ post.id }}"> <div class="card border-0 shadow-soft mb-5 rounded-cg">
<i class="bi bi-chat me-1"></i> {{ post.comments.count }} <div class="card-body p-4">
</button> <div class="d-flex justify-content-between align-items-center mb-4">
</div> <div class="d-flex align-items-center">
<img src="{{ post.author.profile.get_avatar_url }}" class="rounded-circle me-3" width="44" height="44" style="object-fit: cover;">
<div class="collapse mt-3" id="comments-{{ post.id }}"> <div>
<div class="bg-light p-3 rounded-3"> <h4 class="h6 mb-0 fw-bold">{{ post.author.get_full_name|default:post.author.username }}</h4>
{% for comment in post.comments.all %} <p class="text-muted extra-small mb-0">{{ post.timestamp|timesince }} ago</p>
<div class="d-flex gap-2 mb-3">
<img src="{{ comment.author.profile.get_avatar_url }}" class="rounded-circle" width="32" height="32" style="object-fit: cover;">
<div class="flex-grow-1">
<div class="bg-white p-2 rounded-3 shadow-sm">
<p class="small fw-bold mb-1">{{ comment.author.username }}</p>
<p class="small mb-0">{{ comment.content }}</p>
</div>
</div> </div>
</div> </div>
{% endfor %} <span class="post-badge">{{ post.get_post_type_display }}</span>
</div>
<p class="mb-4">{{ post.content }}</p>
{% if post.image %}
<img src="{{ post.image.url }}" class="img-fluid rounded-cg mb-4 w-100" style="max-height: 400px; object-fit: cover;">
{% endif %}
<div class="d-flex gap-4 border-top pt-4">
<a href="{% url 'toggle_reaction' post.id %}?type=heart" class="text-decoration-none {% if post|reacted_by:user %}text-danger{% else %}text-muted{% endif %} small fw-bold">
<i class="bi bi-heart{% if post|reacted_by:user %}-fill{% endif %} me-2"></i> {{ post.reactions.count }}
</a>
<button class="btn btn-link p-0 text-muted text-decoration-none small fw-bold" type="button" data-bs-toggle="collapse" data-bs-target="#comments-{{ post.id }}">
<i class="bi bi-chat me-2"></i> {{ post.comments.count }}
</button>
</div> </div>
</div> </div>
</div> </div>
</div> {% empty %}
{% empty %} <div class="text-center py-5 bg-white rounded-cg shadow-soft">
<div class="text-center py-5 bg-white rounded-3 shadow-sm"> <p class="text-muted mb-0">No posts yet.</p>
<p class="text-muted mb-0">No posts yet.</p> </div>
</div> {% endfor %}
{% endfor %} </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -21,7 +21,7 @@
<button type="submit" class="btn btn-primary-cg w-100">Log In</button> <button type="submit" class="btn btn-primary-cg w-100">Log In</button>
</form> </form>
<div class="text-center mt-3"> <div class="text-center mt-3">
<p class="small text-muted">Don't have an account? <a href="{% url 'signup' %}">Sign up</a></p> <p class="small text-muted">Don't have an account? <a href="{% url 'signup' %}">Get Started</a></p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,13 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sign Up - CommonGround{% endblock %} {% block title %}Player Setup - CommonGround{% endblock %}
{% block content %} {% block content %}
<div class="container py-5"> <div class="container py-5">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-md-6 col-lg-5"> <div class="col-md-6 col-lg-5">
<div class="card border-0 shadow-sm p-4"> <div class="card border-0 shadow-sm p-4">
<h2 class="brand-font text-center mb-4">Create your account</h2> <h2 class="brand-font text-center mb-4">Player Setup</h2>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% for field in form %} {% for field in form %}

View File

@ -7,3 +7,7 @@ def reacted_by(post, user):
if user.is_authenticated: if user.is_authenticated:
return post.reactions.filter(user=user).exists() return post.reactions.filter(user=user).exists()
return False return False
@register.filter(name='get_item')
def get_item(dictionary, key):
return dictionary.get(key)

View File

@ -4,7 +4,10 @@ from .views import (
home, about, signup, onboarding, settings_view, home, about, signup, onboarding, settings_view,
get_started, inbox, chat_detail, get_started, inbox, chat_detail,
profile_view, profile_detail, edit_profile, profile_view, profile_detail, edit_profile,
create_post, delete_post, add_comment, toggle_reaction, hide_post, toggle_follow create_post, delete_post, add_comment, toggle_reaction, hide_post, toggle_follow,
matches, events, groups, my_posts,
send_match_request, handle_match_request, cancel_match_request, block_user, toggle_like,
event_detail, event_create, event_edit, event_delete, event_rsvp
) )
urlpatterns = [ urlpatterns = [
@ -21,6 +24,27 @@ urlpatterns = [
path("profile/<str:username>/", profile_detail, name="profile_detail"), path("profile/<str:username>/", profile_detail, name="profile_detail"),
path("profile/<str:username>/follow/", toggle_follow, name="toggle_follow"), path("profile/<str:username>/follow/", toggle_follow, name="toggle_follow"),
# Matches
path("matches/", matches, name="matches"),
path("matches/requests/", matches, {'tab': 'requests'}, name="match_requests"),
path("matches/mutual/", matches, {'tab': 'mutual'}, name="match_mutual"),
path("matches/liked/", matches, {'tab': 'liked'}, name="match_liked"),
path("matches/blocked/", matches, {'tab': 'blocked'}, name="match_blocked"),
path("matches/request/<str:username>/", send_match_request, name="send_match_request"),
path("matches/handle/<int:request_id>/", handle_match_request, name="handle_match_request"),
path("matches/cancel/<str:username>/", cancel_match_request, name="cancel_match_request"),
path("matches/block/<str:username>/", block_user, name="block_user"),
path("matches/like/<str:username>/", toggle_like, name="toggle_like"),
path("events/", events, name="events"),
path("events/create/", event_create, name="event_create"),
path("events/<int:event_id>/", event_detail, name="event_detail"),
path("events/<int:event_id>/edit/", event_edit, name="event_edit"),
path("events/<int:event_id>/delete/", event_delete, name="event_delete"),
path("events/<int:event_id>/rsvp/", event_rsvp, name="event_rsvp"),
path("groups/", groups, name="groups"),
path("my-posts/", my_posts, name="my_posts"),
# Social URLs # Social URLs
path("post/create/", create_post, name="create_post"), path("post/create/", create_post, name="create_post"),
path("post/<int:post_id>/delete/", delete_post, name="delete_post"), path("post/<int:post_id>/delete/", delete_post, name="delete_post"),

View File

@ -3,13 +3,59 @@ import platform
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.utils import timezone from django.utils import timezone
from .models import Profile, Intent, ValueTag, Message, Post, Comment, Reaction, HiddenPost, Follow from .models import (
Profile, Intent, ValueTag, Message, Post, Comment, Reaction,
HiddenPost, Follow, ConnectionRequest, Like, Match, Thread, Block,
Event, RSVP, EventTag, EventInvite
)
from .forms import EventForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth import login from django.contrib.auth import login
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Q
def get_dashboard_context(request):
"""Helper to get consistent context for dashboard-like views."""
stats = {}
suggested_members = []
suggested_events = []
following_ids = []
if request.user.is_authenticated:
# Quick Stats
stats = {
'unread_messages': Message.objects.filter(recipient=request.user, is_read=False).count(),
'pending_connections': ConnectionRequest.objects.filter(to_user=request.user, status='pending').count(),
'upcoming_events_count': request.user.rsvps.filter(status='going', event__start_datetime__gt=timezone.now()).count(),
'completion_percentage': request.user.profile.profile_completion_percentage,
'streak': request.user.profile.accountability_streak,
'followers_count': request.user.profile.followers_count,
'following_count': request.user.profile.following_count,
}
following_ids = list(Follow.objects.filter(follower=request.user).values_list('followed_id', flat=True))
# Suggestions
user_intents = request.user.profile.intents.all()
user_values = request.user.profile.value_tags.all()
suggested_members = Profile.objects.filter(
Q(intents__in=user_intents) | Q(value_tags__in=user_values)
).exclude(user=request.user).distinct()[:10]
# Suggested Events
suggested_events = Event.objects.filter(start_datetime__gt=timezone.now()).order_by('start_datetime')[:5]
return {
"stats": stats,
"suggested_members": suggested_members,
"suggested_events": suggested_events,
"following_ids": following_ids,
"post_types": Post.POST_TYPE_CHOICES,
"current_time": timezone.now(),
"project_name": "CommonGround",
}
def home(request): def home(request):
"""Render the landing screen or member dashboard.""" """Render the landing screen or member dashboard."""
@ -62,49 +108,13 @@ def home(request):
intents = Intent.objects.all() intents = Intent.objects.all()
# Dashboard logic for logged-in users context = get_dashboard_context(request)
stats = {} context.update({
suggested_members = []
suggested_events = []
following_ids = []
if request.user.is_authenticated:
# Quick Stats
stats = {
'unread_messages': Message.objects.filter(recipient=request.user, is_read=False).count(),
'pending_connections': 0, # Placeholder until connection request system exists
'upcoming_events_count': request.user.attending_events.filter(start_time__gt=timezone.now()).count(),
'completion_percentage': request.user.profile.profile_completion_percentage,
'streak': request.user.profile.accountability_streak,
'followers_count': request.user.profile.followers_count,
'following_count': request.user.profile.following_count,
}
following_ids = list(Follow.objects.filter(follower=request.user).values_list('followed_id', flat=True))
# Suggestions: Aligned members (shared intents or values)
user_intents = request.user.profile.intents.all()
user_values = request.user.profile.value_tags.all()
suggested_members = Profile.objects.filter(
Q(intents__in=user_intents) | Q(value_tags__in=user_values)
).exclude(user=request.user).distinct()[:10]
# Suggested Events: Upcoming events
from .models import Event
suggested_events = Event.objects.filter(start_time__gt=timezone.now()).order_by('start_time')[:5]
context = {
"project_name": "CommonGround",
"profiles": profiles, "profiles": profiles,
"intents": intents, "intents": intents,
"current_intent": intent_filter, "current_intent": intent_filter,
"current_time": timezone.now(),
"posts": posts, "posts": posts,
"stats": stats, })
"suggested_members": suggested_members,
"suggested_events": suggested_events,
"post_types": Post.POST_TYPE_CHOICES,
"following_ids": following_ids,
}
return render(request, "core/index.html", context) return render(request, "core/index.html", context)
@login_required @login_required
@ -170,7 +180,7 @@ def signup(request):
if form.is_valid(): if form.is_valid():
user = form.save() user = form.save()
# Create profile # Create profile
Profile.objects.create(user=user) Profile.objects.get_or_create(user=user)
login(request, user) login(request, user)
return redirect('onboarding') return redirect('onboarding')
else: else:
@ -179,15 +189,23 @@ def signup(request):
@login_required @login_required
def onboarding(request): def onboarding(request):
# Simplified onboarding for MVP
profile = request.user.profile profile = request.user.profile
if request.method == 'POST': if request.method == 'POST':
profile.professional_headline = request.POST.get('headline', '') profile.gamer_tag = request.POST.get('gamer_tag', '')
profile.platform = request.POST.get('platform', 'pc')
profile.primary_game_id = request.POST.get('primary_game')
profile.bio = request.POST.get('bio', '') profile.bio = request.POST.get('bio', '')
profile.onboarding_completed = True profile.onboarding_completed = True
profile.save() profile.save()
return redirect('home') return redirect('home')
return render(request, 'core/onboarding.html', {'profile': profile})
from .models import Game
games = Game.objects.all().order_by('name')
return render(request, 'core/onboarding.html', {
'profile': profile,
'games': games,
'platforms': profile.PLATFORM_CHOICES
})
@login_required @login_required
def settings_view(request): def settings_view(request):
@ -195,7 +213,6 @@ def settings_view(request):
if request.method == 'POST': if request.method == 'POST':
profile.two_factor_enabled = 'two_factor' in request.POST profile.two_factor_enabled = 'two_factor' in request.POST
profile.save() profile.save()
# In a real app we'd save more settings here
return redirect('settings') return redirect('settings')
return render(request, 'core/settings.html', {'profile': profile}) return render(request, 'core/settings.html', {'profile': profile})
@ -215,7 +232,6 @@ def inbox(request):
partner_ids = set(list(sent_to) + list(received_from)) partner_ids = set(list(sent_to) + list(received_from))
partners = User.objects.filter(id__in=partner_ids).select_related('profile') partners = User.objects.filter(id__in=partner_ids).select_related('profile')
# Add last message to each partner for display
for partner in partners: for partner in partners:
last_message = Message.objects.filter( last_message = Message.objects.filter(
Q(sender=request.user, recipient=partner) | Q(sender=request.user, recipient=partner) |
@ -231,32 +247,33 @@ def chat_detail(request, username):
if partner == request.user: if partner == request.user:
return redirect('inbox') return redirect('inbox')
thread = Thread.objects.filter(participants=request.user).filter(participants=partner).first()
if not thread:
thread = Thread.objects.create()
thread.participants.add(request.user, partner)
if request.method == 'POST': if request.method == 'POST':
body = request.POST.get('body') body = request.POST.get('body')
if body: if body:
Message.objects.create(sender=request.user, recipient=partner, body=body) Message.objects.create(sender=request.user, recipient=partner, body=body, thread=thread)
thread.updated_at = timezone.now()
thread.save()
return redirect('chat_detail', username=username) return redirect('chat_detail', username=username)
messages = Message.objects.filter( messages = Message.objects.filter(thread=thread).order_by('timestamp')
Q(sender=request.user, recipient=partner) |
Q(sender=partner, recipient=request.user)
).order_by('timestamp')
# Mark as read
messages.filter(recipient=request.user, is_read=False).update(is_read=True) messages.filter(recipient=request.user, is_read=False).update(is_read=True)
return render(request, 'core/chat.html', { return render(request, 'core/chat.html', {
'partner': partner, 'partner': partner,
'chat_messages': messages 'chat_messages': messages,
'thread': thread
}) })
@login_required @login_required
def profile_view(request): def profile_view(request):
"""Redirect to the current user's profile detail page."""
return redirect('profile_detail', username=request.user.username) return redirect('profile_detail', username=request.user.username)
def profile_detail(request, username): def profile_detail(request, username):
"""View a user's profile."""
target_user = get_object_or_404(User, username=username) target_user = get_object_or_404(User, username=username)
is_following = False is_following = False
if request.user.is_authenticated: if request.user.is_authenticated:
@ -268,17 +285,296 @@ def profile_detail(request, username):
@login_required @login_required
def edit_profile(request): def edit_profile(request):
"""Edit the current user's profile."""
profile = request.user.profile profile = request.user.profile
if request.method == 'POST': if request.method == 'POST':
profile.professional_headline = request.POST.get('headline', '') profile.professional_headline = request.POST.get('headline', '')
profile.bio = request.POST.get('bio', '') profile.bio = request.POST.get('bio', '')
profile.location_city = request.POST.get('location', '') profile.location_city = request.POST.get('location', '')
profile.platform = request.POST.get('platform', '')
profile.gamer_tag = request.POST.get('gamer_tag', '')
profile.rank = request.POST.get('rank', '')
profile.preferred_role = request.POST.get('preferred_role', '')
primary_game_id = request.POST.get('primary_game')
if primary_game_id:
profile.primary_game_id = primary_game_id
if 'avatar' in request.FILES: if 'avatar' in request.FILES:
profile.avatar = request.FILES['avatar'] profile.avatar = request.FILES['avatar']
profile.save() profile.save()
return redirect('my_profile') return redirect('my_profile')
return render(request, 'core/edit_profile.html', {'profile': profile}) from .models import Game
games = Game.objects.all().order_by('name')
return render(request, 'core/edit_profile.html', {
'profile': profile,
'games': games,
'platforms': profile.PLATFORM_CHOICES
})
@login_required
def matches(request, tab=None):
"""Main hub for 'My Matches'."""
if not tab:
tab = request.GET.get('tab', 'mutual')
search_query = request.GET.get('q', '')
intent_filter = request.GET.get('intent', '')
sort_by = request.GET.get('sort', 'newest')
user = request.user
if tab == 'requests':
queryset = User.objects.filter(requests_sent__to_user=user, requests_sent__status='pending')
elif tab == 'sent':
queryset = User.objects.filter(requests_received__from_user=user, requests_received__status='pending')
elif tab == 'liked':
queryset = User.objects.filter(likes_received__from_user=user).exclude(
Q(matches_a__user_b=user) | Q(matches_b__user_a=user)
)
elif tab == 'blocked':
queryset = User.objects.filter(blocks_received__blocker=user)
else: # mutual
queryset = User.objects.filter(
Q(matches_a__user_b=user, matches_a__status='active') |
Q(matches_b__user_a=user, matches_b__status='active')
)
if search_query:
queryset = queryset.filter(
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) |
Q(username__icontains=search_query) |
Q(profile__professional_headline__icontains=search_query) |
Q(profile__location_city__icontains=search_query)
)
if intent_filter:
queryset = queryset.filter(profile__intents__name=intent_filter)
if sort_by == 'newest':
queryset = queryset.order_by('-date_joined')
elif sort_by == 'aligned':
queryset = queryset.order_by('-profile__accountability_streak')
queryset = queryset.select_related('profile').prefetch_related('profile__intents').distinct()
context = get_dashboard_context(request)
incoming_requests = ConnectionRequest.objects.filter(to_user=user, status='pending')
request_map = {r.from_user_id: r.id for r in incoming_requests}
context.update({
'matches_list': queryset,
'current_tab': tab,
'search_query': search_query,
'current_intent': intent_filter,
'current_sort': sort_by,
'intents': Intent.objects.all(),
'title': 'My Matches',
'request_map': request_map
})
return render(request, 'core/matches.html', context)
@login_required
def send_match_request(request, username):
target_user = get_object_or_404(User, username=username)
if target_user == request.user:
return redirect('home')
if Like.objects.filter(from_user=target_user, to_user=request.user).exists():
Match.objects.get_or_create(
user_a=min(request.user, target_user, key=lambda u: u.id),
user_b=max(request.user, target_user, key=lambda u: u.id)
)
return redirect('matches')
ConnectionRequest.objects.get_or_create(from_user=request.user, to_user=target_user, status='pending')
return redirect(request.META.get('HTTP_REFERER', 'home'))
@login_required
def handle_match_request(request, request_id):
conn_request = get_object_or_404(ConnectionRequest, id=request_id, to_user=request.user)
action = request.GET.get('action')
if action == 'accept':
conn_request.status = 'accepted'
conn_request.responded_at = timezone.now()
conn_request.save()
Match.objects.get_or_create(
user_a=min(conn_request.from_user, conn_request.to_user, key=lambda u: u.id),
user_b=max(conn_request.from_user, conn_request.to_user, key=lambda u: u.id)
)
elif action == 'decline':
conn_request.status = 'declined'
conn_request.responded_at = timezone.now()
conn_request.save()
return redirect('matches')
@login_required
def cancel_match_request(request, username):
target_user = get_object_or_404(User, username=username)
ConnectionRequest.objects.filter(from_user=request.user, to_user=target_user, status='pending').delete()
return redirect('matches')
@login_required
def block_user(request, username):
target_user = get_object_or_404(User, username=username)
Block.objects.get_or_create(blocker=request.user, blocked=target_user)
Match.objects.filter(
Q(user_a=request.user, user_b=target_user) |
Q(user_a=target_user, user_b=request.user)
).delete()
ConnectionRequest.objects.filter(
Q(from_user=request.user, to_user=target_user) |
Q(from_user=target_user, to_user=request.user)
).delete()
return redirect('matches')
@login_required
def toggle_like(request, username):
target_user = get_object_or_404(User, username=username)
if target_user == request.user:
return redirect('home')
like, created = Like.objects.get_or_create(from_user=request.user, to_user=target_user)
if not created:
like.delete()
else:
if Like.objects.filter(from_user=target_user, to_user=request.user).exists():
Match.objects.get_or_create(
user_a=min(request.user, target_user, key=lambda u: u.id),
user_b=max(request.user, target_user, key=lambda u: u.id)
)
return redirect(request.META.get('HTTP_REFERER', 'home'))
@login_required
def groups(request):
return render(request, 'core/placeholder.html', {'title': 'Groups'})
@login_required
def my_posts(request):
posts = Post.objects.filter(author=request.user).select_related('author', 'author__profile').prefetch_related('comments', 'reactions')
context = get_dashboard_context(request)
context.update({
'posts': posts,
'title': 'My Posts'
})
return render(request, 'core/index.html', context)
@login_required
def events(request):
tab = request.GET.get('tab', 'upcoming')
query = request.GET.get('q', '')
events = Event.objects.all().order_by('start_datetime')
if query:
events = events.filter(
Q(title__icontains=query) | Q(description__icontains=query) | Q(location_name__icontains=query)
)
if tab == 'mine':
events = events.filter(creator=request.user)
elif tab == 'attending':
events = events.filter(rsvps__user=request.user, rsvps__status='going')
elif tab == 'past':
events = Event.objects.filter(start_datetime__lt=timezone.now()).order_by('-start_datetime')
elif tab == 'calendar':
# Simple grouping by date for calendar view
events = events.filter(start_datetime__gt=timezone.now())
else: # upcoming
events = events.filter(start_datetime__gt=timezone.now())
context = get_dashboard_context(request)
# For calendar view, group events by date
calendar_events = {}
if tab == 'calendar':
for event in events:
date_key = event.start_datetime.date()
if date_key not in calendar_events:
calendar_events[date_key] = []
calendar_events[date_key].append(event)
context.update({
'events': events,
'calendar_events': calendar_events,
'tab': tab,
'query': query,
})
return render(request, 'core/events_list.html', context)
@login_required
def event_detail(request, event_id):
event = get_object_or_404(Event, id=event_id)
user_rsvp = RSVP.objects.filter(event=event, user=request.user).first()
rsvps = event.rsvps.select_related('user', 'user__profile')
context = get_dashboard_context(request)
context.update({
'event': event,
'user_rsvp': user_rsvp,
'rsvps': rsvps,
'going_count': rsvps.filter(status='going').count(),
'maybe_count': rsvps.filter(status='maybe').count(),
})
return render(request, 'core/event_detail.html', context)
@login_required
def event_create(request):
if request.method == 'POST':
form = EventForm(request.POST, request.FILES)
if form.is_valid():
event = form.save(commit=False)
event.creator = request.user
event.save()
form.save_m2m()
return redirect('event_detail', event_id=event.id)
else:
form = EventForm()
context = get_dashboard_context(request)
context.update({'form': form, 'title': 'Create Session'})
return render(request, 'core/event_form.html', context)
@login_required
def event_edit(request, event_id):
event = get_object_or_404(Event, id=event_id, creator=request.user)
if request.method == 'POST':
form = EventForm(request.POST, request.FILES, instance=event)
if form.is_valid():
form.save()
return redirect('event_detail', event_id=event.id)
else:
form = EventForm(instance=event)
context = get_dashboard_context(request)
context.update({'form': form, 'title': 'Edit Session', 'event': event})
return render(request, 'core/event_form.html', context)
@login_required
def event_delete(request, event_id):
event = get_object_or_404(Event, id=event_id, creator=request.user)
if request.method == 'POST':
event.delete()
return redirect('events')
return render(request, 'core/event_confirm_delete.html', {'event': event})
@login_required
def event_rsvp(request, event_id):
if request.method == 'POST':
event = get_object_or_404(Event, id=event_id)
status = request.POST.get('status')
if status in ['going', 'maybe', 'not_going']:
rsvp, created = RSVP.objects.update_or_create(
event=event, user=request.user,
defaults={'status': status}
)
elif status == 'cancel':
RSVP.objects.filter(event=event, user=request.user).delete()
return redirect(request.META.get('HTTP_REFERER', 'event_detail', event_id=event.id))
return redirect('events')

0
groups/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

3
groups/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
groups/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class GroupsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'groups'

View File

@ -0,0 +1,51 @@
# Generated by Django 5.2.7 on 2026-02-17 18:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Group',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('description', models.TextField()),
('group_type', models.CharField(choices=[('circle', 'Circle (412 members)'), ('community', 'Community (Open)'), ('mastermind', 'Mastermind (Structured Recurring)'), ('event_based', 'Event Based'), ('private_invite', 'Private Invite')], max_length=50)),
('intent_type', models.CharField(choices=[('friendship', 'Friendship'), ('networking', 'Networking'), ('activity', 'Activity'), ('accountability', 'Accountability')], max_length=50)),
('transition_focus', models.CharField(blank=True, max_length=100, null=True)),
('visibility', models.CharField(choices=[('public_members', 'Public (Members Only)'), ('private_request', 'Private (Request to Join)'), ('invite_only', 'Invite Only')], default='public_members', max_length=50)),
('capacity', models.PositiveIntegerField(blank=True, null=True)),
('location_scope', models.CharField(choices=[('local', 'Local'), ('virtual', 'Virtual')], default='local', max_length=20)),
('city', models.CharField(blank=True, max_length=100, null=True)),
('state', models.CharField(blank=True, max_length=100, null=True)),
('recurring_schedule', models.TextField(blank=True, help_text='Required for Mastermind groups. Use structured text or JSON.', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('cover_image', models.ImageField(blank=True, null=True, upload_to='groups/covers/')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_groups_v2', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='GroupMember',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('member', 'Member'), ('moderator', 'Moderator'), ('owner', 'Owner')], default='member', max_length=20)),
('status', models.CharField(choices=[('active', 'Active'), ('pending', 'Pending')], default='active', max_length=20)),
('joined_at', models.DateTimeField(auto_now_add=True)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='groups.group')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('group', 'user')},
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-02-17 18:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('groups', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='group',
name='group_type',
field=models.CharField(choices=[('squad', 'Squad (412 members)'), ('community', 'Community (Open)'), ('mastermind', 'Pro Squad (Structured)'), ('tournament', 'Tournament Team'), ('private', 'Private Invite')], max_length=50),
),
migrations.AlterField(
model_name='group',
name='intent_type',
field=models.CharField(choices=[('ranked', 'Ranked Squad'), ('duo', 'Duo'), ('casual', 'Casual'), ('tournament', 'Tournament'), ('practice', 'Practice')], max_length=50),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-02-17 18:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('groups', '0002_alter_group_group_type_alter_group_intent_type'),
]
operations = [
migrations.RenameField(
model_name='group',
old_name='transition_focus',
new_name='focus_game',
),
]

View File

Binary file not shown.

100
groups/models.py Normal file
View File

@ -0,0 +1,100 @@
from django.db import models
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
class Group(models.Model):
GROUP_TYPE_CHOICES = [
('squad', 'Squad (412 members)'),
('community', 'Community (Open)'),
('mastermind', 'Pro Squad (Structured)'),
('tournament', 'Tournament Team'),
('private', 'Private Invite'),
]
INTENT_TYPE_CHOICES = [
('ranked', 'Ranked Squad'),
('duo', 'Duo'),
('casual', 'Casual'),
('tournament', 'Tournament'),
('practice', 'Practice'),
]
VISIBILITY_CHOICES = [
('public_members', 'Public (Members Only)'),
('private_request', 'Private (Request to Join)'),
('invite_only', 'Invite Only'),
]
LOCATION_SCOPE_CHOICES = [
('local', 'Local'),
('virtual', 'Virtual'),
]
name = models.CharField(max_length=255)
description = models.TextField()
group_type = models.CharField(max_length=50, choices=GROUP_TYPE_CHOICES)
intent_type = models.CharField(max_length=50, choices=INTENT_TYPE_CHOICES)
focus_game = models.CharField(max_length=100, blank=True, null=True)
visibility = models.CharField(max_length=50, choices=VISIBILITY_CHOICES, default='public_members')
capacity = models.PositiveIntegerField(null=True, blank=True)
location_scope = models.CharField(max_length=20, choices=LOCATION_SCOPE_CHOICES, default='local')
city = models.CharField(max_length=100, blank=True, null=True)
state = models.CharField(max_length=100, blank=True, null=True)
recurring_schedule = models.TextField(blank=True, null=True, help_text="Required for Mastermind groups. Use structured text or JSON.")
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='created_groups_v2')
created_at = models.DateTimeField(auto_now_add=True)
cover_image = models.ImageField(upload_to='groups/covers/', blank=True, null=True)
def __str__(self):
return str(self.name)
def clean(self):
super().clean()
if self.group_type == 'circle':
if self.capacity and self.capacity > 12:
raise ValidationError({'capacity': 'Circles cannot have more than 12 members.'})
if not self.capacity:
self.capacity = 12
if self.group_type == 'mastermind' and not self.recurring_schedule:
raise ValidationError({'recurring_schedule': 'Mastermind groups require a structured schedule.'})
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
class GroupMember(models.Model):
ROLE_CHOICES = [
('member', 'Member'),
('moderator', 'Moderator'),
('owner', 'Owner'),
]
STATUS_CHOICES = [
('active', 'Active'),
('pending', 'Pending'),
]
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='group_memberships')
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='member')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('group', 'user')
def __str__(self):
return f"{self.user.username} in {self.group.name}"
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
def clean(self):
super().clean()
if self.status == 'active':
# Use filter().count() instead of exclude() for new objects
current_count = GroupMember.objects.filter(group=self.group, status='active').exclude(id=self.id).count()
if self.group.capacity and current_count >= self.group.capacity:
raise ValidationError('This group has reached its maximum capacity.')

View File

@ -0,0 +1,155 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ group.name }} | CommonGround{% endblock %}
{% block content %}
<div class="container-fluid dashboard-container px-lg-5">
<div class="row">
<!-- LEFT SIDEBAR -->
<div class="col-lg-2 d-none d-lg-block">
<div class="sticky-sidebar">
<nav class="sidebar-nav">
<a href="{% url 'home' %}" class="nav-link"><i class="bi bi-compass"></i> Teammates</a>
<a href="{% url 'groups:hub' %}" class="nav-link active"><i class="bi bi-grid"></i> Squads</a>
<a href="{% url 'inbox' %}" class="nav-link"><i class="bi bi-chat-dots"></i> Messages</a>
<a href="{% url 'events' %}" class="nav-link"><i class="bi bi-calendar-event"></i> Sessions</a>
</nav>
</div>
</div>
<!-- MAIN COLUMN -->
<div class="col-lg-10 col-md-12">
<!-- Group Header Card -->
<div class="card border-0 shadow-soft mb-5 overflow-hidden" style="border-radius: 24px;">
{% if group.cover_image %}
<div style="height: 250px; background: url('{{ group.cover_image.url }}') center/cover no-repeat;"></div>
{% else %}
<div style="height: 150px; background: linear-gradient(135deg, #F6F3EC 0%, #E5E1D8 100%);"></div>
{% endif %}
<div class="card-body p-4 p-lg-5">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-4">
<div>
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="post-badge">{{ group.get_group_type_display }}</span>
<span class="intent-badge">{{ group.get_intent_type_display }}</span>
<span class="text-muted small ms-2"><i class="bi bi-geo-alt me-1"></i> {{ group.get_location_scope_display }}</span>
</div>
<h1 class="display-5 fw-bold mb-2">{{ group.name }}</h1>
<p class="text-muted lead mb-0">{{ group.focus_game|default:"Competitive Gaming & Teamwork" }}</p>
</div>
<div class="d-flex gap-3">
{% if is_member %}
<div class="dropdown">
<button class="btn btn-outline-cg px-4 dropdown-toggle" type="button" data-bs-toggle="dropdown">
Member
</button>
<ul class="dropdown-menu border-0 shadow-sm p-2">
<li><a class="dropdown-item rounded small" href="{% url 'groups:members' group.pk %}">View Members</a></li>
{% if membership.role == 'owner' or membership.role == 'moderator' %}
<li><a class="dropdown-item rounded small" href="{% url 'groups:edit' group.pk %}">Settings</a></li>
<li><a class="dropdown-item rounded small" href="{% url 'groups:moderation' group.pk %}">Moderation</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li>
<form action="{% url 'groups:leave' group.pk %}" method="POST">
{% csrf_token %}
<button type="submit" class="dropdown-item rounded small text-danger">Leave Squad</button>
</form>
</li>
</ul>
</div>
{% elif is_pending %}
<button class="btn btn-outline-cg px-4 disabled">Request Sent</button>
{% else %}
<form action="{% url 'groups:join' group.pk %}" method="POST">
{% csrf_token %}
<button type="submit" class="btn btn-primary-cg px-5">Join Squad</button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row g-5">
<!-- Content Column -->
<div class="col-lg-8">
<div class="dashboard-section">
<h2 class="section-header mb-4">About</h2>
<div class="bg-white p-4 p-lg-5 rounded-cg shadow-soft">
<p class="mb-0" style="white-space: pre-wrap; line-height: 1.8;">{{ group.description }}</p>
</div>
</div>
{% if group.group_type == 'mastermind' %}
<div class="dashboard-section">
<h2 class="section-header mb-4">Practice Schedule</h2>
<div class="card border-0 bg-secondary-section p-4" style="border-radius: 16px;">
<div class="d-flex gap-4">
<div class="stat-icon bg-white text-cg-slate">
<i class="bi bi-calendar-check fs-4"></i>
</div>
<div>
<p class="mb-0 fw-bold">Team Commitment</p>
<p class="text-muted small mb-0">{{ group.recurring_schedule }}</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Feed Placeholder -->
<div class="dashboard-section mt-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="section-header mb-0">Squad Feed</h2>
<button class="btn btn-outline-cg btn-sm border-0 bg-light px-3">Latest First</button>
</div>
<div class="text-center py-5 bg-white rounded-cg shadow-soft">
<i class="bi bi-chat-dots fs-1 text-muted opacity-25 mb-3 d-block"></i>
<p class="text-muted mb-0">The conversation is just beginning. Be the first to coordinate.</p>
</div>
</div>
</div>
<!-- Info Column -->
<div class="col-lg-4">
<div class="stat-widget mb-5">
<h2 class="section-header mb-4" style="font-size: 1.1rem;">Squad Info</h2>
<div class="d-flex flex-column gap-3">
<div class="d-flex justify-content-between small border-bottom pb-2">
<span class="text-muted">Visibility</span>
<span class="fw-bold">{{ group.get_visibility_display }}</span>
</div>
<div class="d-flex justify-content-between small border-bottom pb-2">
<span class="text-muted">Capacity</span>
<span class="fw-bold">{{ active_members.count }} / {{ group.capacity|default:"∞" }}</span>
</div>
<div class="d-flex justify-content-between small border-bottom pb-2">
<span class="text-muted">Created</span>
<span class="fw-bold">{{ group.created_at|date:"M Y" }}</span>
</div>
</div>
</div>
<div class="stat-widget">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="section-header mb-0" style="font-size: 1.1rem;">Members</h2>
<a href="{% url 'groups:members' group.pk %}" class="extra-small text-decoration-none fw-bold">View All</a>
</div>
<div class="d-flex flex-wrap gap-2">
{% for member in active_members|slice:":12" %}
<a href="{% url 'profile_detail' member.user.username %}" title="{{ member.user.username }}">
<img src="{{ member.user.profile.get_avatar_url }}" class="rounded-circle border" width="40" height="40" style="object-fit: cover;">
</a>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,117 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{% if object %}Edit{% else %}Create{% endif %} Squad | CommonGround{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="dashboard-section text-center mb-5">
<h1 class="welcome-header">{% if object %}Refine your Squad{% else %}Start a new Squad{% endif %}</h1>
<p class="text-muted">Set the coordination and goals for your team.</p>
</div>
<div class="card border-0 shadow-soft p-4 p-lg-5" style="border-radius: 24px;">
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div class="row g-4">
<div class="col-12">
<label class="form-label fw-bold small">Squad Name</label>
<input type="text" name="name" class="form-control px-4 py-3 bg-light border-0" value="{{ form.name.value|default:'' }}" required placeholder="e.g. Apex Predators: Ranked Climb">
</div>
<div class="col-12">
<label class="form-label fw-bold small">Description & Goals</label>
<textarea name="description" class="form-control px-4 py-3 bg-light border-0" rows="5" required placeholder="What are the goals of this squad? Who are you looking for?">{{ form.description.value|default:'' }}</textarea>
</div>
<div class="col-md-6">
<label class="form-label fw-bold small">Squad Type</label>
<select name="group_type" class="form-select px-4 py-3 bg-light border-0" required id="id_group_type">
{% for code, label in form.fields.group_type.choices %}
<option value="{{ code }}" {% if form.group_type.value == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-bold small">Intent Type</label>
<select name="intent_type" class="form-select px-4 py-3 bg-light border-0" required>
{% for code, label in form.fields.intent_type.choices %}
<option value="{{ code }}" {% if form.intent_type.value == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<label class="form-label fw-bold small">Focus Game / Title (Optional)</label>
<input type="text" name="focus_game" class="form-control px-4 py-3 bg-light border-0" value="{{ form.focus_game.value|default:'' }}" placeholder="e.g. Apex Legends, Valorant, Tournament Prep">
</div>
<div class="col-md-6">
<label class="form-label fw-bold small">Visibility</label>
<select name="visibility" class="form-select px-4 py-3 bg-light border-0" required>
{% for code, label in form.fields.visibility.choices %}
<option value="{{ code }}" {% if form.visibility.value == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-bold small">Capacity (Optional)</label>
<input type="number" name="capacity" class="form-control px-4 py-3 bg-light border-0" value="{{ form.capacity.value|default:'' }}" placeholder="e.g. 5">
<small class="text-muted extra-small">Small Squads are capped at 12 by default.</small>
</div>
<div class="col-12" id="mastermind_schedule_div" style="display: none;">
<label class="form-label fw-bold small">Practice Schedule (Required for Pro Squads)</label>
<textarea name="recurring_schedule" class="form-control px-4 py-3 bg-light border-0" rows="2" placeholder="e.g. Every Mon/Wed/Fri at 8 PM EST">{{ form.recurring_schedule.value|default:'' }}</textarea>
</div>
<div class="col-md-6">
<label class="form-label fw-bold small">Location Scope</label>
<select name="location_scope" class="form-select px-4 py-3 bg-light border-0" required>
{% for code, label in form.fields.location_scope.choices %}
<option value="{{ code }}" {% if form.location_scope.value == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-bold small">Cover Image</label>
<input type="file" name="cover_image" class="form-control px-4 py-3 bg-light border-0">
</div>
<div class="col-12 pt-4">
<div class="d-flex gap-3 justify-content-center">
<a href="{% url 'groups:hub' %}" class="btn btn-outline-cg px-5 py-3">Cancel</a>
<button type="submit" class="btn btn-primary-cg px-5 py-3">{% if object %}Save Changes{% else %}Launch Squad{% endif %}</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const typeSelect = document.getElementById('id_group_type');
const scheduleDiv = document.getElementById('mastermind_schedule_div');
function toggleSchedule() {
if (typeSelect.value === 'mastermind') {
scheduleDiv.style.display = 'block';
} else {
scheduleDiv.style.display = 'none';
}
}
typeSelect.addEventListener('change', toggleSchedule);
toggleSchedule(); // Initial state
});
</script>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Members | {{ group.name }}{% endblock %}
{% block content %}
<div class="container py-5">
<div class="dashboard-section mb-5">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'groups:hub' %}" class="text-decoration-none">Groups</a></li>
<li class="breadcrumb-item"><a href="{% url 'groups:detail' group.pk %}" class="text-decoration-none">{{ group.name }}</a></li>
<li class="breadcrumb-item active">Members</li>
</ol>
</nav>
<h1 class="welcome-header">Community Members</h1>
</div>
<div class="row g-4">
{% for membership in group.memberships.all %}
<div class="col-md-6 col-lg-4">
<div class="card border-0 shadow-soft p-4" style="border-radius: 16px;">
<div class="d-flex align-items-center">
<img src="{{ membership.user.profile.get_avatar_url }}" class="rounded-circle me-3" width="64" height="64" style="object-fit: cover;">
<div>
<h3 class="h6 mb-1 fw-bold">{{ membership.user.get_full_name|default:membership.user.username }}</h3>
<p class="extra-small text-muted mb-2">{{ membership.user.profile.professional_headline|default:"Member" }}</p>
<span class="badge {% if membership.role == 'owner' %}bg- cg-slate{% else %}bg-light text-muted{% endif %} extra-small rounded-pill">
{{ membership.get_role_display }}
</span>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,176 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Squads Hub | CommonGround{% endblock %}
{% block content %}
<div class="container-fluid dashboard-container px-lg-5">
<div class="row">
<!-- LEFT SIDEBAR -->
<div class="col-lg-2 d-none d-lg-block">
<div class="sticky-sidebar">
<nav class="sidebar-nav">
<a href="{% url 'home' %}" class="nav-link"><i class="bi bi-compass"></i> Teammates</a>
<a href="{% url 'matches' %}" class="nav-link"><i class="bi bi-people"></i> My Teammates</a>
<a href="{% url 'inbox' %}" class="nav-link"><i class="bi bi-chat-dots"></i> Messages</a>
<a href="{% url 'events' %}" class="nav-link"><i class="bi bi-calendar-event"></i> Sessions</a>
<a href="{% url 'groups:hub' %}" class="nav-link active"><i class="bi bi-grid"></i> Squads</a>
<a href="{% url 'my_profile' %}" class="nav-link"><i class="bi bi-person"></i> Profile</a>
</nav>
</div>
</div>
<!-- CENTER COLUMN -->
<div class="col-lg-7 col-md-12">
<!-- Header -->
<div class="dashboard-section pt-2 d-flex justify-content-between align-items-center">
<div>
<h1 class="welcome-header mb-1">Squads</h1>
<p class="text-muted small">Intentional communities for gamers.</p>
</div>
<a href="{% url 'groups:create' %}" class="btn btn-primary-cg px-4">
<i class="bi bi-plus-lg me-2"></i> Create Squad
</a>
</div>
<!-- YOUR CIRCLES -->
{% if your_circles %}
<div class="dashboard-section">
<h2 class="section-header mb-4">Your Squads</h2>
<div class="row g-4">
{% for group in your_circles %}
<div class="col-md-6">
<div class="card h-100 shadow-sm border-0" style="border-radius: 16px;">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start mb-3">
<span class="post-badge">{{ group.get_group_type_display }}</span>
<span class="text-muted small"><i class="bi bi-people me-1"></i> {{ group.memberships.filter.status_active_count|default:group.memberships.count }}</span>
</div>
<h3 class="h5 fw-bold mb-2">{{ group.name }}</h3>
<p class="text-muted small line-clamp-2 mb-4">{{ group.description }}</p>
{% if group.group_type == 'mastermind' and group.recurring_schedule %}
<div class="bg-light p-3 rounded mb-4">
<p class="extra-small fw-bold text-muted mb-1"><i class="bi bi-calendar-check me-2"></i> NEXT MEETING</p>
<p class="small mb-0">{{ group.recurring_schedule|truncatechars:50 }}</p>
</div>
{% endif %}
<div class="d-grid">
<a href="{% url 'groups:detail' group.pk %}" class="btn btn-outline-cg btn-sm">Enter Space</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- RECOMMENDED FOR YOU -->
{% if recommended_groups %}
<div class="dashboard-section">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="section-header mb-0">Recommended For You</h2>
</div>
<div class="horizontal-scroll">
{% for group in recommended_groups %}
<div class="suggestion-card" style="width: 280px; text-align: left; padding: 24px;">
<span class="intent-badge mb-3 d-inline-block">{{ group.get_intent_type_display }}</span>
<h6 class="fw-bold mb-2">{{ group.name }}</h6>
<p class="extra-small text-muted mb-4 line-clamp-2">{{ group.description }}</p>
<div class="d-flex justify-content-between align-items-center mt-auto">
<span class="extra-small text-muted">{{ group.memberships.count }} members</span>
<a href="{% url 'groups:detail' group.pk %}" class="btn btn-primary-cg btn-sm px-3">View</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- DISCOVER GROUPS -->
<div class="dashboard-section">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="section-header mb-0">Discover Squads</h2>
</div>
<!-- Search & Filters -->
<div class="card p-3 mb-4 border-0 shadow-soft">
<form method="GET" class="row g-3">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text bg-white border-0"><i class="bi bi-search"></i></span>
<input type="text" name="q" class="form-control border-0 bg-white" placeholder="Search by name or focus...">
</div>
</div>
<div class="col-md-3">
<select name="type" class="form-select border-0 bg-light">
<option value="">All Types</option>
<option value="squad">Squad</option>
<option value="community">Community</option>
<option value="mastermind">Pro Squad</option>
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary-cg w-100">Filter</button>
</div>
</form>
</div>
<div class="row g-4">
{% for group in discover_groups %}
<div class="col-md-6">
<div class="card h-100 shadow-sm border-0" style="border-radius: 16px;">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start mb-3">
<span class="post-badge">{{ group.get_group_type_display }}</span>
<span class="text-muted small"><i class="bi bi-geo-alt me-1"></i> {{ group.get_location_scope_display }}</span>
</div>
<h3 class="h5 fw-bold mb-2">{{ group.name }}</h3>
<p class="text-muted small line-clamp-2 mb-4">{{ group.description }}</p>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="members-overlap me-2">
<!-- Placeholder for member avatars -->
<div class="avatar-sm rounded-circle bg-light border d-inline-block" style="width: 24px; height: 24px;"></div>
</div>
<span class="extra-small text-muted">{{ group.memberships.count }} members</span>
</div>
<a href="{% url 'groups:detail' group.pk %}" class="btn btn-outline-cg btn-sm">Details</a>
</div>
</div>
</div>
</div>
{% empty %}
<div class="col-12 text-center py-5">
<p class="text-muted">No squads found. Be the first to start one!</p>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- RIGHT SIDEBAR -->
<div class="col-lg-3 d-none d-lg-block">
<div class="sticky-sidebar pt-2">
<div class="stat-widget">
<h2 class="section-header mb-4" style="font-size: 1.1rem;">Squad Guidelines</h2>
<ul class="list-unstyled small text-muted">
<li class="mb-3"><i class="bi bi-check2-circle text-sage me-2"></i> <strong>Coordination:</strong> Squads are for active play and team coordination.</li>
<li class="mb-3"><i class="bi bi-check2-circle text-sage me-2"></i> <strong>Respect:</strong> Maintain a high-trust, toxicity-free environment.</li>
<li class="mb-3"><i class="bi bi-check2-circle text-sage me-2"></i> <strong>Participation:</strong> Pro Squads expect active presence and regular sessions.</li>
</ul>
</div>
<div class="stat-widget mt-4">
<h2 class="section-header mb-4" style="font-size: 1.1rem;">Starting a Squad?</h2>
<p class="small text-muted mb-4">Pro Squads require a structured schedule to ensure all members are aligned on practice times.</p>
<a href="{% url 'groups:create' %}" class="btn btn-primary-cg btn-sm w-100 py-2">Get Started</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

3
groups/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

15
groups/urls.py Normal file
View File

@ -0,0 +1,15 @@
from django.urls import path
from . import views
app_name = 'groups'
urlpatterns = [
path('', views.GroupsHubView.as_view(), name='hub'),
path('create/', views.GroupCreateView.as_view(), name='create'),
path('<int:pk>/', views.GroupDetailView.as_view(), name='detail'),
path('<int:pk>/edit/', views.GroupUpdateView.as_view(), name='edit'),
path('<int:pk>/members/', views.GroupMembersView.as_view(), name='members'),
path('<int:pk>/join/', views.GroupJoinView.as_view(), name='join'),
path('<int:pk>/leave/', views.GroupLeaveView.as_view(), name='leave'),
path('<int:pk>/moderation/', views.GroupModerationView.as_view(), name='moderation'),
]

123
groups/views.py Normal file
View File

@ -0,0 +1,123 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.views.generic import ListView, DetailView, CreateView, UpdateView, View
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.db.models import Q
from .models import Group, GroupMember
class GroupsHubView(LoginRequiredMixin, ListView):
model = Group
template_name = 'groups/hub.html'
context_object_name = 'discover_groups'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
context['your_circles'] = Group.objects.filter(
memberships__user=user,
memberships__status='active'
).distinct()
# Simple recommendation based on intent (can be refined later)
user_profile = getattr(user, 'profile', None)
if user_profile:
user_intents = user_profile.intents.all()
context['recommended_groups'] = Group.objects.exclude(
memberships__user=user
).filter(
intent_type__in=[i.name.lower() for i in user_intents]
).distinct()[:3]
return context
class GroupCreateView(LoginRequiredMixin, CreateView):
model = Group
template_name = 'groups/group_form.html'
fields = [
'name', 'description', 'group_type', 'intent_type',
'focus_game', 'visibility', 'capacity',
'location_scope', 'city', 'state', 'recurring_schedule', 'cover_image'
]
success_url = reverse_lazy('groups:hub')
def form_valid(self, form):
form.instance.created_by = self.request.user
response = super().form_valid(form)
# Automatically add creator as owner
GroupMember.objects.create(
group=self.object,
user=self.request.user,
role='owner',
status='active'
)
return response
class GroupDetailView(LoginRequiredMixin, DetailView):
model = Group
template_name = 'groups/group_detail.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
context['is_member'] = self.object.memberships.filter(user=user, status='active').exists()
context['is_pending'] = self.object.memberships.filter(user=user, status='pending').exists()
context['membership'] = self.object.memberships.filter(user=user).first()
context['active_members'] = self.object.memberships.filter(status='active')
return context
class GroupUpdateView(LoginRequiredMixin, UpdateView):
model = Group
template_name = 'groups/group_form.html'
fields = [
'name', 'description', 'group_type', 'intent_type',
'focus_game', 'visibility', 'capacity',
'location_scope', 'city', 'state', 'recurring_schedule', 'cover_image'
]
def get_queryset(self):
return Group.objects.filter(memberships__user=self.request.user, memberships__role__in=['owner', 'moderator'])
def get_success_url(self):
return reverse_lazy('groups:detail', kwargs={'pk': self.object.pk})
class GroupMembersView(LoginRequiredMixin, DetailView):
model = Group
template_name = 'groups/group_members.html'
class GroupJoinView(LoginRequiredMixin, View):
def post(self, request, pk):
group = get_object_or_404(Group, pk=pk)
# Check if already a member
if group.memberships.filter(user=request.user).exists():
return redirect('groups:detail', pk=pk)
status = 'active'
if group.visibility == 'private_request':
status = 'pending'
try:
GroupMember.objects.create(
group=group,
user=request.user,
status=status
)
except Exception as e:
# Handle capacity error from model clean
pass
return redirect('groups:detail', pk=pk)
class GroupLeaveView(LoginRequiredMixin, View):
def post(self, request, pk):
membership = get_object_or_404(GroupMember, group_id=pk, user=request.user)
if membership.role != 'owner':
membership.delete()
return redirect('groups:hub')
class GroupModerationView(LoginRequiredMixin, DetailView):
model = Group
template_name = 'groups/group_moderation.html'
def get_queryset(self):
return Group.objects.filter(memberships__user=self.request.user, memberships__role__in=['owner', 'moderator'])

28
seed_games.py Normal file
View File

@ -0,0 +1,28 @@
from core.models import Game
from django.utils.text import slugify
games = [
{"name": "League of Legends", "genre": "moba", "team_size": 5, "has_roles": True, "roles_json": ["Top", "Jungle", "Mid", "ADC", "Support"]},
{"name": "Valorant", "genre": "fps", "team_size": 5, "has_roles": True, "roles_json": ["Duelist", "Initiator", "Controller", "Sentinel"]},
{"name": "Counter-Strike 2", "genre": "fps", "team_size": 5, "has_roles": False},
{"name": "Apex Legends", "genre": "battle_royale", "team_size": 3, "has_roles": True, "roles_json": ["Offensive", "Defensive", "Support", "Recon"]},
{"name": "Overwatch 2", "genre": "fps", "team_size": 5, "has_roles": True, "roles_json": ["Tank", "Damage", "Support"]},
{"name": "Dota 2", "genre": "moba", "team_size": 5, "has_roles": True, "roles_json": ["Carry", "Mid", "Offlane", "Soft Support", "Hard Support"]},
{"name": "Fortnite", "genre": "battle_royale", "team_size": 4, "has_roles": False},
{"name": "Call of Duty: Warzone", "genre": "battle_royale", "team_size": 4, "has_roles": False},
{"name": "Rocket League", "genre": "sports", "team_size": 3, "has_roles": False},
{"name": "Minecraft", "genre": "other", "team_size": None, "has_roles": False},
]
for game_data in games:
Game.objects.get_or_create(
name=game_data["name"],
defaults={
"slug": slugify(game_data["name"]),
"genre": game_data["genre"],
"team_size": game_data.get("team_size"),
"has_roles": game_data["has_roles"],
"roles_json": game_data.get("roles_json"),
}
)
print(f"Successfully seeded {len(games)} games.")

View File

@ -1,281 +1,202 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:wght@700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Playfair+Display:ital,wght@0,400;0,500;0,600;1,400&display=swap');
:root { :root {
--cg-sand: #F4F1EA; --cg-sand-primary: #F4F1EA;
--cg-slate: #2C3E50; --cg-sand-secondary: #FAF8F4;
--cg-blue: #5D9CEC;
--cg-border: #E0D8C3;
--cg-white: #FFFFFF; --cg-white: #FFFFFF;
--cg-slate: #2C3E50;
--cg-muted: #6B7280;
--cg-blue: #5D9CEC;
--cg-sage: #8FAF9F;
--cg-divider: rgba(44, 62, 80, 0.08);
--cg-success: #789688; /* Muted sage-green for success */
--cg-radius-sm: 8px;
--cg-radius-md: 12px;
--cg-radius-lg: 16px;
--cg-transition: all 250ms ease;
--cg-shadow-soft: 0 4px 12px rgba(44, 62, 80, 0.04);
} }
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--cg-sand); background: linear-gradient(180deg, #F6F3EC 0%, #F2EFE7 100%);
color: var(--cg-slate);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
h1, .welcome-header {
font-family: 'Playfair Display', serif;
font-size: 36px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--cg-slate); color: var(--cg-slate);
} }
h1, h2, h3, h4, .brand-font { h2, h3, h4, h5, h6, .section-header, .brand-font {
font-family: 'Playfair Display', serif; font-family: 'Playfair Display', serif;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--cg-slate);
} }
.section-header {
font-size: 20px;
}
.text-muted {
color: #6B7280 !important;
}
/* Layout */
.navbar { .navbar {
background-color: var(--cg-white); background-color: var(--cg-white);
border-bottom: 1px solid var(--cg-border); border-bottom: 1px solid rgba(0,0,0,0.05);
padding: 1rem 0; padding: 0.75rem 0;
transition: var(--cg-transition);
} }
.hero-section { .navbar-brand img {
padding: 100px 0 60px; height: 24px;
background: linear-gradient(180deg, var(--cg-white) 0%, var(--cg-sand) 100%);
text-align: center;
}
.profile-card {
background: var(--cg-white);
border: 1px solid var(--cg-border);
border-radius: 12px;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
height: 100%;
}
.profile-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.05);
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 1rem;
border: 3px solid var(--cg-sand);
}
.intent-badge {
background-color: rgba(93, 156, 236, 0.1);
color: var(--cg-blue);
border-radius: 20px;
padding: 4px 12px;
font-size: 0.85rem;
font-weight: 500;
}
.transition-tag {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #888;
margin-bottom: 0.5rem;
display: block;
}
.btn-primary-cg {
background-color: var(--cg-slate);
color: white;
border-radius: 8px;
padding: 10px 24px;
font-weight: 500;
border: none;
}
.btn-primary-cg:hover {
background-color: #1a252f;
color: white;
}
.btn-outline-cg {
border: 1px solid var(--cg-slate);
color: var(--cg-slate);
border-radius: 8px;
padding: 10px 24px;
font-weight: 500;
}
.btn-outline-cg:hover {
background-color: var(--cg-slate);
color: white;
}
.bg-light-cg {
background-color: #fcfbf8;
}
.bg-sand {
background-color: var(--cg-sand);
}
.filter-pill {
border: 1px solid var(--cg-border);
background: var(--cg-white);
padding: 8px 20px;
border-radius: 25px;
margin: 5px;
cursor: pointer;
transition: all 0.2s;
}
.filter-pill.active {
background: var(--cg-slate);
color: white;
border-color: var(--cg-slate);
}
.bottom-nav {
position: fixed;
bottom: 0;
width: 100%;
background: white;
border-top: 1px solid var(--cg-border);
display: flex;
justify-content: space-around;
padding: 10px 0;
z-index: 1000;
}
.bottom-nav-item {
text-align: center;
color: var(--cg-slate);
text-decoration: none;
font-size: 0.75rem;
}
.bottom-nav-item i {
font-size: 1.25rem;
display: block;
} }
main { main {
padding-bottom: 80px; /* Space for bottom nav */ padding-bottom: 100px;
padding-top: 24px;
} }
/* Social Feed Styles */ section {
.nav-pills .nav-link.active { padding: 40px 0;
background-color: var(--cg-slate);
}
.nav-pills .nav-link {
color: var(--cg-slate);
}
.upload-btn-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
}
.upload-btn-wrapper input[type=file] {
font-size: 100px;
position: absolute;
left: 0;
top: 0;
opacity: 0;
cursor: pointer;
}
.extra-small {
font-size: 0.7rem;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Dashboard Layout */
.dashboard-container {
padding-top: 1rem;
} }
/* Sidebar Navigation */
.sticky-sidebar { .sticky-sidebar {
position: sticky; background: #F1EEE6;
top: 5.5rem; border-right: 1px solid rgba(0,0,0,0.05);
height: calc(100vh - 7rem); padding: 24px 16px;
overflow-y: auto; height: calc(100vh - 72px);
margin-left: -1.5rem;
} }
.sidebar-nav .nav-link { .sidebar-nav .nav-link {
padding: 10px 16px;
border-radius: 50px;
margin-bottom: 4px;
gap: 12px;
color: var(--cg-slate); color: var(--cg-slate);
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 0.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: background-color 0.2s ease;
} }
.sidebar-nav .nav-link:hover { .sidebar-nav .nav-link:hover {
background-color: rgba(44, 62, 80, 0.05); background-color: rgba(0,0,0,0.03);
} }
.sidebar-nav .nav-link.active { .sidebar-nav .nav-link.active {
background-color: var(--cg-slate); background-color: rgba(93,156,236,0.12);
color: white; color: var(--cg-slate);
opacity: 1;
} }
/* Post Type Badges */ .sidebar-nav .nav-link i {
.post-badge { font-size: 1.1rem;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 4px;
margin-bottom: 0.5rem;
display: inline-block;
letter-spacing: 0.05em;
} }
.badge-reflection { background-color: #E1F5FE; color: #01579B; } /* Components */
.badge-looking_for { background-color: #FFF3E0; color: #E65100; } .card, .profile-card, .suggestion-card {
.badge-offering { background-color: #E8F5E9; color: #1B5E20; } background: var(--cg-white);
.badge-event_invite { background-color: #F3E5F5; color: #4A148C; } border: none;
.badge-progress_update { background-color: #E0F2F1; color: #004D40; } border-radius: 16px;
.badge-skill_share { background-color: #FCE4EC; color: #880E4F; } box-shadow: 0 10px 30px rgba(0,0,0,0.06);
transition: var(--cg-transition);
/* Horizontal Scroll */ overflow: hidden;
.horizontal-scroll {
display: flex;
overflow-x: auto;
gap: 1rem;
padding-bottom: 1rem;
scrollbar-width: none;
-ms-overflow-style: none;
}
.horizontal-scroll::-webkit-scrollbar {
display: none;
}
.suggestion-card {
min-width: 180px;
max-width: 180px;
background: white;
border-radius: 12px;
padding: 1rem;
border: 1px solid var(--cg-border);
text-align: center;
} }
.stat-widget { .stat-widget {
background: white; background: var(--cg-white);
border-radius: 12px; border: none;
border: 1px solid var(--cg-border); border-radius: 16px;
padding: 1.25rem; box-shadow: 0 6px 16px rgba(0,0,0,0.05);
padding: 24px;
transition: var(--cg-transition);
} }
.stat-item { .card:hover, .profile-card:hover, .stat-widget:hover {
display: flex; transform: translateY(-2px);
justify-content: space-between; box-shadow: 0 12px 36px rgba(0,0,0,0.08);
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
} }
.stat-item:last-child { .dashboard-section {
border-bottom: none; margin-bottom: 40px;
} }
/* Badges & Tags */
.intent-badge {
background-color: var(--cg-sand-secondary);
color: var(--cg-blue);
border: 1px solid rgba(93, 156, 236, 0.2);
border-radius: 30px;
padding: 4px 14px;
font-size: 0.8rem;
font-weight: 500;
}
.post-badge {
font-family: 'Inter', sans-serif;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 4px 10px;
border-radius: 6px;
background-color: var(--cg-sand-secondary);
color: var(--cg-muted);
border: 1px solid var(--cg-divider);
}
/* Specific Page Refinements */
.hero-section {
padding: 120px 0 80px;
background-color: var(--cg-sand-primary);
text-align: center;
}
.bottom-nav {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-top: 1px solid var(--cg-divider);
padding: 12px 0;
}
.bottom-nav-item {
color: var(--cg-muted);
}
.bottom-nav-item.active {
color: var(--cg-slate);
}
/* Forms */
.form-control, .form-select {
background-color: var(--cg-white);
border: 1px solid var(--cg-divider);
border-radius: var(--cg-radius-md);
padding: 12px 16px;
transition: var(--cg-transition);
}
.form-control:focus {
border-color: var(--cg-blue);
box-shadow: 0 0 0 4px rgba(93, 156, 236, 0.05);
}
/* Divider Customization */
hr {
border-top: 1px solid var(--cg-divider);
opacity: 1;
}
/* Utils */
.rounded-cg { border-radius: var(--cg-radius-lg); }
.shadow-soft { box-shadow: var(--cg-shadow-soft); }