diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index 1aeb9d0..4a4c1aa 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -1,91 +1,71 @@
name: CI/CD
-
on:
push:
branches:
- dev
- main
-
pull_request:
branches:
- main
-
env:
JAVA_VERSION: "21"
-
jobs:
backend-unit-tests:
name: Backend Unit Tests
runs-on: ubuntu-latest
-
defaults:
run:
working-directory: backend
-
steps:
- name: Checkout
uses: actions/checkout@v4
-
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
-
- name: Make Gradle wrapper executable
run: chmod +x gradlew
-
- name: Run unit tests
run: ./gradlew test
-
deploy:
name: Deploy
needs: backend-unit-tests
-
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
-
runs-on: ubuntu-latest
-
steps:
- name: Checkout
uses: actions/checkout@v4
-
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
-
- name: Build boot jar
working-directory: backend
run: |
chmod +x gradlew
./gradlew bootJar
-
- name: Stage jar for Docker
working-directory: backend
run: |
BOOT_JAR=$(find build/libs -name "*.jar" ! -name "*-plain.jar" | head -n 1)
cp "$BOOT_JAR" app.jar
-
- name: Login to Docker registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.${{ secrets.DOMAIN }} \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin
-
- name: Build Docker image
run: |
docker build \
-t git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest \
backend/
-
- name: Push Docker image
run: |
docker push \
git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest
-
- name: Deploy to VPS
run: |
mkdir -p ~/.ssh
@@ -93,14 +73,126 @@ jobs:
chmod 600 ~/.ssh/key
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
- # Write secrets to VPS (only if they've changed or first deploy)
- ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
+
+ ssh -i ~/.ssh/key \
+ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"mkdir -p ~/programs/etf-oglasi-server/config"
- echo "${{ secrets.FIREBASE_CREDENTIALS }}" | \
- ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
- "cat > ~/programs/etf-oglasi-server/config/firebase.json"
-
- ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
+ ssh -i ~/.ssh/key \
+ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d"
+
+ mobile-release:
+ name: Mobile Release
+ needs: backend-unit-tests
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: frontend
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Set up JDK ${{ env.JAVA_VERSION }}
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: ${{ env.JAVA_VERSION }}
+
+ - name: Set up Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Accept Android SDK licenses
+ run: yes | sdkmanager --licenses > /dev/null || true
+
+ - name: Determine next mobile version
+ id: version
+ run: |
+ git fetch --tags
+ LAST=$(git tag -l 'mobile-v*' | sed 's/mobile-v//' | sort -n | tail -1)
+ NEXT=$(( ${LAST:-0} + 1 ))
+ echo "next=$NEXT" >> "$GITHUB_OUTPUT"
+ echo "tag=mobile-v$NEXT" >> "$GITHUB_OUTPUT"
+
+ - name: Stamp app version
+ run: node scripts/set-mobile-version.js ${{ steps.version.outputs.next }}
+
+ - name: Restore Firebase config
+ run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > google-services.json
+
+ - name: Restore env
+ run: echo "EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }}" > .env
+
+ - name: Restore signing keystore
+ run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ksan.dev.keystore
+
+ - name: Install dependencies
+ run: npm ci
+ - name: Prebuild Android project
+ run: npx expo prebuild -p android --no-install
+
+ - name: Increase Gradle memory
+ run: |
+ echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> android/gradle.properties
+
+
+ - name: Build signed release APK
+ working-directory: frontend/android
+ run: |
+ ./gradlew assembleRelease --no-daemon \
+ -Pandroid.injected.signing.store.file="$PWD/../ksan.dev.keystore" \
+ -Pandroid.injected.signing.store.password="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
+ -Pandroid.injected.signing.key.alias="${{ secrets.ANDROID_KEY_ALIAS }}" \
+ -Pandroid.injected.signing.key.password="${{ secrets.ANDROID_KEY_PASSWORD }}"
+
+ - name: Locate APK
+ id: apk
+ run: |
+ APK=$(find android/app/build/outputs/apk/release -name "*.apk" | head -n1)
+ OUT="etfoglasi-${{ steps.version.outputs.tag }}.apk"
+ cp "$APK" "$OUT"
+ echo "path=$OUT" >> "$GITHUB_OUTPUT"
+ echo "name=$OUT" >> "$GITHUB_OUTPUT"
+
+ - name: Create Gitea release
+ run: |
+ API="https://git.${{ secrets.DOMAIN }}/api/v1/repos/${{ secrets.REGISTRY_USER }}/etf-oglasi"
+ AUTH="Authorization: token ${{ secrets.GITEARELEASES_TOKEN }}"
+
+ HTTP_CODE=$(curl -s -o release.json -w "%{http_code}" -X POST \
+ -H "$AUTH" \
+ -H "Content-Type: application/json" \
+ -d "{\"tag_name\":\"${{ steps.version.outputs.tag }}\",\"target_commitish\":\"${{ github.sha }}\",\"name\":\"${{ steps.version.outputs.tag }}\",\"draft\":false,\"prerelease\":false}" \
+ "$API/releases")
+
+ echo "HTTP $HTTP_CODE"
+ cat release.json
+
+ if [ "$HTTP_CODE" != "201" ]; then
+ echo "Failed to create release"
+ exit 1
+ fi
+
+ RELEASE_ID=$(node -pe "JSON.parse(require('fs').readFileSync('release.json','utf8')).id")
+
+ HTTP_CODE=$(curl -s -o asset.json -w "%{http_code}" -X POST \
+ -H "$AUTH" \
+ -F "attachment=@${{ steps.apk.outputs.path }}" \
+ "$API/releases/$RELEASE_ID/assets?name=${{ steps.apk.outputs.name }}")
+
+ echo "HTTP $HTTP_CODE"
+ cat asset.json
+
+ if [ "$HTTP_CODE" != "201" ]; then
+ echo "Failed to upload asset"
+ exit 1
+ fi
diff --git a/.gitignore b/.gitignore
index f3e878f..cf70aec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,3 +87,8 @@ frontend/android/
# Expo
frontend/expo-env.d.ts
+
+
+
+CLAUDE.md
+ksan.dev.keystore
diff --git a/README.md b/README.md
index bd204b5..8841578 100644
--- a/README.md
+++ b/README.md
@@ -1,88 +1,47 @@
-# Full-Stack Learning Project
+# ETF Oglasi
-This repository is a full-stack learning project focused on building and understanding modern application development practices end-to-end.
+
-It demonstrates how to design, build, and deploy a real-world application using a backend API, mobile frontend, database integration, and CI/CD workflows.
+A hobby full-stack project I built to learn modern app development end to end.
----
+It scrapes the announcement boards of the Faculty of Electrical Engineering,
+University of Banja Luka, stores the announcements in PostgreSQL, and lets
+students browse and subscribe to subjects from a mobile app. Subscribers get
+push notifications when new announcements are posted.
## Tech Stack
-### Backend
-- Spring Boot
-- RESTful API architecture
-- PostgreSQL database
+- **Backend:** Spring Boot 3.5 (Java 21), REST API, PostgreSQL
+- **Frontend:** React Native / Expo (Expo Router, NativeWind)
+- **Notifications:** Firebase Cloud Messaging
+- **Tooling:** Docker, Gitea Actions for CI/CD, deployed to a self-hosted home server
-### Frontend
-- React Native (Expo)
+## Project Structure
-### DevOps / Tooling
-- Docker
-- CI/CD pipelines (Gitea Actions or similar)
-- Git version control
+- `backend/` — Spring Boot REST API and the scraper
+- `frontend/` — Expo mobile app
----
+## Backend
-## Purpose
+```bash
+cd backend
+cp src/main/resources/application.properties.example src/main/resources/application.properties # fill in datasource + jwt.secret
+docker compose up -d # local Postgres
+./gradlew bootRun
+```
-The goal of this project is to gain hands-on experience with:
+Run tests: `./gradlew test`
-- Full-stack application architecture
-- Backend API development with Spring Boot
-- Mobile development with React Native
-- Database design and integration
-- Authentication and security concepts
-- Containerization with Docker
-- Continuous Integration / Continuous Deployment (CI/CD)
+## Frontend
----
-
-## Deployment
-
-The application is deployed on a self-hosted home server for development and testing purposes.
-
----
-
-## How to Run (Backend)
-
-To be added.
-
-Planned steps:
-- Install Java and Gradle
-- Configure PostgreSQL database
-- Set environment variables
-- Run Spring Boot application
-- Expose API on localhost or network
-- I usually run eveything with traefik but i dont know should i include that here???
-
----
-
-## How to Run (Frontend)
-
-To be added...
-
-
----
-
-
-## CI/CD
-
-To be added.
-
-Planned steps:
-- Configure Gitea Actions / CI pipeline
-- Build backend and frontend automatically
-- Deploy to home server
-- Automate testing and builds
-
----
+```bash
+cd frontend
+npm install
+# set EXPO_PUBLIC_API_URL in .env to the backend base URL
+npm start
+```
## Status
-This project is currently under semi-active development as a learning and experimentation environment.
-
----
-
-## Notes
-
-This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful
+Semi-actively developed as a learning project. It is an app I actually use, and
+maybe others will find it useful too.
diff --git a/backend/init.sql b/backend/init.sql
index 1715fe8..48f4736 100644
--- a/backend/init.sql
+++ b/backend/init.sql
@@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS public.users
email character varying(255) COLLATE pg_catalog."default" NOT NULL,
password character varying(255) COLLATE pg_catalog."default" NOT NULL,
reg_time timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
- notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'NO_NOTIFICATION'::character varying,
+ notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'PUSH_NOTIFICATION'::character varying,
CONSTRAINT users_pkey PRIMARY KEY (id),
CONSTRAINT unique_email UNIQUE (email)
);
diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java b/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java
index 76520ce..5e34840 100644
--- a/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java
+++ b/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java
@@ -2,7 +2,7 @@ package dev.ksan.etfoglasiserver.config;
import dev.ksan.etfoglasiserver.service.JWTService;
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
-import dev.ksan.etfoglasiserver.service.UserService;
+import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -36,7 +36,12 @@ public class JwtFilter extends OncePerRequestFilter {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
- username = jwtService.extractEmail(token);
+ try {
+ username = jwtService.extractEmail(token);
+ } catch (JwtException e) {
+ // expired, malformed, or otherwise invalid token - treat as unauthenticated
+ username = null;
+ }
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java b/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java
index 14f94af..76844fd 100644
--- a/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java
+++ b/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java
@@ -3,6 +3,7 @@ package dev.ksan.etfoglasiserver.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
@@ -16,6 +17,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.List;
@Configuration
@EnableWebSecurity
@@ -30,19 +36,39 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
- return http.csrf(customizer -> customizer.disable()).
- authorizeHttpRequests(request -> request
+ return http
+ .csrf(customizer -> customizer.disable())
+ .cors(cors -> {})
+ .authorizeHttpRequests(request -> request
+ .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/login", "/api/register").permitAll()
- .requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll()
- .anyRequest().authenticated()).
- httpBasic(Customizer.withDefaults()).
- sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll()
+ .anyRequest().authenticated()
+ )
+ .httpBasic(Customizer.withDefaults())
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
-
-
}
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration config = new CorsConfiguration();
+
+ config.setAllowedOrigins(List.of(
+ "http://localhost:8081",
+ "https://etf-oglasi.ksan.dev"
+ ));
+
+ config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
+ config.setAllowedHeaders(List.of("*"));
+ config.setAllowCredentials(true);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", config);
+
+ return source;
+ }
diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java b/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java
index f18cebc..210f569 100644
--- a/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java
+++ b/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java
@@ -25,7 +25,7 @@ public class EntryController {
return service.getEntryById(entryId);
}
- //ignore this for now
+ // TODO only for testing
@PostMapping("/entries")
public void addEntry(@RequestBody EntryDTO entry) {
service.addEntry(entry);
@@ -62,13 +62,14 @@ public class EntryController {
}
- @PutMapping("/entries")
- public void updateEntry(@RequestBody EntryDTO entry) {
- service.updateEntry(entry);
- }
+// @PutMapping("/entries")
+// public void updateEntry(@RequestBody EntryDTO entry) {
+// service.updateEntry(entry);
+// }
- @DeleteMapping("/entries/{entryId}")
- public void deleteEntry(@PathVariable String entryId) {
- service.deleteEntry(entryId);
- }
+// @DeleteMapping("/entries/{entryId}")
+// public void deleteEntry(@PathVariable String entryId) {
+// service.deleteEntry(entryId);
+// }
+//
}
diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java
index 34a6898..744b59a 100644
--- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java
+++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java
@@ -1,17 +1,13 @@
package dev.ksan.etfoglasiserver.service;
-import dev.ksan.etfoglasiserver.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
-import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.function.Function;
-import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
@@ -29,7 +25,7 @@ public class JWTService {
return Jwts.builder().claims().add(claims).subject(email)
- .issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+60*60*300))
+ .issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+1000L*60*60*24*60))
.and().signWith(getKey()).compact();
}
diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java
index d8809cb..25fc059 100644
--- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java
+++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java
@@ -24,16 +24,30 @@ public class NotificationService {
for (DeviceToken token : tokens) {
+ // Include a notification block so Android's system tray displays it
+ // even when the app is killed/dozing (a data-only message is only
+ // ever shown by the app's own JS handler, which won't run when the
+ // OS has force-stopped the app). HIGH priority tells FCM to deliver
+ // promptly and wake the device out of Doze.
Message message = Message.builder()
.setToken(token.getFcmToken())
+ .setNotification(Notification.builder()
+ .setTitle(title)
+ .setBody(body)
+ .build())
+ .setAndroidConfig(AndroidConfig.builder()
+ .setPriority(AndroidConfig.Priority.HIGH)
+ .build())
.putData("title", title)
.putData("body", body)
.build();
try {
- firebaseMessaging.send(message);
+ String id = firebaseMessaging.send(message);
+ System.out.println("Send:" + id);
} catch (FirebaseMessagingException ex) {
+ ex.printStackTrace();
if (ex.getMessagingErrorCode()
== MessagingErrorCode.UNREGISTERED) {
deviceTokenRepo.delete(token);
diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java
index 96509fb..cf3bb99 100644
--- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java
+++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java
@@ -74,6 +74,11 @@ public class SubjectService {
@Async
public void notifyAsync(Entry entry) {
+ // The scraper couldn't always match an announcement to a known subject, in
+ // which case there's nobody to notify. Guard against the NPE that would
+ // otherwise be thrown (and silently swallowed) on this async thread.
+ if (entry.getSubject() == null) return;
+
List users =
userService.findUsersBySubjectId(entry.getSubject().getId());
diff --git a/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java b/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java
index 5ecac6a..14b0303 100644
--- a/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java
+++ b/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java
@@ -139,14 +139,15 @@ class EntryControllerTest {
}
- @Test
- @WithMockUser
- void deleteEntry_authenticated_returns200() throws Exception {
- doNothing().when(entryService).deleteEntry("entry-1");
-
- mvc.perform(delete("/api/entries/entry-1"))
- .andExpect(status().isOk());
- }
+ //we removed this from controller so test is pointless
+// @Test
+// @WithMockUser
+// void deleteEntry_authenticated_returns200() throws Exception {
+// doNothing().when(entryService).deleteEntry("entry-1");
+//
+// mvc.perform(delete("/api/entries/entry-1"))
+// .andExpect(status().isOk());
+// }
@Test
void deleteEntry_unauthenticated_returns401or403() throws Exception {
diff --git a/ci-full.yaml b/ci-full.yaml
deleted file mode 100644
index 8c644a7..0000000
--- a/ci-full.yaml
+++ /dev/null
@@ -1,24 +0,0 @@
-name: CI - full
-
-on:
- pull_request:
- branches: [main]
-
-jobs:
- backend-test:
- uses: ./.gitea/workflows/backend-test.yaml
-
- frontend-build:
- runs-on: ubuntu-latest
- defaults:
- run:
- working-directory: frontend
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- - name: Install dependencies
- run: npm ci
- - name: Build frontend
- run: npx expo export --platform web
diff --git a/frontend/app.json b/frontend/app.json
index 561014a..407b9f6 100644
--- a/frontend/app.json
+++ b/frontend/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "etfoglasi",
"slug": "etfoglasi",
- "version": "1.0.0",
+ "version": "1.0.6",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "etfoglasi",
@@ -14,7 +14,7 @@
"android": {
"googleServicesFile": "./google-services.json",
"adaptiveIcon": {
- "backgroundColor": "#E6F4FE",
+ "backgroundColor": "#1C1918",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx
index 665b4f8..43b163b 100644
--- a/frontend/app/_layout.tsx
+++ b/frontend/app/_layout.tsx
@@ -1,5 +1,5 @@
import "../globals.css";
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
import { Stack } from 'expo-router';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import {
@@ -10,8 +10,13 @@ import {
} from '@react-native-firebase/messaging';
import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications';
import { AuthProvider, useAuth } from "@/context/AuthContext";
+import { useUpdatecheck } from '@/hooks/useUpdatecheck';
+import { UpdatePrompt } from '@/components/UpdatePrompt';
import Toast from 'react-native-toast-message';
+// The FCM background handler now lives in index.js (registered before this
+// module loads) so it fires reliably while the app is backgrounded/killed.
+
function NotificationSetup() {
const { user, loading } = useAuth();
@@ -27,7 +32,8 @@ function NotificationSetup() {
console.log('Foreground notification:', msg);
Toast.show({
type: 'info',
- text1: msg.notification?.title ?? 'New Notification',
+ text1: (msg.data?.title as string | undefined) ?? 'New Notification',
+ text2: msg.data?.body as string | undefined,
position: 'top',
visibilityTime: 4000,
});
@@ -50,12 +56,22 @@ function NotificationSetup() {
return null;
}
+function UpdateCheck() {
+ const { updateInfo } = useUpdatecheck();
+ const [dismissed, setDismissed] = useState(false);
+
+ if (!updateInfo || dismissed) return null;
+
+ return setDismissed(true)} />;
+}
+
export default function RootLayout() {
return (
+
diff --git a/frontend/assets/images/android-icon-background.png b/frontend/assets/images/android-icon-background.png
index 5ffefc5..ebf4f76 100644
Binary files a/frontend/assets/images/android-icon-background.png and b/frontend/assets/images/android-icon-background.png differ
diff --git a/frontend/assets/images/android-icon-foreground.png b/frontend/assets/images/android-icon-foreground.png
index 3a9e501..e68b6c4 100644
Binary files a/frontend/assets/images/android-icon-foreground.png and b/frontend/assets/images/android-icon-foreground.png differ
diff --git a/frontend/assets/images/android-icon-monochrome.png b/frontend/assets/images/android-icon-monochrome.png
index 77484eb..a338f38 100644
Binary files a/frontend/assets/images/android-icon-monochrome.png and b/frontend/assets/images/android-icon-monochrome.png differ
diff --git a/frontend/assets/images/favicon.png b/frontend/assets/images/favicon.png
index 408bd74..7208ce8 100644
Binary files a/frontend/assets/images/favicon.png and b/frontend/assets/images/favicon.png differ
diff --git a/frontend/assets/images/icon.png b/frontend/assets/images/icon.png
index 7165a53..746b22b 100644
Binary files a/frontend/assets/images/icon.png and b/frontend/assets/images/icon.png differ
diff --git a/frontend/components/UpdatePrompt.tsx b/frontend/components/UpdatePrompt.tsx
new file mode 100644
index 0000000..1305c5c
--- /dev/null
+++ b/frontend/components/UpdatePrompt.tsx
@@ -0,0 +1,138 @@
+// components/UpdatePrompt.tsx
+// ─────────────────────────────────────────────────────────────────────────────
+// Modal that appears when a newer build is available.
+// "Update now" opens the Gitea release page in the browser.
+// "Later" dismisses it for the current session (it reappears next launch).
+// ─────────────────────────────────────────────────────────────────────────────
+
+import React from 'react';
+import {
+ Linking,
+ Modal,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+import type { UpdateInfo } from '../hooks/useUpdatecheck';
+import { version as currentVersion } from '../version.json';
+
+interface Props {
+ updateInfo: UpdateInfo;
+ onDismiss: () => void;
+}
+
+export function UpdatePrompt({ updateInfo, onDismiss }: Props) {
+ function openRelease() {
+ Linking.openURL(updateInfo.releaseUrl).catch(() => {
+ // If the device can't open the URL, just dismiss so the app isn't stuck.
+ onDismiss();
+ });
+ }
+
+ return (
+
+
+
+ Update available
+
+
+ A new version of the app is available.{'\n'}
+ You are on build {currentVersion},
+ the latest is build{' '}
+ {updateInfo.latestVersion}.
+
+
+
+
+ Later
+
+
+
+ Update now
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 24,
+ },
+ card: {
+ width: '100%',
+ maxWidth: 380,
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ padding: 24,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.15,
+ shadowRadius: 12,
+ elevation: 8,
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#111',
+ marginBottom: 12,
+ },
+ body: {
+ fontSize: 15,
+ color: '#444',
+ lineHeight: 22,
+ marginBottom: 24,
+ },
+ bold: {
+ fontWeight: '700',
+ color: '#111',
+ },
+ actions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: 12,
+ },
+ btn: {
+ paddingVertical: 10,
+ paddingHorizontal: 20,
+ borderRadius: 8,
+ },
+ btnPrimary: {
+ backgroundColor: '#2563EB',
+ },
+ btnPrimaryText: {
+ color: '#fff',
+ fontWeight: '600',
+ fontSize: 15,
+ },
+ btnSecondary: {
+ backgroundColor: '#F3F4F6',
+ },
+ btnSecondaryText: {
+ color: '#374151',
+ fontWeight: '600',
+ fontSize: 15,
+ },
+});
\ No newline at end of file
diff --git a/frontend/config/gitea.tsx b/frontend/config/gitea.tsx
new file mode 100644
index 0000000..ef56ecc
--- /dev/null
+++ b/frontend/config/gitea.tsx
@@ -0,0 +1,10 @@
+export const GITEA_URL = 'https://git.ksan.dev';
+
+export const GITEA_OWNER = 'ksan';
+
+export const GITEA_REPO = 'etf-oglasi';
+
+
+export const RELEASE_TAG_PREFIX = 'mobile-v';
+
+export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
diff --git a/frontend/context/AuthContext.tsx b/frontend/context/AuthContext.tsx
index 7b40d73..15db829 100644
--- a/frontend/context/AuthContext.tsx
+++ b/frontend/context/AuthContext.tsx
@@ -1,9 +1,10 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
+import Toast from "react-native-toast-message";
import {
authApi,
- LoginPayload, RegisterPayload, subscriptionsApi, userApi, UserUpdateDTO,
+ LoginPayload, RegisterPayload, setUnauthorizedHandler, subscriptionsApi, userApi, UserUpdateDTO,
} from "@/services/api";
type User = {
@@ -45,6 +46,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(u);
};
+ useEffect(() => {
+ setUnauthorizedHandler(() => {
+ persist(null);
+ Toast.show({
+ type: 'info',
+ text1: 'Session expired',
+ text2: 'Please sign in again.',
+ position: 'top',
+ visibilityTime: 4000,
+ });
+ });
+ return () => setUnauthorizedHandler(null);
+ }, []);
+
const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => {
if (!user) return;
await userApi.updateNotificationType(type, user.token);
diff --git a/frontend/hooks/useUpdatecheck.tsx b/frontend/hooks/useUpdatecheck.tsx
new file mode 100644
index 0000000..72b4147
--- /dev/null
+++ b/frontend/hooks/useUpdatecheck.tsx
@@ -0,0 +1,137 @@
+
+import { useEffect, useState } from 'react';
+import {
+ GITEA_URL,
+ GITEA_OWNER,
+ GITEA_REPO,
+ RELEASE_TAG_PREFIX,
+ UPDATE_CHECK_TIMEOUT_MS,
+} from '../config/gitea';
+import { version as currentVersion } from '../version.json';
+
+export interface UpdateInfo {
+ /** Build number of the latest release (same unit as currentVersion). */
+ latestVersion: number;
+ /** Direct URL the user can open to read the release notes / download the APK. */
+ releaseUrl: string;
+ /** Tag name as stored on Gitea, e.g. "mobile-v42". */
+ tagName: string;
+}
+
+export interface UseUpdateCheckResult {
+ /** True while the API call is in flight. */
+ checking: boolean;
+ /** Populated once the check completes and a newer build exists. */
+ updateInfo: UpdateInfo | null;
+ /** Non-null when the check failed (network error, timeout, bad response). */
+ error: Error | null;
+ /** Re-run the check manually (e.g. from a "check again" button). */
+ recheck: () => void;
+}
+
+// ─── Gitea releases API ──────────────────────────────────────────────────────
+
+interface GiteaRelease {
+ id: number;
+ tag_name: string;
+ name: string;
+ html_url: string;
+ draft: boolean;
+ prerelease: boolean;
+}
+
+async function fetchLatestRelease(): Promise {
+ const url = `${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`;
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
+
+ try {
+ const res = await fetch(url, { signal: controller.signal });
+
+ if (!res.ok) {
+ throw new Error(`Gitea API returned HTTP ${res.status}`);
+ }
+
+ return (await res.json()) as GiteaRelease;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/** Parse the integer build number out of a tag like "mobile-v42". Returns NaN if it can't. */
+function parseBuildNumber(tagName: string): number {
+ if (!tagName.startsWith(RELEASE_TAG_PREFIX)) return NaN;
+ return parseInt(tagName.slice(RELEASE_TAG_PREFIX.length), 10);
+}
+
+
+export function useUpdatecheck(): UseUpdateCheckResult {
+ const [checking, setChecking] = useState(false);
+ const [updateInfo, setUpdateInfo] = useState(null);
+ const [error, setError] = useState(null);
+ // A counter we bump to re-trigger the effect without changing the dep array shape.
+ const [trigger, setTrigger] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function check() {
+ setChecking(true);
+ setError(null);
+
+ try {
+ const release = await fetchLatestRelease();
+
+ if (cancelled) return;
+
+ // Skip drafts and pre-releases.
+ if (release.draft || release.prerelease) {
+ setUpdateInfo(null);
+ return;
+ }
+
+ const latestVersion = parseBuildNumber(release.tag_name);
+
+ if (isNaN(latestVersion)) {
+ // The tag doesn't follow our scheme — ignore it silently.
+ setUpdateInfo(null);
+ return;
+ }
+
+ if (latestVersion > currentVersion) {
+ setUpdateInfo({
+ latestVersion,
+ releaseUrl: release.html_url,
+ tagName: release.tag_name,
+ });
+ } else {
+ setUpdateInfo(null);
+ }
+ } catch (err) {
+ if (cancelled) return;
+ // Don't surface AbortError as a real error — it's just a timeout.
+ if (err instanceof Error && err.name === 'AbortError') {
+ setError(new Error('Update check timed out. Check your connection.'));
+ } else {
+ setError(err instanceof Error ? err : new Error(String(err)));
+ }
+ } finally {
+ if (!cancelled) setChecking(false);
+ }
+ }
+
+ check();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [trigger]);
+
+ return {
+ checking,
+ updateInfo,
+ error,
+ recheck: () => setTrigger(t => t + 1),
+ };
+}
\ No newline at end of file
diff --git a/frontend/index.js b/frontend/index.js
new file mode 100644
index 0000000..a5ea359
--- /dev/null
+++ b/frontend/index.js
@@ -0,0 +1,27 @@
+// Custom entry point. The FCM background handler must be registered in the very
+// first module that loads — before the router/app mounts — so it reliably fires
+// when the app is backgrounded or fully quit. Registering it here (rather than in
+// app/_layout.tsx) is what react-native-firebase requires for the killed state.
+import messaging from '@react-native-firebase/messaging';
+import notifee, { AndroidImportance } from '@notifee/react-native';
+
+messaging().setBackgroundMessageHandler(async (remoteMessage) => {
+ const title = remoteMessage.data?.title;
+ const body = remoteMessage.data?.body;
+ if (!title && !body) return;
+
+ const channelId = await notifee.createChannel({
+ id: 'default',
+ name: 'General',
+ importance: AndroidImportance.HIGH,
+ });
+
+ await notifee.displayNotification({
+ title,
+ body,
+ android: { channelId, pressAction: { id: 'default' } },
+ });
+});
+
+// Hand off to expo-router, which mounts app/_layout.tsx and the routes.
+import 'expo-router/entry';
diff --git a/frontend/index.tsx b/frontend/index.tsx
deleted file mode 100644
index c373b3d..0000000
--- a/frontend/index.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-
-
-import "./globals.css";
-
-import React from "react";
-import { useColorScheme } from "react-native";
-import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
-import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
-import { Ionicons } from "@expo/vector-icons";
-
-import SubscribedFeed from "@/screens/SubscribedFeed";
-import AllFeed from "@/screens/AllFeed";
-import Profile from "@/screens/Profile";
-import {enableScreens} from "react-native-screens";
-import {SafeAreaProvider} from "react-native-safe-area-context";
-import AuthGate from "@/screens/AuthGate";
-import {AuthProvider, useAuth} from "@/context/AuthContext";
-import messaging, {setBackgroundMessageHandler} from "@react-native-firebase/messaging";
-import {getMessaging} from "@firebase/messaging";
-messaging().setBackgroundMessageHandler(async () => {});
-// Must be called before any navigator renders
-enableScreens();
-
-const Tab = createBottomTabNavigator();
-
-const LIGHT_TAB = {
- bg: "#FFFFFF",
- border: "#E8E2D5",
- active: "#C4622D",
- inactive: "#8A8278",
- label: "#1A1714",
-};
-const DARK_TAB = {
- bg: "#161513",
- border: "#2C2A27",
- active: "#E07B45",
- inactive: "#706D67",
- label: "#F0EDE8",
-};
-
-
-function ProfileTab() {
- const { user, loading } = useAuth();
- if (loading) return null;
- return user ? : ;
-}
-
-export default function App() {
- const scheme = useColorScheme();
- const dark = scheme === "dark";
- const tab = dark ? DARK_TAB : LIGHT_TAB;
-
- const navTheme = dark
- ? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
- : { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
-
- return (
-
-
-
- ({
- headerShown: false,
-
- tabBarStyle: {
- backgroundColor: tab.bg,
- borderTopColor: tab.border,
- borderTopWidth: 1,
- height: 60,
- paddingBottom: 8,
- paddingTop: 6,
- },
-
- tabBarActiveTintColor: tab.active,
- tabBarInactiveTintColor: tab.inactive,
-
- tabBarLabelStyle: {
- fontSize: 10,
- fontWeight: "600",
- letterSpacing: 0.3,
- },
-
- tabBarIcon: ({ focused, color, size }) => {
- const icons: Record<
- string,
- { active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }
- > = {
- "Subscribed": { active: "bookmark", inactive: "bookmark-outline" },
- "Discover": { active: "compass", inactive: "compass-outline" },
- "Profile": { active: "person-circle", inactive: "person-circle-outline" },
- };
- const set = icons[route.name];
-
- return (
-
- );
- },
- })}
- >
-
-
-
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 512fa3c..cf93d01 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
+ "@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/messaging": "^24.0.0",
@@ -3583,6 +3584,15 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@notifee/react-native": {
+ "version": "9.1.8",
+ "resolved": "https://registry.npmjs.org/@notifee/react-native/-/react-native-9.1.8.tgz",
+ "integrity": "sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "react-native": "*"
+ }
+ },
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index c3e52cf..4958cbe 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "etfoglasi",
- "main": "expo-router/entry",
+ "main": "index.js",
"version": "1.0.0",
"scripts": {
"start": "expo start",
@@ -12,6 +12,7 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
+ "@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/messaging": "^24.0.0",
diff --git a/frontend/screens/HelpSupport.tsx b/frontend/screens/HelpSupport.tsx
index 6b73ba8..4d81cfd 100644
--- a/frontend/screens/HelpSupport.tsx
+++ b/frontend/screens/HelpSupport.tsx
@@ -5,11 +5,14 @@ import {
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
+import Constants from "expo-constants";
const CONTACT_EMAIL = "contact@ksan.dev";
//TODO make github page because yea
const GITHUB_URL = "https://git.ksan.dev/ksan";
-const APP_VERSION = "1.0.0";
+// Sourced from app.json's expo.version, which CI stamps on every mobile
+// release (scripts/set-mobile-version.js), so this stays in sync automatically.
+const APP_VERSION = Constants.expoConfig?.version ?? "unknown";
export default function HelpSupport({ onClose }: { onClose: () => void }) {
const dark = useColorScheme() === "dark";
diff --git a/frontend/scripts/set-mobile-version.js b/frontend/scripts/set-mobile-version.js
new file mode 100644
index 0000000..e0528fe
--- /dev/null
+++ b/frontend/scripts/set-mobile-version.js
@@ -0,0 +1,28 @@
+// Used by CI to stamp a mobile release build with its version number.
+// Updates version.json (read by hooks/useUpdatecheck.tsx) and app.json's
+// android.versionCode/versionName so the build, the in-app update check,
+// and the Gitea release tag (mobile-vN) all agree on the same number.
+const fs = require('fs');
+const path = require('path');
+
+const version = parseInt(process.argv[2], 10);
+if (!Number.isInteger(version) || version <= 0) {
+ console.error('Usage: node set-mobile-version.js ');
+ process.exit(1);
+}
+
+const root = path.join(__dirname, '..');
+
+fs.writeFileSync(
+ path.join(root, 'version.json'),
+ JSON.stringify({ version }, null, 2) + '\n',
+);
+
+const appJsonPath = path.join(root, 'app.json');
+const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
+appJson.expo.version = `1.0.${version}`;
+appJson.expo.android = appJson.expo.android ?? {};
+appJson.expo.android.versionCode = version;
+fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
+
+console.log(`Stamped mobile version ${version}`);
diff --git a/frontend/services/api.tsx b/frontend/services/api.tsx
index 117e593..63d3d2f 100644
--- a/frontend/services/api.tsx
+++ b/frontend/services/api.tsx
@@ -1,11 +1,19 @@
-const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this
+const BASE_URL = process.env.EXPO_PUBLIC_API_URL+'/api';
export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
function authHeader(token?: string) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
+let onUnauthorized: (() => void) | null = null;
+
+// Called once from AuthProvider so api.tsx can trigger a logout/relogin
+// when the backend rejects a request due to a missing/expired/invalid token.
+export function setUnauthorizedHandler(handler: (() => void) | null) {
+ onUnauthorized = handler;
+}
+
async function request(
method: string,
path: string,
@@ -30,6 +38,7 @@ async function request(
console.log('Failed URL:', res.url);
console.log('Status:', res.status);
console.log('Response:', errorText);
+ if (res.status === 401 && token) onUnauthorized?.();
throw new Error(errorText);
}
return res.json();
diff --git a/frontend/services/notifications.tsx b/frontend/services/notifications.tsx
index db6d4ad..ef4b6bf 100644
--- a/frontend/services/notifications.tsx
+++ b/frontend/services/notifications.tsx
@@ -5,6 +5,7 @@ import {
onTokenRefresh,
AuthorizationStatus,
} from '@react-native-firebase/messaging';
+import notifee, { AndroidImportance } from '@notifee/react-native';
import { post } from '@/services/api';
export async function registerDeviceToken(authToken: string): Promise {
@@ -18,7 +19,7 @@ export async function registerDeviceToken(authToken: string): Promise {
const fcmToken = await getToken(messaging);
- await fetch(`${process.env.EXPO_PUBLIC_API_URL}/device-tokens/register`, {
+ await fetch(`${process.env.EXPO_PUBLIC_API_URL}/api/device-tokens/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -32,4 +33,23 @@ export function listenForTokenRefresh(authToken: string): () => void {
return onTokenRefresh(getMessaging(), async (newToken) => {
await post('/device-tokens/register', { token: newToken }, authToken);
});
+}
+
+export async function displayLocalNotification(title?: string, body?: string) {
+ if (!title && !body) return;
+
+ const channelId = await notifee.createChannel({
+ id: 'default',
+ name: 'General',
+ importance: AndroidImportance.HIGH,
+ });
+
+ await notifee.displayNotification({
+ title,
+ body,
+ android: {
+ channelId,
+ pressAction: { id: 'default' },
+ },
+ });
}
\ No newline at end of file
diff --git a/frontend/version.json b/frontend/version.json
new file mode 100644
index 0000000..0e33e3f
--- /dev/null
+++ b/frontend/version.json
@@ -0,0 +1,3 @@
+{
+ "version": 0
+}
\ No newline at end of file
diff --git a/logo.png b/logo.png
new file mode 100644
index 0000000..9555b3d
Binary files /dev/null and b/logo.png differ