Fixes: #8972 Fixes: #24263 And I think it also (partially) fix #24263 (no need to convert) , because users could upload any supported image format if it isn't larger than AVATAR_MAX_ORIGIN_SIZE The main idea: * if the uploaded file size is not larger than AVATAR_MAX_ORIGIN_SIZE, use the origin * if the resized size is larger than the origin, use the origin Screenshots: JPG: <details> ![image](https://github.com/go-gitea/gitea/assets/2114189/70e98bb0-ecb9-4c4e-a89f-4a37d4e37f8e) </details> APNG: <details> ![image](https://github.com/go-gitea/gitea/assets/2114189/9055135b-5e2d-4152-bd72-596fcb7c6671) ![image](https://github.com/go-gitea/gitea/assets/2114189/50364caf-f7f6-4241-a289-e485fe4cd582) </details> WebP (animated) <details> ![image](https://github.com/go-gitea/gitea/assets/2114189/f642eb85-498a-49a5-86bf-0a7b04089ae0) </details> The only exception: if a WebP image is larger than MaxOriginSize and it is animated, then current `webp` package can't decode it, so only in this case it isn't supported. IMO no need to support such case: why a user would upload a 1MB animated webp as avatar? crazy ..... --------- Co-authored-by: silverwind <me@silverwind.io>tags/v1.20.0-rc0
@@ -1773,16 +1773,19 @@ ROUTER = console | |||
;; Max Width and Height of uploaded avatars. | |||
;; This is to limit the amount of RAM used when resizing the image. | |||
;AVATAR_MAX_WIDTH = 4096 | |||
;AVATAR_MAX_HEIGHT = 3072 | |||
;AVATAR_MAX_HEIGHT = 4096 | |||
;; | |||
;; The multiplication factor for rendered avatar images. | |||
;; Larger values result in finer rendering on HiDPI devices. | |||
;AVATAR_RENDERED_SIZE_FACTOR = 3 | |||
;AVATAR_RENDERED_SIZE_FACTOR = 2 | |||
;; | |||
;; Maximum allowed file size for uploaded avatars. | |||
;; This is to limit the amount of RAM used when resizing the image. | |||
;AVATAR_MAX_FILE_SIZE = 1048576 | |||
;; | |||
;; If the uploaded file is not larger than this byte size, the image will be used as is, without resizing/converting. | |||
;AVATAR_MAX_ORIGIN_SIZE = 262144 | |||
;; | |||
;; Chinese users can choose "duoshuo" | |||
;; or a custom avatar source, like: http://cn.gravatar.com/avatar/ | |||
;GRAVATAR_SOURCE = gravatar |
@@ -792,9 +792,10 @@ and | |||
- `AVATAR_STORAGE_TYPE`: **default**: Storage type defined in `[storage.xxx]`. Default is `default` which will read `[storage]` if no section `[storage]` will be a type `local`. | |||
- `AVATAR_UPLOAD_PATH`: **data/avatars**: Path to store user avatar image files. | |||
- `AVATAR_MAX_WIDTH`: **4096**: Maximum avatar image width in pixels. | |||
- `AVATAR_MAX_HEIGHT`: **3072**: Maximum avatar image height in pixels. | |||
- `AVATAR_MAX_FILE_SIZE`: **1048576** (1Mb): Maximum avatar image file size in bytes. | |||
- `AVATAR_RENDERED_SIZE_FACTOR`: **3**: The multiplication factor for rendered avatar images. Larger values result in finer rendering on HiDPI devices. | |||
- `AVATAR_MAX_HEIGHT`: **4096**: Maximum avatar image height in pixels. | |||
- `AVATAR_MAX_FILE_SIZE`: **1048576** (1MiB): Maximum avatar image file size in bytes. | |||
- `AVATAR_MAX_ORIGIN_SIZE`: **262144** (256KiB): If the uploaded file is not larger than this byte size, the image will be used as is, without resizing/converting. | |||
- `AVATAR_RENDERED_SIZE_FACTOR`: **2**: The multiplication factor for rendered avatar images. Larger values result in finer rendering on HiDPI devices. | |||
- `REPOSITORY_AVATAR_STORAGE_TYPE`: **default**: Storage type defined in `[storage.xxx]`. Default is `default` which will read `[storage]` if no section `[storage]` will be a type `local`. | |||
- `REPOSITORY_AVATAR_UPLOAD_PATH`: **data/repo-avatars**: Path to store repository avatar image files. |
@@ -214,8 +214,8 @@ menu: | |||
- `AVATAR_STORAGE_TYPE`: **local**: 头像存储类型,可以为 `local` 或 `minio`,分别支持本地文件系统和 minio 兼容的API。 | |||
- `AVATAR_UPLOAD_PATH`: **data/avatars**: 存储头像的文件系统路径。 | |||
- `AVATAR_MAX_WIDTH`: **4096**: 头像最大宽度,单位像素。 | |||
- `AVATAR_MAX_HEIGHT`: **3072**: 头像最大高度,单位像素。 | |||
- `AVATAR_MAX_FILE_SIZE`: **1048576** (1Mb): 头像最大大小。 | |||
- `AVATAR_MAX_HEIGHT`: **4096**: 头像最大高度,单位像素。 | |||
- `AVATAR_MAX_FILE_SIZE`: **1048576** (1MiB): 头像最大大小。 | |||
- `REPOSITORY_AVATAR_STORAGE_TYPE`: **local**: 仓库头像存储类型,可以为 `local` 或 `minio`,分别支持本地文件系统和 minio 兼容的API。 | |||
- `REPOSITORY_AVATAR_UPLOAD_PATH`: **data/repo-avatars**: 存储仓库头像的路径。 |
@@ -5,13 +5,14 @@ package avatar | |||
import ( | |||
"bytes" | |||
"errors" | |||
"fmt" | |||
"image" | |||
"image/color" | |||
"image/png" | |||
_ "image/gif" // for processing gif images | |||
_ "image/jpeg" // for processing jpeg images | |||
_ "image/png" // for processing png images | |||
"code.gitea.io/gitea/modules/avatar/identicon" | |||
"code.gitea.io/gitea/modules/setting" | |||
@@ -22,8 +23,11 @@ import ( | |||
_ "golang.org/x/image/webp" // for processing webp images | |||
) | |||
// AvatarSize returns avatar's size | |||
const AvatarSize = 290 | |||
// DefaultAvatarSize is the target CSS pixel size for avatar generation. It is | |||
// multiplied by setting.Avatar.RenderedSizeFactor and the resulting size is the | |||
// usual size of avatar image saved on server, unless the original file is smaller | |||
// than the size after resizing. | |||
const DefaultAvatarSize = 256 | |||
// RandomImageSize generates and returns a random avatar image unique to input data | |||
// in custom size (height and width). | |||
@@ -39,28 +43,44 @@ func RandomImageSize(size int, data []byte) (image.Image, error) { | |||
// RandomImage generates and returns a random avatar image unique to input data | |||
// in default size (height and width). | |||
func RandomImage(data []byte) (image.Image, error) { | |||
return RandomImageSize(AvatarSize, data) | |||
return RandomImageSize(DefaultAvatarSize*setting.Avatar.RenderedSizeFactor, data) | |||
} | |||
// Prepare accepts a byte slice as input, validates it contains an image of an | |||
// acceptable format, and crops and resizes it appropriately. | |||
func Prepare(data []byte) (*image.Image, error) { | |||
imgCfg, _, err := image.DecodeConfig(bytes.NewReader(data)) | |||
// processAvatarImage process the avatar image data, crop and resize it if necessary. | |||
// the returned data could be the original image if no processing is needed. | |||
func processAvatarImage(data []byte, maxOriginSize int64) ([]byte, error) { | |||
imgCfg, imgType, err := image.DecodeConfig(bytes.NewReader(data)) | |||
if err != nil { | |||
return nil, fmt.Errorf("DecodeConfig: %w", err) | |||
return nil, fmt.Errorf("image.DecodeConfig: %w", err) | |||
} | |||
// for safety, only accept known types explicitly | |||
if imgType != "png" && imgType != "jpeg" && imgType != "gif" && imgType != "webp" { | |||
return nil, errors.New("unsupported avatar image type") | |||
} | |||
// do not process image which is too large, it would consume too much memory | |||
if imgCfg.Width > setting.Avatar.MaxWidth { | |||
return nil, fmt.Errorf("Image width is too large: %d > %d", imgCfg.Width, setting.Avatar.MaxWidth) | |||
return nil, fmt.Errorf("image width is too large: %d > %d", imgCfg.Width, setting.Avatar.MaxWidth) | |||
} | |||
if imgCfg.Height > setting.Avatar.MaxHeight { | |||
return nil, fmt.Errorf("Image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight) | |||
return nil, fmt.Errorf("image height is too large: %d > %d", imgCfg.Height, setting.Avatar.MaxHeight) | |||
} | |||
// If the origin is small enough, just use it, then APNG could be supported, | |||
// otherwise, if the image is processed later, APNG loses animation. | |||
// And one more thing, webp is not fully supported, for animated webp, image.DecodeConfig works but Decode fails. | |||
// So for animated webp, if the uploaded file is smaller than maxOriginSize, it will be used, if it's larger, there will be an error. | |||
if len(data) < int(maxOriginSize) { | |||
return data, nil | |||
} | |||
img, _, err := image.Decode(bytes.NewReader(data)) | |||
if err != nil { | |||
return nil, fmt.Errorf("Decode: %w", err) | |||
return nil, fmt.Errorf("image.Decode: %w", err) | |||
} | |||
// try to crop and resize the origin image if necessary | |||
if imgCfg.Width != imgCfg.Height { | |||
var newSize, ax, ay int | |||
if imgCfg.Width > imgCfg.Height { | |||
@@ -74,13 +94,33 @@ func Prepare(data []byte) (*image.Image, error) { | |||
img, err = cutter.Crop(img, cutter.Config{ | |||
Width: newSize, | |||
Height: newSize, | |||
Anchor: image.Point{ax, ay}, | |||
Anchor: image.Point{X: ax, Y: ay}, | |||
}) | |||
if err != nil { | |||
return nil, err | |||
} | |||
} | |||
img = resize.Resize(AvatarSize, AvatarSize, img, resize.Bilinear) | |||
return &img, nil | |||
targetSize := uint(DefaultAvatarSize * setting.Avatar.RenderedSizeFactor) | |||
img = resize.Resize(targetSize, targetSize, img, resize.Bilinear) | |||
// try to encode the cropped/resized image to png | |||
bs := bytes.Buffer{} | |||
if err = png.Encode(&bs, img); err != nil { | |||
return nil, err | |||
} | |||
resized := bs.Bytes() | |||
// usually the png compression is not good enough, use the original image (no cropping/resizing) if the origin is smaller | |||
if len(data) <= len(resized) { | |||
return data, nil | |||
} | |||
return resized, nil | |||
} | |||
// ProcessAvatarImage process the avatar image data, crop and resize it if necessary. | |||
// the returned data could be the original image if no processing is needed. | |||
func ProcessAvatarImage(data []byte) ([]byte, error) { | |||
return processAvatarImage(data, setting.Avatar.MaxOriginSize) | |||
} |
@@ -4,6 +4,9 @@ | |||
package avatar | |||
import ( | |||
"bytes" | |||
"image" | |||
"image/png" | |||
"os" | |||
"testing" | |||
@@ -25,49 +28,109 @@ func Test_RandomImage(t *testing.T) { | |||
assert.NoError(t, err) | |||
} | |||
func Test_PrepareWithPNG(t *testing.T) { | |||
func Test_ProcessAvatarPNG(t *testing.T) { | |||
setting.Avatar.MaxWidth = 4096 | |||
setting.Avatar.MaxHeight = 4096 | |||
data, err := os.ReadFile("testdata/avatar.png") | |||
assert.NoError(t, err) | |||
imgPtr, err := Prepare(data) | |||
_, err = processAvatarImage(data, 262144) | |||
assert.NoError(t, err) | |||
assert.Equal(t, 290, (*imgPtr).Bounds().Max.X) | |||
assert.Equal(t, 290, (*imgPtr).Bounds().Max.Y) | |||
} | |||
func Test_PrepareWithJPEG(t *testing.T) { | |||
func Test_ProcessAvatarJPEG(t *testing.T) { | |||
setting.Avatar.MaxWidth = 4096 | |||
setting.Avatar.MaxHeight = 4096 | |||
data, err := os.ReadFile("testdata/avatar.jpeg") | |||
assert.NoError(t, err) | |||
imgPtr, err := Prepare(data) | |||
_, err = processAvatarImage(data, 262144) | |||
assert.NoError(t, err) | |||
assert.Equal(t, 290, (*imgPtr).Bounds().Max.X) | |||
assert.Equal(t, 290, (*imgPtr).Bounds().Max.Y) | |||
} | |||
func Test_PrepareWithInvalidImage(t *testing.T) { | |||
func Test_ProcessAvatarInvalidData(t *testing.T) { | |||
setting.Avatar.MaxWidth = 5 | |||
setting.Avatar.MaxHeight = 5 | |||
_, err := Prepare([]byte{}) | |||
assert.EqualError(t, err, "DecodeConfig: image: unknown format") | |||
_, err := processAvatarImage([]byte{}, 12800) | |||
assert.EqualError(t, err, "image.DecodeConfig: image: unknown format") | |||
} | |||
func Test_PrepareWithInvalidImageSize(t *testing.T) { | |||
func Test_ProcessAvatarInvalidImageSize(t *testing.T) { | |||
setting.Avatar.MaxWidth = 5 | |||
setting.Avatar.MaxHeight = 5 | |||
data, err := os.ReadFile("testdata/avatar.png") | |||
assert.NoError(t, err) | |||
_, err = Prepare(data) | |||
assert.EqualError(t, err, "Image width is too large: 10 > 5") | |||
_, err = processAvatarImage(data, 12800) | |||
assert.EqualError(t, err, "image width is too large: 10 > 5") | |||
} | |||
func Test_ProcessAvatarImage(t *testing.T) { | |||
setting.Avatar.MaxWidth = 4096 | |||
setting.Avatar.MaxHeight = 4096 | |||
scaledSize := DefaultAvatarSize * setting.Avatar.RenderedSizeFactor | |||
newImgData := func(size int, optHeight ...int) []byte { | |||
width := size | |||
height := size | |||
if len(optHeight) == 1 { | |||
height = optHeight[0] | |||
} | |||
img := image.NewRGBA(image.Rect(0, 0, width, height)) | |||
bs := bytes.Buffer{} | |||
err := png.Encode(&bs, img) | |||
assert.NoError(t, err) | |||
return bs.Bytes() | |||
} | |||
// if origin image canvas is too large, crop and resize it | |||
origin := newImgData(500, 600) | |||
result, err := processAvatarImage(origin, 0) | |||
assert.NoError(t, err) | |||
assert.NotEqual(t, origin, result) | |||
decoded, err := png.Decode(bytes.NewReader(result)) | |||
assert.NoError(t, err) | |||
assert.EqualValues(t, scaledSize, decoded.Bounds().Max.X) | |||
assert.EqualValues(t, scaledSize, decoded.Bounds().Max.Y) | |||
// if origin image is smaller than the default size, use the origin image | |||
origin = newImgData(1) | |||
result, err = processAvatarImage(origin, 0) | |||
assert.NoError(t, err) | |||
assert.Equal(t, origin, result) | |||
// use the origin image if the origin is smaller | |||
origin = newImgData(scaledSize + 100) | |||
result, err = processAvatarImage(origin, 0) | |||
assert.NoError(t, err) | |||
assert.Less(t, len(result), len(origin)) | |||
// still use the origin image if the origin doesn't exceed the max-origin-size | |||
origin = newImgData(scaledSize + 100) | |||
result, err = processAvatarImage(origin, 262144) | |||
assert.NoError(t, err) | |||
assert.Equal(t, origin, result) | |||
// allow to use known image format (eg: webp) if it is small enough | |||
origin, err = os.ReadFile("testdata/animated.webp") | |||
assert.NoError(t, err) | |||
result, err = processAvatarImage(origin, 262144) | |||
assert.NoError(t, err) | |||
assert.Equal(t, origin, result) | |||
// do not support unknown image formats, eg: SVG may contain embedded JS | |||
origin = []byte("<svg></svg>") | |||
_, err = processAvatarImage(origin, 262144) | |||
assert.ErrorContains(t, err, "image: unknown format") | |||
// make sure the canvas size limit works | |||
setting.Avatar.MaxWidth = 5 | |||
setting.Avatar.MaxHeight = 5 | |||
origin = newImgData(10) | |||
_, err = processAvatarImage(origin, 262144) | |||
assert.ErrorContains(t, err, "image width is too large: 10 > 5") | |||
} |
@@ -6,6 +6,7 @@ package repository | |||
import ( | |||
"crypto/md5" | |||
"fmt" | |||
"strconv" | |||
"testing" | |||
"time" | |||
@@ -136,13 +137,11 @@ func TestPushCommits_AvatarLink(t *testing.T) { | |||
enableGravatar(t) | |||
assert.Equal(t, | |||
"https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon&s=84", | |||
"https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon&s="+strconv.Itoa(28*setting.Avatar.RenderedSizeFactor), | |||
pushCommits.AvatarLink(db.DefaultContext, "user2@example.com")) | |||
assert.Equal(t, | |||
"https://secure.gravatar.com/avatar/"+ | |||
fmt.Sprintf("%x", md5.Sum([]byte("nonexistent@example.com")))+ | |||
"?d=identicon&s=84", | |||
fmt.Sprintf("https://secure.gravatar.com/avatar/%x?d=identicon&s=%d", md5.Sum([]byte("nonexistent@example.com")), 28*setting.Avatar.RenderedSizeFactor), | |||
pushCommits.AvatarLink(db.DefaultContext, "nonexistent@example.com")) | |||
} | |||
@@ -3,21 +3,23 @@ | |||
package setting | |||
// settings | |||
// Avatar settings | |||
var ( | |||
// Picture settings | |||
Avatar = struct { | |||
Storage | |||
MaxWidth int | |||
MaxHeight int | |||
MaxFileSize int64 | |||
MaxOriginSize int64 | |||
RenderedSizeFactor int | |||
}{ | |||
MaxWidth: 4096, | |||
MaxHeight: 3072, | |||
MaxHeight: 4096, | |||
MaxFileSize: 1048576, | |||
RenderedSizeFactor: 3, | |||
MaxOriginSize: 262144, | |||
RenderedSizeFactor: 2, | |||
} | |||
GravatarSource string | |||
@@ -44,9 +46,10 @@ func loadPictureFrom(rootCfg ConfigProvider) { | |||
Avatar.Storage = getStorage(rootCfg, "avatars", storageType, avatarSec) | |||
Avatar.MaxWidth = sec.Key("AVATAR_MAX_WIDTH").MustInt(4096) | |||
Avatar.MaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(3072) | |||
Avatar.MaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(4096) | |||
Avatar.MaxFileSize = sec.Key("AVATAR_MAX_FILE_SIZE").MustInt64(1048576) | |||
Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(3) | |||
Avatar.MaxOriginSize = sec.Key("AVATAR_MAX_ORIGIN_SIZE").MustInt64(262144) | |||
Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(2) | |||
switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source { | |||
case "duoshuo": | |||
@@ -94,5 +97,5 @@ func loadRepoAvatarFrom(rootCfg ConfigProvider) { | |||
RepoAvatar.Storage = getStorage(rootCfg, "repo-avatars", storageType, repoAvatarSec) | |||
RepoAvatar.Fallback = sec.Key("REPOSITORY_AVATAR_FALLBACK").MustString("none") | |||
RepoAvatar.FallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString("/assets/img/repo_default.png") | |||
RepoAvatar.FallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString(AppSubURL + "/assets/img/repo_default.png") | |||
} |
@@ -6,7 +6,6 @@ package repository | |||
import ( | |||
"context" | |||
"fmt" | |||
"image/png" | |||
"io" | |||
"strconv" | |||
"strings" | |||
@@ -21,7 +20,7 @@ import ( | |||
// UploadAvatar saves custom avatar for repository. | |||
// FIXME: split uploads to different subdirs in case we have massive number of repos. | |||
func UploadAvatar(ctx context.Context, repo *repo_model.Repository, data []byte) error { | |||
m, err := avatar.Prepare(data) | |||
avatarData, err := avatar.ProcessAvatarImage(data) | |||
if err != nil { | |||
return err | |||
} | |||
@@ -47,9 +46,7 @@ func UploadAvatar(ctx context.Context, repo *repo_model.Repository, data []byte) | |||
} | |||
if err := storage.SaveFrom(storage.RepoAvatars, repo.CustomAvatarRelativePath(), func(w io.Writer) error { | |||
if err := png.Encode(w, *m); err != nil { | |||
log.Error("Encode: %v", err) | |||
} | |||
_, err := w.Write(avatarData) | |||
return err | |||
}); err != nil { | |||
return fmt.Errorf("UploadAvatar %s failed: Failed to remove old repo avatar %s: %w", repo.RepoPath(), newAvatar, err) |
@@ -6,7 +6,6 @@ package user | |||
import ( | |||
"context" | |||
"fmt" | |||
"image/png" | |||
"io" | |||
"time" | |||
@@ -244,7 +243,7 @@ func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error { | |||
// UploadAvatar saves custom avatar for user. | |||
func UploadAvatar(u *user_model.User, data []byte) error { | |||
m, err := avatar.Prepare(data) | |||
avatarData, err := avatar.ProcessAvatarImage(data) | |||
if err != nil { | |||
return err | |||
} | |||
@@ -262,9 +261,7 @@ func UploadAvatar(u *user_model.User, data []byte) error { | |||
} | |||
if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error { | |||
if err := png.Encode(w, *m); err != nil { | |||
log.Error("Encode: %v", err) | |||
} | |||
_, err := w.Write(avatarData) | |||
return err | |||
}); err != nil { | |||
return fmt.Errorf("Failed to create dir %s: %w", u.CustomAvatarRelativePath(), err) |
@@ -7,11 +7,12 @@ | |||
<div id="profile-avatar" class="content gt-df"> | |||
{{if eq .SignedUserID .ContextUser.ID}} | |||
<a class="image" href="{{AppSubUrl}}/user/settings" data-tooltip-content="{{.locale.Tr "user.change_avatar"}}"> | |||
{{avatar $.Context .ContextUser 290}} | |||
{{/* the size doesn't take affect (and no need to take affect), image size(width) should be controlled by the parent container since this is not a flex layout*/}} | |||
{{avatar $.Context .ContextUser 256}} | |||
</a> | |||
{{else}} | |||
<span class="image"> | |||
{{avatar $.Context .ContextUser 290}} | |||
{{avatar $.Context .ContextUser 256}} | |||
</span> | |||
{{end}} | |||
</div> |
@@ -1046,62 +1046,6 @@ a.label, | |||
box-shadow: -1px -1px 0 0 var(--color-secondary); | |||
} | |||
.ui.cards > .card, | |||
.ui.card { | |||
background: var(--color-card); | |||
border: 1px solid var(--color-secondary); | |||
box-shadow: none; | |||
} | |||
.ui.cards > .card > .content, | |||
.ui.card > .content { | |||
border-color: var(--color-secondary); | |||
} | |||
.ui.cards > .card > .extra, | |||
.ui.card > .extra, | |||
.ui.cards > .card > .extra a:not(.ui), | |||
.ui.card > .extra a:not(.ui) { | |||
color: var(--color-text); | |||
} | |||
.ui.cards > .card > .extra a:not(.ui):hover, | |||
.ui.card > .extra a:not(.ui):hover { | |||
color: var(--color-primary); | |||
} | |||
.ui.cards > .card > .content > .header, | |||
.ui.card > .content > .header { | |||
color: var(--color-text); | |||
} | |||
.ui.cards > .card > .content > .description, | |||
.ui.card > .content > .description { | |||
color: var(--color-text); | |||
} | |||
.ui.cards > .card .meta > a:not(.ui), | |||
.ui.card .meta > a:not(.ui) { | |||
color: var(--color-text-light-2); | |||
} | |||
.ui.cards > .card .meta > a:not(.ui):hover, | |||
.ui.card .meta > a:not(.ui):hover { | |||
color: var(--color-text); | |||
} | |||
.ui.cards a.card:hover, | |||
a.ui.card:hover { | |||
border: 1px solid var(--color-secondary); | |||
background: var(--color-card); | |||
} | |||
.ui.cards > .card > .extra, | |||
.ui.card > .extra { | |||
color: var(--color-text); | |||
border-top-color: var(--color-secondary-light-1) !important; | |||
} | |||
.ui.comments .comment .text { | |||
margin: 0; | |||
} | |||
@@ -1183,12 +1127,10 @@ a.ui.card:hover { | |||
img.ui.avatar, | |||
.ui.avatar img, | |||
.ui.avatar svg, | |||
.ui.cards > .card img.avatar, | |||
.ui.cards > .card .avatar img, | |||
.ui.card img.avatar, | |||
.ui.card .avatar img { | |||
.ui.avatar svg { | |||
border-radius: var(--border-radius); | |||
object-fit: contain; | |||
aspect-ratio: 1; | |||
} | |||
.ui.divided.list > .item { |
@@ -10,6 +10,7 @@ | |||
@import "./modules/tippy.css"; | |||
@import "./modules/modal.css"; | |||
@import "./modules/breadcrumb.css"; | |||
@import "./modules/card.css"; | |||
@import "./code/linebutton.css"; | |||
@import "./markup/content.css"; | |||
@import "./markup/codecopy.css"; |
@@ -0,0 +1,134 @@ | |||
/* Below styles are a subset of the full fomantic card styles which are */ | |||
/* needed to get all current uses of fomantic cards working. */ | |||
/* TODO: remove all these styles and use custom styling instead */ | |||
.ui.card:last-child { | |||
margin-bottom: 0; | |||
} | |||
.ui.card:first-child { | |||
margin-top: 0; | |||
} | |||
.ui.cards > .card, | |||
.ui.card { | |||
display: flex; | |||
flex-direction: column; | |||
max-width: 100%; | |||
width: 290px; | |||
min-height: 0; | |||
padding: 0; | |||
background: var(--color-card); | |||
border: 1px solid var(--color-secondary); | |||
box-shadow: none; | |||
word-wrap: break-word; | |||
} | |||
.ui.card { | |||
margin: 1em 0; | |||
} | |||
.ui.cards { | |||
display: flex; | |||
margin: -0.875em -0.5em; | |||
flex-wrap: wrap; | |||
} | |||
.ui.cards > .card { | |||
display: flex; | |||
margin: 0.875em 0.5em; | |||
float: none; | |||
} | |||
.ui.cards > .card > .content, | |||
.ui.card > .content { | |||
border-top: 1px solid var(--color-secondary); | |||
max-width: 100%; | |||
padding: 1em; | |||
font-size: 1em; | |||
} | |||
.ui.cards > .card > .content > .meta + .description, | |||
.ui.cards > .card > .content > .header + .description, | |||
.ui.card > .content > .meta + .description, | |||
.ui.card > .content > .header + .description { | |||
margin-top: .5em; | |||
} | |||
.ui.cards > .card > .content > .header:not(.ui), | |||
.ui.card > .content > .header:not(.ui) { | |||
font-weight: 500; | |||
font-size: 1.28571429em; | |||
margin-top: -.21425em; | |||
line-height: 1.28571429em; | |||
} | |||
.ui.cards > .card > .content:first-child, | |||
.ui.card > .content:first-child { | |||
border-top: none; | |||
border-radius: var(--border-radius) var(--border-radius) 0 0; | |||
} | |||
.ui.cards > .card > :last-child, | |||
.ui.card > :last-child { | |||
border-radius: 0 0 var(--border-radius) var(--border-radius); | |||
} | |||
.ui.cards > .card > :only-child, | |||
.ui.card > :only-child { | |||
border-radius: var(--border-radius) !important; | |||
} | |||
.ui.cards > .card > .extra, | |||
.ui.card > .extra, | |||
.ui.cards > .card > .extra a:not(.ui), | |||
.ui.card > .extra a:not(.ui) { | |||
color: var(--color-text); | |||
} | |||
.ui.cards > .card > .extra a:not(.ui):hover, | |||
.ui.card > .extra a:not(.ui):hover { | |||
color: var(--color-primary); | |||
} | |||
.ui.cards > .card > .content > .header, | |||
.ui.card > .content > .header { | |||
color: var(--color-text); | |||
} | |||
.ui.cards > .card > .content > .description, | |||
.ui.card > .content > .description { | |||
color: var(--color-text); | |||
} | |||
.ui.cards > .card .meta > a:not(.ui), | |||
.ui.card .meta > a:not(.ui) { | |||
color: var(--color-text-light-2); | |||
} | |||
.ui.cards > .card .meta > a:not(.ui):hover, | |||
.ui.card .meta > a:not(.ui):hover { | |||
color: var(--color-text); | |||
} | |||
.ui.cards a.card:hover, | |||
a.ui.card:hover { | |||
border: 1px solid var(--color-secondary); | |||
background: var(--color-card); | |||
} | |||
.ui.cards > .card > .extra, | |||
.ui.card > .extra { | |||
color: var(--color-text); | |||
border-top-color: var(--color-secondary-light-1) !important; | |||
} | |||
.ui.three.cards { | |||
margin-left: -1em; | |||
margin-right: -1em; | |||
} | |||
.ui.three.cards > .card { | |||
width: calc(33.33333333333333% - 2em); | |||
margin-left: 1em; | |||
margin-right: 1em; | |||
} |
@@ -39,16 +39,13 @@ | |||
} | |||
.user.profile .ui.card #profile-avatar { | |||
background: none; | |||
padding: 1rem 1rem 0.25rem; | |||
justify-content: center; | |||
} | |||
.user.profile .ui.card #profile-avatar img { | |||
width: 100%; | |||
max-width: 100%; | |||
height: auto; | |||
object-fit: contain; | |||
margin: 0; | |||
} | |||
@media (max-width: 767px) { |
@@ -23,7 +23,6 @@ | |||
"components": [ | |||
"api", | |||
"button", | |||
"card", | |||
"checkbox", | |||
"comment", | |||
"container", |