commit c9fcd918cec21c7365cdf193a678035e4c53518a Author: Lain Iwakura Date: Tue Aug 5 13:45:18 2025 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03d7930 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +MobileMkch +MobileMkch.app +MobileMkch.apk +MobileMkch.ipa +MobileMkch.xcodeproj/ +MobileMkch.xcworkspace/ +*.app +*.apk +*.ipa +*.dmg +*.deb +*.rpm +*.msi +*.exe +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +*.log +*.tmp +*.temp +*.ico diff --git a/Icon.png b/Icon.png new file mode 100644 index 0000000..59593bb Binary files /dev/null and b/Icon.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5af212a --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# MobileMkch + +Мобильный клиент для борды mkch.pooziqo.xyz + +## Возможности + +- Просмотр досок и тредов +- Чтение комментариев +- Темная/светлая тема +- Навигация между экранами +- Поддержка изображений и видео +- Адаптивный интерфейс + +## Сборка + +### Desktop + +```bash +go build +./MobileMkch +``` + +### Android + +1. Установите Android Studio (включает SDK и NDK): +```bash +# macOS +brew install --cask android-studio + +# Или скачайте с https://developer.android.com/studio +``` + +2. Установите Fyne CLI: +```bash +go install fyne.io/fyne/v2/cmd/fyne@latest +``` + +3. Настройте переменные окружения: +```bash +export ANDROID_HOME=$HOME/Library/Android/sdk +export ANDROID_NDK_HOME=$ANDROID_HOME/ndk +``` + +4. Соберите APK: +```bash +fyne package --os android --app-id com.mkch.mobile +``` + +5. Установите на устройство: +```bash +adb install MobileMkch.apk +``` + +### iOS + +1. Установите Xcode из App Store + +2. Установите Fyne CLI: +```bash +go install fyne.io/fyne/v2/cmd/fyne@latest +``` + +3. Соберите IPA: +```bash +fyne package --os ios --app-id com.mkch.mobile +``` + +4. Откройте в Xcode: +```bash +open MobileMkch.xcodeproj +``` + +5. Подпишите и установите через Xcode или TestFlight + +**✅ iOS сборка протестирована и работает!** + +## Требования + +- Go 1.24+ +- Fyne v2.6.2 +- Android SDK (для Android) +- Xcode (для iOS) + +## Технологии + +- Go 1.24+ +- Fyne v2.6.2 +- HTTP клиент для API + +## Структура + +- `main.go` - точка входа +- `api/client.go` - HTTP клиент для mkch API +- `models/models.go` - структуры данных +- `ui/` - пользовательский интерфейс + - `manager.go` - управление экранами + - `boards_screen.go` - список досок + - `threads_screen.go` - треды доски + - `thread_detail_screen.go` - детали треда \ No newline at end of file diff --git a/api/client.go b/api/client.go new file mode 100644 index 0000000..d34959b --- /dev/null +++ b/api/client.go @@ -0,0 +1,208 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "MobileMkch/models" +) + +const ( + BaseURL = "https://mkch.pooziqo.xyz" + ApiURL = BaseURL + "/api" +) + +type Client struct { + httpClient *http.Client + baseURL string + debug bool +} + +func NewClient() *Client { + return &Client{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + baseURL: ApiURL, + debug: false, + } +} + +func (c *Client) EnableDebug(enable bool) { + c.debug = enable +} + +func (c *Client) makeRequest(url string) ([]byte, error) { + if c.debug { + fmt.Printf("[API] Запрос: %s\n", url) + } + + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("ошибка запроса: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("ошибка чтения ответа: %w", err) + } + + if c.debug { + fmt.Printf("[API] Статус: %d, Длина ответа: %d байт\n", resp.StatusCode, len(body)) + if len(body) < 500 { + fmt.Printf("[API] Ответ: %s\n", string(body)) + } + } + + if resp.StatusCode != http.StatusOK { + return nil, &models.APIError{ + Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), + Code: resp.StatusCode, + } + } + + return body, nil +} + +func (c *Client) GetBoards() ([]models.Board, error) { + url := c.baseURL + "/boards/" + body, err := c.makeRequest(url) + if err != nil { + return nil, fmt.Errorf("ошибка получения досок: %w", err) + } + + var boards []models.Board + if err := json.Unmarshal(body, &boards); err != nil { + if c.debug { + fmt.Printf("[API] Ошибка парсинга JSON: %v\n", err) + fmt.Printf("[API] Ответ был: %s\n", string(body)) + } + return nil, fmt.Errorf("ошибка парсинга досок: %w", err) + } + + if c.debug { + fmt.Printf("[API] Получено досок: %d\n", len(boards)) + } + + return boards, nil +} + +func (c *Client) GetThreads(boardCode string) ([]models.Thread, error) { + url := fmt.Sprintf("%s/board/%s", c.baseURL, boardCode) + body, err := c.makeRequest(url) + if err != nil { + return nil, fmt.Errorf("ошибка получения тредов доски %s: %w", boardCode, err) + } + + var threads []models.Thread + if err := json.Unmarshal(body, &threads); err != nil { + if c.debug { + fmt.Printf("[API] Ошибка парсинга JSON тредов: %v\n", err) + fmt.Printf("[API] Ответ был: %s\n", string(body)) + } + return nil, fmt.Errorf("ошибка парсинга тредов: %w", err) + } + + if c.debug { + fmt.Printf("[API] Получено тредов в /%s/: %d\n", boardCode, len(threads)) + } + + return threads, nil +} + +func (c *Client) GetThread(boardCode string, threadID int) (*models.ThreadDetail, error) { + url := fmt.Sprintf("%s/board/%s/thread/%d", c.baseURL, boardCode, threadID) + body, err := c.makeRequest(url) + if err != nil { + return nil, fmt.Errorf("ошибка получения треда %d: %w", threadID, err) + } + + var thread models.ThreadDetail + if err := json.Unmarshal(body, &thread); err != nil { + if c.debug { + fmt.Printf("[API] Ошибка парсинга JSON треда: %v\n", err) + fmt.Printf("[API] Ответ был: %s\n", string(body)) + } + return nil, fmt.Errorf("ошибка парсинга треда: %w", err) + } + + if c.debug { + fmt.Printf("[API] Получен тред: ID=%d, Title=%s\n", thread.ID, thread.Title) + } + + return &thread, nil +} + +func (c *Client) GetComments(boardCode string, threadID int) ([]models.Comment, error) { + url := fmt.Sprintf("%s/board/%s/thread/%d/comments", c.baseURL, boardCode, threadID) + body, err := c.makeRequest(url) + if err != nil { + return nil, fmt.Errorf("ошибка получения комментариев треда %d: %w", threadID, err) + } + + var comments []models.Comment + if err := json.Unmarshal(body, &comments); err != nil { + if c.debug { + fmt.Printf("[API] Ошибка парсинга JSON комментариев: %v\n", err) + fmt.Printf("[API] Ответ был: %s\n", string(body)) + } + return nil, fmt.Errorf("ошибка парсинга комментариев: %w", err) + } + + if c.debug { + fmt.Printf("[API] Получено комментариев: %d\n", len(comments)) + } + + return comments, nil +} + +func (c *Client) GetFullThread(boardCode string, threadID int) (*models.ThreadDetail, []models.Comment, error) { + threadChan := make(chan *models.ThreadDetail, 1) + commentsChan := make(chan []models.Comment, 1) + errorChan := make(chan error, 2) + + go func() { + thread, err := c.GetThread(boardCode, threadID) + if err != nil { + errorChan <- fmt.Errorf("ошибка получения треда: %w", err) + return + } + threadChan <- thread + }() + + go func() { + comments, err := c.GetComments(boardCode, threadID) + if err != nil { + errorChan <- fmt.Errorf("ошибка получения комментариев: %w", err) + return + } + commentsChan <- comments + }() + + var thread *models.ThreadDetail + var comments []models.Comment + var errors []error + + for i := 0; i < 2; i++ { + select { + case t := <-threadChan: + thread = t + case c := <-commentsChan: + comments = c + case err := <-errorChan: + errors = append(errors, err) + case <-time.After(30 * time.Second): + return nil, nil, fmt.Errorf("тайм-аут получения треда") + } + } + + if len(errors) > 0 { + return nil, nil, errors[0] + } + + return thread, comments, nil +} diff --git a/fyne.json b/fyne.json new file mode 100644 index 0000000..84dd125 --- /dev/null +++ b/fyne.json @@ -0,0 +1,22 @@ +{ + "appID": "com.mkch.mobile", + "appName": "MobileMkch", + "appVersion": "1.0.0", + "appBuild": 1, + "category": "Social", + "description": "Мобильный клиент для борды mkch.pooziqo.xyz", + "icon": "Icon.jpg", + "release": { + "android": { + "permissions": [ + "android.permission.INTERNET" + ] + }, + "ios": { + "info": { + "CFBundleDisplayName": "MobileMkch", + "CFBundleIdentifier": "com.mkch.mobile" + } + } + } +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..12b2f37 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module MobileMkch + +go 1.24.5 + +require fyne.io/fyne/v2 v2.6.2 + +require ( + fyne.io/systray v1.11.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fredbi/uri v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fyne-io/gl-js v0.2.0 // indirect + github.com/fyne-io/glfw-js v0.3.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rymdport/portal v0.4.1 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e96f14d --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +fyne.io/fyne/v2 v2.6.2 h1:RPgwmXWn+EuP/TKwO7w5p73ILVC26qHD9j3CZUZNwgM= +fyne.io/fyne/v2 v2.6.2/go.mod h1:9IJ8uWgzfcMossFoUkLiOrUIEtaDvF4nML114WiCtXU= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= +github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= +github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9dff402 --- /dev/null +++ b/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "MobileMkch/api" + "MobileMkch/ui" +) + +func main() { + a := app.NewWithID("com.mkch.mobile") + a.SetIcon(theme.ComputerIcon()) + + a.Settings().SetTheme(theme.DarkTheme()) + + w := a.NewWindow("MobileMkch") + w.Resize(fyne.NewSize(400, 700)) + + apiClient := api.NewClient() + + uiManager := ui.NewUIManager(a, w, apiClient) + + w.SetContent(uiManager.GetMainContent()) + + w.ShowAndRun() +} + +const Version = "1.0.0" + +func showAbout() *widget.PopUp { + content := container.NewVBox( + widget.NewLabel("MobileMkch"), + widget.NewLabel(fmt.Sprintf("Версия: %s", Version)), + widget.NewLabel("Мобильный клиент для mkch.pooziqo.xyz"), + widget.NewLabel(""), + widget.NewLabel("Разработано с ❤️ на Go + Fyne"), + ) + + return widget.NewModalPopUp(content, nil) +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..c227329 --- /dev/null +++ b/models/models.go @@ -0,0 +1,119 @@ +package models + +import ( + "strings" + "time" +) + +type Board struct { + Code string `json:"code"` + Description string `json:"description"` +} + +type Thread struct { + ID int `json:"id"` + Title string `json:"title"` + Text string `json:"text"` + Creation string `json:"creation"` + Author string `json:"author"` + Board string `json:"board"` + Rating *int `json:"rating,omitempty"` + Pinned *bool `json:"pinned,omitempty"` + Files []string `json:"files"` +} + +func (t *Thread) GetCreationTime() time.Time { + if parsed, err := time.Parse(time.RFC3339, t.Creation); err == nil { + return parsed + } + return time.Now() +} + +func (t *Thread) GetRatingValue() int { + if t.Rating != nil { + return *t.Rating + } + return 0 +} + +func (t *Thread) IsPinned() bool { + return t.Pinned != nil && *t.Pinned +} + +type ThreadDetail struct { + ID int `json:"id"` + Creation string `json:"creation"` + Title string `json:"title"` + Text string `json:"text"` + Author string `json:"author"` + Board string `json:"board"` + Files []string `json:"files"` +} + +func (td *ThreadDetail) GetCreationTime() time.Time { + if parsed, err := time.Parse(time.RFC3339, td.Creation); err == nil { + return parsed + } + return time.Now() +} + +type Comment struct { + ID int `json:"id"` + Text string `json:"text"` + Creation string `json:"creation"` + Files []string `json:"files"` +} + +func (c *Comment) GetCreationTime() time.Time { + if parsed, err := time.Parse(time.RFC3339, c.Creation); err == nil { + return parsed + } + return time.Now() +} + +func (c *Comment) FormatText() string { + text := c.Text + // Простая замена #id на >>id для отображения + // TODO: можно добавить более сложную обработку ссылок + return strings.ReplaceAll(text, "#", ">>") +} + +type FileInfo struct { + URL string + Filename string + IsImage bool + IsVideo bool +} + +func GetFileInfo(filePath string) FileInfo { + filename := filePath + if idx := strings.LastIndex(filePath, "/"); idx != -1 { + filename = filePath[idx+1:] + } + + ext := strings.ToLower(filePath) + isImage := strings.HasSuffix(ext, ".jpg") || + strings.HasSuffix(ext, ".jpeg") || + strings.HasSuffix(ext, ".png") || + strings.HasSuffix(ext, ".gif") || + strings.HasSuffix(ext, ".webp") + + isVideo := strings.HasSuffix(ext, ".mp4") || + strings.HasSuffix(ext, ".webm") + + return FileInfo{ + URL: "https://mkch.pooziqo.xyz" + filePath, + Filename: filename, + IsImage: isImage, + IsVideo: isVideo, + } +} + +type APIError struct { + Message string + Code int +} + +func (e APIError) Error() string { + return e.Message +} diff --git a/ui/boards_screen.go b/ui/boards_screen.go new file mode 100644 index 0000000..2539c77 --- /dev/null +++ b/ui/boards_screen.go @@ -0,0 +1,161 @@ +package ui + +import ( + "fmt" + "log" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "MobileMkch/models" +) + +type BoardsScreen struct { + uiManager *UIManager + content *fyne.Container + boards []models.Board + loading bool +} + +func NewBoardsScreen(uiManager *UIManager) *BoardsScreen { + screen := &BoardsScreen{ + uiManager: uiManager, + loading: false, + } + + screen.setupContent() + return screen +} + +func (bs *BoardsScreen) setupContent() { + bs.content = container.NewVBox() + + header := widget.NewLabel("Выберите доску:") + header.TextStyle = fyne.TextStyle{Bold: true} + bs.content.Add(header) + + bs.content.Add(widget.NewLabel("Загрузка досок...")) + +} + +func (bs *BoardsScreen) GetContent() fyne.CanvasObject { + return bs.content +} + +func (bs *BoardsScreen) GetTitle() string { + return "MobileMkch - Доски" +} + +func (bs *BoardsScreen) OnShow() { + if !bs.loading { + bs.loadBoards() + } +} + +func (bs *BoardsScreen) OnHide() { + // Ничего не делаем +} + +func (bs *BoardsScreen) loadBoards() { + bs.loading = true + bs.showLoading() + go func() { + defer func() { + bs.loading = false + }() + + boards, err := bs.uiManager.GetAPIClient().GetBoards() + if err != nil { + log.Printf("Ошибка загрузки досок: %v", err) + fyne.Do(func() { + bs.showError(fmt.Sprintf("Ошибка загрузки досок:\n%v", err)) + }) + return + } + + bs.boards = boards + + fyne.Do(func() { + bs.displayBoards() + }) + }() +} + +func (bs *BoardsScreen) showLoading() { + bs.content.RemoveAll() + + header := widget.NewLabel("Доски mkch") + header.TextStyle = fyne.TextStyle{Bold: true} + bs.content.Add(header) + + progressBar := widget.NewProgressBarInfinite() + bs.content.Add(progressBar) + progressBar.Start() + + bs.content.Add(widget.NewLabel("Загрузка досок...")) + + bs.content.Refresh() +} + +func (bs *BoardsScreen) showError(message string) { + bs.content.RemoveAll() + + header := widget.NewLabel("Доски mkch") + header.TextStyle = fyne.TextStyle{Bold: true} + bs.content.Add(header) + + errorLabel := widget.NewLabel("❌ " + message) + errorLabel.Wrapping = fyne.TextWrapWord + bs.content.Add(errorLabel) + + retryButton := widget.NewButton("Повторить", func() { + bs.loadBoards() + }) + bs.content.Add(retryButton) + + bs.content.Refresh() +} + +func (bs *BoardsScreen) displayBoards() { + bs.content.RemoveAll() + + header := widget.NewLabel("Доски mkch") + header.TextStyle = fyne.TextStyle{Bold: true} + bs.content.Add(header) + + if len(bs.boards) == 0 { + emptyLabel := widget.NewLabel("Досок не найдено") + bs.content.Add(emptyLabel) + bs.content.Refresh() + return + } + + for i := range bs.boards { + board := bs.boards[i] + + boardButton := bs.createBoardCard(&board) + bs.content.Add(boardButton) + } + + bs.content.Refresh() +} + +func (bs *BoardsScreen) createBoardCard(board *models.Board) fyne.CanvasObject { + title := fmt.Sprintf("/%s/", board.Code) + + description := board.Description + if description == "" { + description = "Без описания" + } + + button := widget.NewButton(fmt.Sprintf("%s\n%s", title, description), func() { + bs.uiManager.ShowBoardThreads(board) + }) + + return button +} + +func (bs *BoardsScreen) RefreshBoards() { + bs.loadBoards() +} diff --git a/ui/manager.go b/ui/manager.go new file mode 100644 index 0000000..39bbcd0 --- /dev/null +++ b/ui/manager.go @@ -0,0 +1,210 @@ +package ui + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "MobileMkch/api" + "MobileMkch/models" +) + +type UIManager struct { + app fyne.App + window fyne.Window + apiClient *api.Client + + navigationStack []Screen + currentScreen Screen + + mainContainer *fyne.Container + headerBar *fyne.Container + content *fyne.Container + + isDarkTheme bool +} + +type Screen interface { + GetContent() fyne.CanvasObject + GetTitle() string + OnShow() + OnHide() +} + +func NewUIManager(app fyne.App, window fyne.Window, apiClient *api.Client) *UIManager { + manager := &UIManager{ + app: app, + window: window, + apiClient: apiClient, + navigationStack: make([]Screen, 0), + content: container.NewMax(), + isDarkTheme: true, // По умолчанию темная тема + } + + manager.setupHeader() + manager.setupMainContainer() + + boardsScreen := NewBoardsScreen(manager) + manager.ShowScreen(boardsScreen) + + return manager +} + +func (ui *UIManager) setupHeader() { + titleLabel := widget.NewLabel("MobileMkch") + titleLabel.TextStyle = fyne.TextStyle{Bold: true} + + backButton := widget.NewButton("← Назад", func() { + ui.GoBack() + }) + backButton.Hide() + + refreshButton := widget.NewButton("🔄", func() { + if ui.currentScreen != nil { + ui.currentScreen.OnShow() + } + }) + + themeButton := widget.NewButton("🌙", func() { + ui.toggleTheme() + }) + + rightButtons := container.NewHBox(refreshButton, themeButton) + + ui.headerBar = container.NewHBox( + backButton, + titleLabel, + rightButtons, + ) +} + +func (ui *UIManager) setupMainContainer() { + ui.mainContainer = container.NewBorder( + ui.headerBar, + nil, + nil, + nil, + ui.content, + ) +} + +func (ui *UIManager) GetMainContent() fyne.CanvasObject { + return ui.mainContainer +} + +func (ui *UIManager) ShowScreen(screen Screen) { + if ui.currentScreen != nil { + ui.currentScreen.OnHide() + } + + ui.navigationStack = append(ui.navigationStack, screen) + ui.currentScreen = screen + + ui.content.RemoveAll() + ui.content.Add(screen.GetContent()) + ui.content.Refresh() + + ui.updateHeader(screen.GetTitle()) + + screen.OnShow() +} + +func (ui *UIManager) GoBack() { + if len(ui.navigationStack) <= 1 { + return + } + + if ui.currentScreen != nil { + ui.currentScreen.OnHide() + } + + ui.navigationStack = ui.navigationStack[:len(ui.navigationStack)-1] + + ui.currentScreen = ui.navigationStack[len(ui.navigationStack)-1] + ui.content.RemoveAll() + ui.content.Add(ui.currentScreen.GetContent()) + ui.content.Refresh() + + ui.updateHeader(ui.currentScreen.GetTitle()) + + ui.currentScreen.OnShow() +} + +func (ui *UIManager) toggleTheme() { + ui.isDarkTheme = !ui.isDarkTheme + + if ui.isDarkTheme { + ui.app.Settings().SetTheme(theme.DarkTheme()) + } else { + ui.app.Settings().SetTheme(theme.LightTheme()) + } + + ui.window.Canvas().Refresh(ui.window.Canvas().Content()) +} + +func (ui *UIManager) updateHeader(title string) { + ui.window.SetTitle(title) + + if len(ui.headerBar.Objects) > 0 { + if backBtn, ok := ui.headerBar.Objects[0].(*widget.Button); ok { + if len(ui.navigationStack) > 1 { + backBtn.Show() + } else { + backBtn.Hide() + } + } + } + + ui.headerBar.Refresh() +} + +func (ui *UIManager) ShowBoardThreads(board *models.Board) { + threadsScreen := NewThreadsScreen(ui, board) + ui.ShowScreen(threadsScreen) +} + +func (ui *UIManager) ShowThreadDetail(board *models.Board, thread *models.Thread) { + threadScreen := NewThreadDetailScreen(ui, board, thread) + ui.ShowScreen(threadScreen) +} + +func (ui *UIManager) GetAPIClient() *api.Client { + return ui.apiClient +} + +func (ui *UIManager) GetWindow() fyne.Window { + return ui.window +} + +func (ui *UIManager) ShowError(title, message string) { + dialog := widget.NewModalPopUp( + container.NewVBox( + widget.NewLabel(title), + widget.NewLabel(""), + widget.NewLabel(message), + widget.NewLabel(""), + widget.NewButton("OK", func() { + // Закрыть диалог - пока что просто прячем popup + }), + ), + ui.window.Canvas(), + ) + dialog.Show() +} + +func (ui *UIManager) ShowInfo(title, message string) { + dialog := widget.NewModalPopUp( + container.NewVBox( + widget.NewLabel(title), + widget.NewLabel(""), + widget.NewLabel(message), + widget.NewLabel(""), + widget.NewButton("OK", func() { + // Закрыть диалог + }), + ), + ui.window.Canvas(), + ) + dialog.Show() +} diff --git a/ui/thread_detail_screen.go b/ui/thread_detail_screen.go new file mode 100644 index 0000000..74bfab3 --- /dev/null +++ b/ui/thread_detail_screen.go @@ -0,0 +1,274 @@ +package ui + +import ( + "fmt" + "log" + "net/url" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + + "MobileMkch/models" +) + +type ThreadDetailScreen struct { + uiManager *UIManager + board *models.Board + thread *models.Thread + threadDetail *models.ThreadDetail + comments []models.Comment + content *fyne.Container + loading bool +} + +func NewThreadDetailScreen(uiManager *UIManager, board *models.Board, thread *models.Thread) *ThreadDetailScreen { + screen := &ThreadDetailScreen{ + uiManager: uiManager, + board: board, + thread: thread, + loading: false, + } + + screen.setupContent() + return screen +} + +func (tds *ThreadDetailScreen) setupContent() { + tds.content = container.NewVBox() + header := widget.NewLabel(fmt.Sprintf("#%d: %s", tds.thread.ID, tds.thread.Title)) + header.TextStyle = fyne.TextStyle{Bold: true} + header.Wrapping = fyne.TextWrapWord + tds.content.Add(header) + tds.content.Add(widget.NewLabel("Загрузка деталей треда...")) +} + +func (tds *ThreadDetailScreen) GetContent() fyne.CanvasObject { + return container.NewScroll(tds.content) +} + +func (tds *ThreadDetailScreen) GetTitle() string { + return fmt.Sprintf("#%d", tds.thread.ID) +} + +func (tds *ThreadDetailScreen) OnShow() { + if !tds.loading { + tds.loadThreadDetail() + } +} + +func (tds *ThreadDetailScreen) OnHide() { + // Ничего не делаем +} + +func (tds *ThreadDetailScreen) loadThreadDetail() { + tds.loading = true + + tds.showLoading() + + go func() { + defer func() { + tds.loading = false + }() + + threadDetail, comments, err := tds.uiManager.GetAPIClient().GetFullThread(tds.board.Code, tds.thread.ID) + if err != nil { + log.Printf("Ошибка загрузки треда: %v", err) + + fyne.Do(func() { + tds.showError(fmt.Sprintf("Ошибка загрузки треда:\n%v", err)) + }) + return + } + + tds.threadDetail = threadDetail + tds.comments = comments + + fyne.Do(func() { + tds.displayThreadDetail() + }) + }() +} + +func (tds *ThreadDetailScreen) showLoading() { + tds.content.RemoveAll() + + header := widget.NewLabel(fmt.Sprintf("#%d: %s", tds.thread.ID, tds.thread.Title)) + header.TextStyle = fyne.TextStyle{Bold: true} + header.Wrapping = fyne.TextWrapWord + tds.content.Add(header) + + progressBar := widget.NewProgressBarInfinite() + tds.content.Add(progressBar) + progressBar.Start() + + tds.content.Add(widget.NewLabel("Загрузка треда и комментариев...")) + + tds.content.Refresh() +} + +func (tds *ThreadDetailScreen) showError(message string) { + tds.content.RemoveAll() + + header := widget.NewLabel(fmt.Sprintf("#%d: %s", tds.thread.ID, tds.thread.Title)) + header.TextStyle = fyne.TextStyle{Bold: true} + header.Wrapping = fyne.TextWrapWord + tds.content.Add(header) + + errorLabel := widget.NewLabel("❌ " + message) + errorLabel.Wrapping = fyne.TextWrapWord + tds.content.Add(errorLabel) + + retryButton := widget.NewButton("Повторить", func() { + tds.loadThreadDetail() + }) + tds.content.Add(retryButton) + + tds.content.Refresh() +} + +func (tds *ThreadDetailScreen) displayThreadDetail() { + tds.content.RemoveAll() + + if tds.threadDetail == nil { + tds.showError("Не удалось загрузить тред") + return + } + + header := widget.NewLabel(fmt.Sprintf("#%d: %s", tds.threadDetail.ID, tds.threadDetail.Title)) + header.TextStyle = fyne.TextStyle{Bold: true} + header.Wrapping = fyne.TextWrapWord + tds.content.Add(header) + + threadContainer := tds.createThreadContainer(tds.threadDetail) + tds.content.Add(threadContainer) + + tds.content.Add(widget.NewSeparator()) + + commentsHeader := widget.NewLabel(fmt.Sprintf("Комментарии (%d):", len(tds.comments))) + commentsHeader.TextStyle = fyne.TextStyle{Bold: true} + tds.content.Add(commentsHeader) + + if len(tds.comments) == 0 { + tds.content.Add(widget.NewLabel("Комментариев пока нет")) + } else { + for i := range tds.comments { + comment := &tds.comments[i] + commentContainer := tds.createCommentContainer(comment) + tds.content.Add(commentContainer) + + if i < len(tds.comments)-1 { + tds.content.Add(widget.NewSeparator()) + } + } + } + + tds.content.Refresh() +} + +func (tds *ThreadDetailScreen) createThreadContainer(thread *models.ThreadDetail) *fyne.Container { + container := container.NewVBox() + + info := fmt.Sprintf("Автор: %s | Дата: %s", + thread.Author, + thread.GetCreationTime().Format("02.01.2006 15:04")) + + infoLabel := widget.NewLabel(info) + infoLabel.TextStyle = fyne.TextStyle{Italic: true} + container.Add(infoLabel) + + if len(thread.Files) > 0 { + filesContainer := tds.createFilesContainer(thread.Files) + container.Add(filesContainer) + } + + if thread.Text != "" { + textLabel := widget.NewLabel(thread.Text) + textLabel.Wrapping = fyne.TextWrapWord + container.Add(textLabel) + } + + return container +} + +func (tds *ThreadDetailScreen) createCommentContainer(comment *models.Comment) *fyne.Container { + container := container.NewVBox() + + info := fmt.Sprintf("ID: %d | Дата: %s", + comment.ID, + comment.GetCreationTime().Format("02.01.2006 15:04")) + + infoLabel := widget.NewLabel(info) + infoLabel.TextStyle = fyne.TextStyle{Italic: true} + container.Add(infoLabel) + + if len(comment.Files) > 0 { + filesContainer := tds.createFilesContainer(comment.Files) + container.Add(filesContainer) + } + + if comment.Text != "" { + text := comment.FormatText() + textLabel := widget.NewLabel(text) + textLabel.Wrapping = fyne.TextWrapWord + container.Add(textLabel) + } + + return container +} + +func (tds *ThreadDetailScreen) createFilesContainer(files []string) *fyne.Container { + if len(files) == 0 { + return container.NewVBox() + } + + filesContainer := container.New(layout.NewGridLayout(2)) + + for _, filePath := range files { + fileInfo := models.GetFileInfo(filePath) + fileWidget := tds.createFileWidget(fileInfo) + filesContainer.Add(fileWidget) + } + + return container.NewVBox( + widget.NewLabel(fmt.Sprintf("📎 Файлы (%d):", len(files))), + filesContainer, + ) +} + +func (tds *ThreadDetailScreen) createFileWidget(fileInfo models.FileInfo) fyne.CanvasObject { + if fileInfo.IsImage { + button := widget.NewButton(fmt.Sprintf("🖼️ %s", fileInfo.Filename), func() { + tds.openFile(fileInfo.URL) + }) + return button + } else if fileInfo.IsVideo { + button := widget.NewButton(fmt.Sprintf("🎬 %s", fileInfo.Filename), func() { + tds.openFile(fileInfo.URL) + }) + return button + } else { + button := widget.NewButton(fmt.Sprintf("📄 %s", fileInfo.Filename), func() { + tds.openFile(fileInfo.URL) + }) + return button + } +} + +func (tds *ThreadDetailScreen) openFile(fileURL string) { + parsedURL, err := url.Parse(fileURL) + if err != nil { + tds.uiManager.ShowError("Ошибка", "Не удалось открыть файл") + return + } + + err = fyne.CurrentApp().OpenURL(parsedURL) + if err != nil { + tds.uiManager.ShowError("Ошибка", "Не удалось открыть файл в браузере") + } +} + +func (tds *ThreadDetailScreen) RefreshThread() { + tds.loadThreadDetail() +} diff --git a/ui/threads_screen.go b/ui/threads_screen.go new file mode 100644 index 0000000..7e2b052 --- /dev/null +++ b/ui/threads_screen.go @@ -0,0 +1,195 @@ +package ui + +import ( + "fmt" + "log" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "MobileMkch/models" +) + +type ThreadsScreen struct { + uiManager *UIManager + board *models.Board + content *fyne.Container + threads []models.Thread + loading bool +} + +func NewThreadsScreen(uiManager *UIManager, board *models.Board) *ThreadsScreen { + screen := &ThreadsScreen{ + uiManager: uiManager, + board: board, + loading: false, + } + + screen.setupContent() + return screen +} + +func (ts *ThreadsScreen) setupContent() { + ts.content = container.NewVBox() + + header := widget.NewLabel(fmt.Sprintf("/%s/ - %s", ts.board.Code, ts.board.Description)) + header.TextStyle = fyne.TextStyle{Bold: true} + ts.content.Add(header) + + ts.content.Add(widget.NewLabel("Загрузка тредов...")) +} + +func (ts *ThreadsScreen) GetContent() fyne.CanvasObject { + return container.NewScroll(ts.content) +} + +func (ts *ThreadsScreen) GetTitle() string { + return fmt.Sprintf("/%s/", ts.board.Code) +} + +func (ts *ThreadsScreen) OnShow() { + if !ts.loading { + ts.loadThreads() + } +} + +func (ts *ThreadsScreen) OnHide() { + // Ничего не делаем +} + +func (ts *ThreadsScreen) loadThreads() { + ts.loading = true + + ts.showLoading() + + go func() { + defer func() { + ts.loading = false + }() + + threads, err := ts.uiManager.GetAPIClient().GetThreads(ts.board.Code) + if err != nil { + log.Printf("Ошибка загрузки тредов: %v", err) + + fyne.Do(func() { + ts.showError(fmt.Sprintf("Ошибка загрузки тредов:\n%v", err)) + }) + return + } + + ts.threads = threads + + fyne.Do(func() { + ts.displayThreads() + }) + }() +} + +func (ts *ThreadsScreen) showLoading() { + ts.content.RemoveAll() + + header := widget.NewLabel(fmt.Sprintf("/%s/ - %s", ts.board.Code, ts.board.Description)) + header.TextStyle = fyne.TextStyle{Bold: true} + ts.content.Add(header) + + progressBar := widget.NewProgressBarInfinite() + ts.content.Add(progressBar) + progressBar.Start() + + ts.content.Add(widget.NewLabel("Загрузка тредов...")) + + ts.content.Refresh() +} + +func (ts *ThreadsScreen) showError(message string) { + ts.content.RemoveAll() + + header := widget.NewLabel(fmt.Sprintf("/%s/ - %s", ts.board.Code, ts.board.Description)) + header.TextStyle = fyne.TextStyle{Bold: true} + ts.content.Add(header) + + errorLabel := widget.NewLabel("❌ " + message) + errorLabel.Wrapping = fyne.TextWrapWord + ts.content.Add(errorLabel) + + retryButton := widget.NewButton("Повторить", func() { + ts.loadThreads() + }) + ts.content.Add(retryButton) + + ts.content.Refresh() +} + +func (ts *ThreadsScreen) displayThreads() { + ts.content.RemoveAll() + + header := widget.NewLabel(fmt.Sprintf("/%s/ - %s", ts.board.Code, ts.board.Description)) + header.TextStyle = fyne.TextStyle{Bold: true} + ts.content.Add(header) + + if len(ts.threads) == 0 { + ts.content.Add(widget.NewLabel("Тредов не найдено")) + ts.content.Refresh() + return + } + + for i := range ts.threads { + thread := ts.threads[i] + + threadCard := ts.createThreadCard(&thread) + ts.content.Add(threadCard) + } + + ts.content.Refresh() +} + +func (ts *ThreadsScreen) createThreadCard(thread *models.Thread) fyne.CanvasObject { + title := fmt.Sprintf("#%d: %s", thread.ID, thread.Title) + + if len(title) > 50 { + title = title[:47] + "..." + } + + info := fmt.Sprintf("Автор: %s | %s", + thread.Author, + thread.GetCreationTime().Format("02.01.2006 15:04")) + + if thread.IsPinned() { + info += " | 📌 Закреплён" + } + + if rating := thread.GetRatingValue(); rating != 0 { + info += fmt.Sprintf(" | ⭐ %d", rating) + } + + if len(thread.Files) > 0 { + info += fmt.Sprintf(" | 📎 %d", len(thread.Files)) + } + + preview := thread.Text + + preview = strings.ReplaceAll(preview, "\n", " ") + preview = strings.ReplaceAll(preview, "\r", " ") + + if len(preview) > 90 { + maxLen := 50 + if len(preview) < maxLen { + maxLen = len(preview) + } + preview = preview[:maxLen] + "..." + } + + buttonText := fmt.Sprintf("%s\n%s\n%s", title, info, preview) + + button := widget.NewButton(buttonText, func() { + ts.uiManager.ShowThreadDetail(ts.board, thread) + }) + + return button +} + +func (ts *ThreadsScreen) RefreshThreads() { + ts.loadThreads() +}